From d4ec47a4a3e6c494bcd6588b055c1bd98c840953 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 09:39:29 +1100 Subject: [PATCH 1/6] Clear leftover Project item selection when making a new bin. New Bin only selected the folder and started rename, so Clear still targeted clips that were selected before. The trash button and Delete then removed those items and left the bin. Clicking a bin already deselects items; creating one now does the same, and Clear drops the rename field so it does not keep editing a bin that is about to go. --- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/panels/project.rs | 9 ++++++++- crates/ui-egui/tests/project_panel_ui.rs | 23 +++++++++++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 38ee823c..5f898166 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1411,6 +1411,7 @@ impl FilmcraftApp { } // Clear in the Project panel removes the selected bin when no item is selected (#587). let clear = if self.ui.focused == PanelKind::Project && id == "project.delete" { + self.ui.project_panel.rename = None; crate::panels::project_views::clear_params(&self.session.state.project_selection, self.ui.project_panel.selected_bin) } else { None diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 9aef769e..95932cb1 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -625,7 +625,10 @@ fn footer(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, v: &View, action actions.push(("file.newBin".into(), json!({"parent": parent, "panel": pre}))); } "project.delete" => match crate::panels::project_views::clear_params(&app.session.state.project_selection, app.ui.project_panel.selected_bin) { - Some(p) => actions.push((id.into(), p)), + Some(p) => { + app.ui.project_panel.rename = None; + actions.push((id.into(), p)); + } None => app.ui.status = tl!("Select items to clear").into(), }, _ => actions.push((id.into(), json!({}))), @@ -732,6 +735,7 @@ pub fn panel_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui) -> bool { actions.push(("projectPanel.rename".into(), json!({}))); } if item(ui, "delete", tl!("Delete"), clear.is_some(), Some("Backspace")) { + app.ui.project_panel.rename = None; actions.push(("project.delete".into(), clear.unwrap_or_default())); } ui.separator(); @@ -979,6 +983,9 @@ pub fn new_bin(app: &mut FilmcraftApp, params: &Value) -> Result app.ui.expanded_bins.push(p); } let panel = params.get("panel").and_then(Value::as_str).map_or(shown.prefix, str::to_string); + // Clicking a bin already clears the item selection so Clear targets the bin (#587). New Bin + // must do the same: leftover clips would otherwise steal the trash button and Delete. + let _ = app.session.execute("project.select", json!({"items": []})); app.ui.project_panel.selected_bin = Some(bin); app.ui.project_panel.rename = Some(Rename { item: None, bin: Some(bin), text, panel }); Ok(r) diff --git a/crates/ui-egui/tests/project_panel_ui.rs b/crates/ui-egui/tests/project_panel_ui.rs index d4ee713e..25b9e294 100644 --- a/crates/ui-egui/tests/project_panel_ui.rs +++ b/crates/ui-egui/tests/project_panel_ui.rs @@ -570,6 +570,29 @@ fn new_bin_asks_for_its_name() { assert!(d.app().ui.project_panel.rename.is_none()); } +#[test] +fn new_bin_then_clear_removes_the_bin() { + // leftover Project item selection must not keep Clear from removing the new bin + let mut d = project_driver(); + d.exec("project.view.set", json!({"view": "list"})); + d.click("project.button.new-item"); + d.wait_for("project.newItem.file.newColorMatte"); + d.click("project.newItem.file.newColorMatte"); + d.click("colorMatte.ok"); + let items0 = d.app().session.project.items.len(); + let bins0 = d.app().session.project.root.children.len(); + assert!(!d.app().session.state.project_selection.is_empty()); + d.click("project.button.file.newBin"); + d.frames(2); + let bin = d.app().ui.project_panel.selected_bin.expect("the new bin is selected"); + assert!(d.app().session.state.project_selection.is_empty(), "New Bin clears leftover item selection"); + d.click("project.button.project.delete"); + d.frames(2); + assert!(d.app().session.project.root.find_bin(filmcraft_project::BinId(bin)).is_none(), "Clear removes the new bin"); + assert_eq!(d.app().session.project.root.children.len(), bins0); + assert_eq!(d.app().session.project.items.len(), items0, "clips already in the project stay"); +} + // ------------------------------------------------------------------------------------ Media Browser fn media_dir(tag: &str) -> std::path::PathBuf { From 16493a7f70984699d563be14cc7547433f376f9d Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 00:47:09 +0200 Subject: [PATCH 2/6] #813: masks round their corners (rounded rectangle masks) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: Premiere 26's redesigned mask tools give rectangle masks rounded corners; FilmCraft masks only had sharp corners. Fix: MaskPath::with_rounded_corners replaces each sharp corner between straight edges by a circular fillet (two vertices joined by a cubic arc, handles 4/3·tan(φ/4)·r, shrunk to fit half of each edge; hostile radii and indices are clamped or ignored). masks.add takes `cornerRadius` for a rounded rectangle, and the new masks.roundCorners command rounds an existing mask's corners (or the listed vertices) in one undo step, on every Mask Path keyframe so animated paths keep interpolating. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/masks.rs | 44 +++++++++- crates/engine/src/masks_tests.rs | 63 ++++++++++++++ crates/project/src/mask.rs | 144 ++++++++++++++++++++++++++++++- docs/architecture.md | 3 +- docs/gaps.md | 8 +- 5 files changed, 253 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/masks.rs b/crates/engine/src/masks.rs index 2a53cfdf..ffa5a5a1 100644 --- a/crates/engine/src/masks.rs +++ b/crates/engine/src/masks.rs @@ -38,7 +38,7 @@ pub(crate) fn commands() -> Vec { spec( "masks.add", "Create Mask", - r#"{"clip":id?,"effect":index|id?="opacity","shape":"ellipse"|"polygon"|"bezier","path":[[x,y]…]|{vertices}?,"center":[x,y]?,"size":[w,h]?}"#, + r#"{"clip":id?,"effect":index|id?="opacity","shape":"ellipse"|"polygon"|"rectangle"|"bezier","path":[[x,y]…]|{vertices}?,"center":[x,y]?,"size":[w,h]?,"cornerRadius":px?}"#, add, ), spec("masks.remove", "Delete Mask", r#"{"clip":id?,"effect":index|id?,"mask":n?}"#, remove), @@ -64,6 +64,7 @@ pub(crate) fn commands() -> Vec { track, ), spec("masks.toggleVertexSmooth", "Convert Mask Vertex", r#"{"clip":id?,"effect":index|id?,"mask":n?,"vertex":n}"#, toggle_smooth), + spec("masks.roundCorners", "Round Mask Corners", r#"{"clip":id?,"effect":index|id?,"mask":n?,"radius":px,"vertices":[n]?}"#, round_corners), CommandSpec { id: "masks.select", label: "Select Mask", @@ -212,7 +213,12 @@ fn add(s: &mut Session, p: &Value) -> Result { None => match shape.as_str() { "ellipse" => MaskPath::ellipse(center, size * 0.5), "polygon" | "rectangle" | "rect" | "4-point" => { - MaskPath::rect(center.x - size.x / 2.0, center.y - size.y / 2.0, center.x + size.x / 2.0, center.y + size.y / 2.0) + let (x0, y0, x1, y1) = (center.x - size.x / 2.0, center.y - size.y / 2.0, center.x + size.x / 2.0, center.y + size.y / 2.0); + match f64_p(p, "cornerRadius") { + Some(r) if !r.is_finite() || r < 0.0 => return Err(bad("masks.add", "`cornerRadius` is pixels ≥ 0")), + Some(r) => MaskPath::rounded_rect(x0, y0, x1, y1, r), + None => MaskPath::rect(x0, y0, x1, y1), + } } _ => return Err(bad("masks.add", "a bezier mask needs `path` (pen points)")), }, @@ -454,6 +460,40 @@ fn toggle_smooth(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } +/// Round the mask's sharp corners (Premiere 26's rounded rectangle masks): every corner between +/// two straight edges (or only the listed `vertices`) becomes a circular fillet of `radius` +/// pixels, shrunk to fit half of each edge. The same corners are rounded in every Mask Path +/// keyframe so an animated path keeps interpolating. Returns how many corners were rounded. +fn round_corners(s: &mut Session, p: &Value) -> Result { + const ID: &str = "masks.roundCorners"; + let radius = f64_p(p, "radius").filter(|r| r.is_finite() && *r > 0.0).ok_or_else(|| bad(ID, "need `radius` (pixels, > 0)"))?; + let only: Option> = match p.get("vertices") { + None | Some(Value::Null) => None, + Some(v) => { + let list = v.as_array().ok_or_else(|| bad(ID, "`vertices` is a list of vertex indices"))?; + let mut out = list + .iter() + .map(|i| i.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(ID, "`vertices` is a list of vertex indices"))) + .collect::>>()?; + out.sort_unstable(); + out.dedup(); + Some(out) + } + }; + let rounded = edit_mask(s, p, "Round Mask Corners", move |m, t| { + let mut corners = m.path_at(t).roundable_corners(); + if let Some(only) = &only { + corners.retain(|i| only.binary_search(i).is_ok()); + } + if corners.is_empty() { + return Err(bad(ID, "no sharp corner between straight edges to round")); + } + drop_all(m, t, |path| *path = path.with_rounded_corners(radius, &corners)); + Ok(corners.len()) + })?; + Ok(json!({"corners": rounded})) +} + fn select(s: &mut Session, p: &Value) -> Result { if bool_p(p, "none") == Some(true) || p.is_null() || p.as_object().is_some_and(|o| o.is_empty()) { s.state.selected_mask = None; diff --git a/crates/engine/src/masks_tests.rs b/crates/engine/src/masks_tests.rs index 549a64fc..1fc069a8 100644 --- a/crates/engine/src/masks_tests.rs +++ b/crates/engine/src/masks_tests.rs @@ -317,3 +317,66 @@ fn tracking_runs_in_the_background_and_can_be_cancelled() { assert!(n < 48, "stopped early ({n} keyframes)"); assert!(s.execute("masks.track", json!({"direction": "backward"})).is_err(), "nothing before the first frame"); } + +/// #813: a rectangle mask with rounded corners (created rounded, or rounded afterwards) cuts the +/// clip's corners away while the edges stay where they were; one undo step, every keyframe. +#[test] +fn rounded_rectangle_masks_cut_their_corners() { + let (mut s, clip) = demo(); + // a 960×540 opacity mask in the middle of the 1920×1080 frame, rounded by 200 px + s.execute("masks.add", json!({"clip": clip.0, "shape": "rectangle", "center": [960, 540], "size": [960, 540], "cornerRadius": 200})).unwrap(); + s.execute("masks.set", json!({"feather": 0})).unwrap(); + let list = s.execute("masks.list", json!({})).unwrap(); + assert_eq!(list["masks"][0]["path"]["vertices"].as_array().unwrap().len(), 8); + let img = s.render_program(0.25).unwrap(); + // just inside the rectangle's corner: cut away; middle of an edge and the centre: kept + let corner = px(&img, 0.26, 0.26); + assert!(corner[0] + corner[1] + corner[2] < 1e-4, "rounded corner cut: {corner:?}"); + assert!(px(&img, 0.5, 0.26)[3] > 0.99, "top edge kept"); + assert!(px(&img, 0.5, 0.5)[3] > 0.99, "inside kept"); + + // round an existing 4-point mask: same picture, one undo step back to sharp corners + s.undo(); + s.undo(); + s.execute("masks.add", json!({"clip": clip.0, "shape": "polygon", "center": [960, 540], "size": [960, 540]})).unwrap(); + s.execute("masks.set", json!({"feather": 0})).unwrap(); + let sharp = s.render_program(0.25).unwrap(); + assert!(px(&sharp, 0.26, 0.26)[3] > 0.99, "a sharp corner keeps the clip"); + let r = s.execute("masks.roundCorners", json!({"radius": 200})).unwrap(); + assert_eq!(r["corners"], 4); + assert_eq!(s.render_program(0.25).unwrap().px, img.px, "rounding afterwards = creating rounded"); + // nothing left to round; bad radii are refused without changing the mask + assert!(s.execute("masks.roundCorners", json!({"radius": 50})).is_err()); + for radius in [json!(0), json!(-5), json!("big"), json!(null)] { + assert!(s.execute("masks.roundCorners", json!({"radius": radius})).is_err(), "{radius}"); + } + s.undo(); + assert_eq!(s.render_program(0.25).unwrap().px, sharp.px, "one undo step restores the sharp corners"); +} + +#[test] +fn rounding_an_animated_mask_rounds_every_keyframe_and_only_the_chosen_corners() { + let (mut s, clip) = demo(); + let it = s.active_sequence().unwrap().find_item(clip).unwrap().1.clone(); + s.set_playhead(it.start); + s.execute("masks.add", json!({"effect": "opacity", "shape": "polygon", "center": [400, 400], "size": [200, 200]})).unwrap(); + s.execute("effects.toggleAnimation", json!({"clip": clip.0, "effect": "opacity", "mask": 0, "param": "path"})).unwrap(); + let rate = s.active_sequence().unwrap().settings.frame_rate; + s.set_playhead(it.start + rate.tick_of(10)); + s.execute("masks.translate", json!({"delta": [300, 0]})).unwrap(); + // only vertices 0 and 2 (top-left, bottom-right), out-of-range indices ignored + let r = s.execute("masks.roundCorners", json!({"radius": 30, "vertices": [0, 2, 99]})).unwrap(); + assert_eq!(r["corners"], 2); + let m = &s.active_sequence().unwrap().find_item(clip).unwrap().1.effects.iter().find(|e| e.effect == "opacity").unwrap().masks[0]; + assert_eq!(m.path.keyframes.len(), 2); + for k in &m.path.keyframes { + let filmcraft_project::ParamValue::Path(p) = &k.value else { panic!("path keyframe") }; + assert_eq!(p.len(), 6, "both keyframes rounded the same corners"); + assert_eq!(p.vertices.iter().filter(|v| v.is_corner()).count(), 2, "vertices 1 and 3 stay sharp"); + } + // halfway the path still interpolates (equal vertex counts) + let l = s.execute("masks.list", json!({"time": (it.start + rate.tick_of(5)).0})).unwrap(); + let v = l["masks"][0]["path"]["vertices"].as_array().unwrap(); + assert_eq!(v.len(), 6); + assert!((v[0]["p"][0].as_f64().unwrap() - 450.0).abs() < 1e-6, "{}", v[0]); +} diff --git a/crates/project/src/mask.rs b/crates/project/src/mask.rs index d2266b4d..764a27a5 100644 --- a/crates/project/src/mask.rs +++ b/crates/project/src/mask.rs @@ -1,9 +1,12 @@ -//! Effect masks (Premiere's per-effect masks: ellipse, 4-point polygon, free-draw Bézier). +//! Effect masks (Premiere's per-effect masks: ellipse, 4-point polygon or rounded rectangle, +//! free-draw Bézier). //! //! Every mask is a closed cubic Bézier path in **clip pixel space** (full-resolution source //! pixels, the space of effect point parameters). An ellipse is four smooth vertices with circular -//! tangents and a polygon is vertices with zero-length tangents, so all three tools produce the same -//! [`MaskPath`] and the same rasterizer, keyframe interpolation and tracker apply to each. +//! tangents, a polygon is vertices with zero-length tangents and a rounded corner is a pair of +//! vertices joined by a circular-arc cubic ([`MaskPath::with_rounded_corners`]), so all the tools +//! produce the same [`MaskPath`] and the same rasterizer, keyframe interpolation and tracker apply +//! to each. //! //! A mask has a keyframable *Mask Path* (vertex-wise interpolation when the vertex counts match, //! hold otherwise), *Feather* (falloff width in pixels, centred on the edge), *Opacity* (%), @@ -80,6 +83,86 @@ impl MaskPath { Self::polygon(&[Vec2::new(x0, y0), Vec2::new(x1, y0), Vec2::new(x1, y1), Vec2::new(x0, y1)]) } + /// An axis-aligned rectangle with corners rounded by `radius` (Premiere 26's rounded + /// rectangle mask). `radius` ≤ 0 (or not finite) gives the plain 4-point [`MaskPath::rect`]. + pub fn rounded_rect(x0: f64, y0: f64, x1: f64, y1: f64, radius: f64) -> Self { + let r = Self::rect(x0, y0, x1, y1); + if radius.is_finite() && radius > 0.0 { r.with_rounded_corners(radius, &[0, 1, 2, 3]) } else { r } + } + + /// Indices of the sharp corners [`MaskPath::with_rounded_corners`] can round: corner vertices + /// of a closed path whose two segments are straight lines. + pub fn roundable_corners(&self) -> Vec { + let n = self.vertices.len(); + if !self.closed || n < 3 { + return Vec::new(); + } + (0..n) + .filter(|&i| { + let (Some(prev), Some(v), Some(next)) = (self.vertices.get((i + n - 1) % n), self.vertices.get(i), self.vertices.get((i + 1) % n)) else { + return false; + }; + v.is_corner() && is_zero(&prev.t_out) && is_zero(&next.t_in) + }) + .collect() + } + + /// The path with each listed corner replaced by a circular fillet of `radius`: two vertices on + /// the adjacent edges joined by a cubic arc (handles `4/3·tan(φ/4)·r` for a turn of φ, so a + /// right angle gets the [`KAPPA`] quarter circle). The fillet is shrunk so it never takes more + /// than half of either edge. Every listed roundable corner ([`MaskPath::roundable_corners`]) + /// becomes two vertices, coincident when its geometry is degenerate (a straight or reversing + /// corner, a zero-length edge), so applying the same list to each keyframe of an animated path + /// keeps the vertex counts equal. Other indices (out of range, curved, repeated) are left alone. + pub fn with_rounded_corners(&self, radius: f64, corners: &[usize]) -> Self { + let n = self.vertices.len(); + let mut round = vec![false; n]; + for &i in corners { + if let Some(r) = round.get_mut(i) { + *r = true; + } + } + let roundable = self.roundable_corners(); + round.iter_mut().enumerate().for_each(|(i, r)| *r &= roundable.binary_search(&i).is_ok()); + let radius = if radius.is_finite() { radius.clamp(0.0, 1e7) } else { 0.0 }; + let mut vertices = Vec::with_capacity(n.saturating_mul(2)); + for (i, v) in self.vertices.iter().enumerate() { + if !round.get(i).copied().unwrap_or(false) { + vertices.push(*v); + continue; + } + let (Some(prev), Some(next)) = (self.vertices.get((i + n - 1) % n), self.vertices.get((i + 1) % n)) else { + vertices.push(*v); + continue; + }; + let (a, b) = (v.p - prev.p, next.p - v.p); + let (la, lb) = (a.length(), b.length()); + let fillet = (la > 1e-9 && lb > 1e-9 && radius > 0.0).then(|| (a * (1.0 / la), b * (1.0 / lb))).and_then(|(u, w)| { + // turn angle φ between the incoming and outgoing directions + let phi = (u.x * w.x + u.y * w.y).clamp(-1.0, 1.0).acos(); + if !(1e-6..std::f64::consts::PI - 1e-6).contains(&phi) { + return None; + } + let tan_half = (phi / 2.0).tan(); + let d = (radius * tan_half).min(la / 2.0).min(lb / 2.0); + let r = d / tan_half; + let h = 4.0 / 3.0 * (phi / 4.0).tan() * r; + Some((u, w, d, h)) + }); + match fillet { + Some((u, w, d, h)) => { + vertices.push(MaskVertex { p: v.p - u * d, t_in: Vec2::ZERO, t_out: u * h }); + vertices.push(MaskVertex { p: v.p + w * d, t_in: w * -h, t_out: Vec2::ZERO }); + } + None => { + vertices.push(MaskVertex::corner(v.p)); + vertices.push(MaskVertex::corner(v.p)); + } + } + } + Self { vertices, closed: self.closed } + } + pub fn len(&self) -> usize { self.vertices.len() } @@ -439,4 +522,59 @@ mod tests { let back: Mask = serde_json::from_str(&s).unwrap(); assert_eq!(back, m); } + + #[test] + fn rounded_rect_corners_are_quarter_circles() { + let p = MaskPath::rounded_rect(0.0, 0.0, 100.0, 60.0, 20.0); + assert_eq!(p.len(), 8); + // top-left corner: a quarter circle of radius 20 about (20, 20); the edges stay straight + let pts = p.flatten(0.01); + let mut on_arc = 0; + for q in &pts { + let (cx, cy) = (q.x.clamp(20.0, 80.0), q.y.clamp(20.0, 40.0)); + let d = ((q.x - cx).powi(2) + (q.y - cy).powi(2)).sqrt(); + if (q.x - cx).abs() > 1e-9 && (q.y - cy).abs() > 1e-9 { + on_arc += 1; + assert!((d - 20.0).abs() < 0.03, "{q:?} is {d} from its corner centre"); + } + assert!(q.x >= -1e-9 && q.x <= 100.0 + 1e-9 && q.y >= -1e-9 && q.y <= 60.0 + 1e-9, "{q:?} outside the rectangle"); + } + assert!(on_arc >= 8, "the corners are curved: {on_arc} arc points"); + // the sharp corner itself is cut away + assert!(pts.iter().all(|q| q.x + q.y > 5.0), "the corner at (0, 0) is rounded off"); + // radius 0 is the plain rectangle + assert_eq!(MaskPath::rounded_rect(0.0, 0.0, 10.0, 5.0, 0.0), MaskPath::rect(0.0, 0.0, 10.0, 5.0)); + } + + #[test] + fn rounding_clamps_to_half_an_edge_and_ignores_curves_and_hostile_input() { + // a radius larger than the shape gives a stadium: fillets meet at the edges' midpoints + let p = MaskPath::rounded_rect(0.0, 0.0, 100.0, 40.0, 1e9); + assert_eq!(p.len(), 8); + for v in &p.vertices { + assert!(v.p.x.is_finite() && v.p.y.is_finite()); + } + assert!(p.vertices.iter().any(|v| (v.p - Vec2::new(0.0, 20.0)).length() < 1e-9), "{:?}", p.vertices); + // a triangle: the 60° turns get fillets tangent to both edges, within the triangle + let tri = MaskPath::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(100.0, 0.0), Vec2::new(50.0, 86.6)]); + let r = tri.with_rounded_corners(10.0, &tri.roundable_corners()); + assert_eq!(r.len(), 6); + assert!(r.vertices.iter().all(|v| v.p.y >= -1e-9)); + // an ellipse has no sharp corners; NaN / negative radii and bad indices change nothing harmful + let e = MaskPath::ellipse(Vec2::new(50.0, 50.0), Vec2::new(30.0, 20.0)); + assert!(e.roundable_corners().is_empty()); + assert_eq!(e.with_rounded_corners(10.0, &[0, 1, 2, 3]), e); + let sq = MaskPath::rect(0.0, 0.0, 10.0, 10.0); + assert_eq!(sq.with_rounded_corners(5.0, &[usize::MAX, 99]), sq); + for bad in [f64::NAN, f64::INFINITY, -3.0, 0.0] { + let q = sq.with_rounded_corners(bad, &[0]); + assert_eq!(q.len(), 5, "a listed corner always splits in two"); + assert!(q.vertices.iter().all(|v| v.is_corner() && v.p.x.is_finite())); + } + // a collinear vertex splits into two coincident corners, keeping vertex counts stable + let line = MaskPath::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(5.0, 0.0), Vec2::new(10.0, 0.0), Vec2::new(5.0, 5.0)]); + let q = line.with_rounded_corners(2.0, &[1]); + assert_eq!(q.len(), 5); + assert_eq!(q.vertices[1].p, q.vertices[2].p); + } } diff --git a/docs/architecture.md b/docs/architecture.md index ed1486af..8b09b678 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -529,7 +529,8 @@ with different vertex counts hold). Feather, Opacity and Expansion are ordinary - **GPU.** `filmcraft-gpu::GpuMask` evaluates the same coverage and mix in WGSL (compute), tested to agree with the CPU within 3·10⁻⁶. Layers with masks are CPU-rendered images in frame plans. - **Editing.** `masks.*` commands (add / remove / set / moveVertex / translate / addVertex / - removeVertex / toggleVertexSmooth / select / list); keyframe commands take `"mask": n`. The + removeVertex / toggleVertexSmooth / roundCorners / select / list; `masks.add` takes a + `cornerRadius` for a rounded rectangle); keyframe commands take `"mask": n`. The Program monitor overlay drags vertices, Bézier handles, the whole mask and the feather / expansion handles; drags merge into one undo step. - **Tracking** (`render::track`, `masks.track`): Shi–Tomasi features inside the mask, pyramidal diff --git a/docs/gaps.md b/docs/gaps.md index aec13ce2..c5c87ce5 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -228,11 +228,13 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** ## G18. Effects depth: approximations and missing behaviour - **Missing:** Warp Stabilizer is 2-D (Premiere's is 3-D subspace), Morph Cut approximate, Lumetri - HDR-aware maths, multicam paging beyond 16 angles, redesigned 26.0 mask tools (rounded corners, - constrained lines). + HDR-aware maths, multicam paging beyond 16 angles, redesigned 26.0 mask tools (constrained + lines; an on-monitor corner-roundness handle). - **Have:** Optical Flow time interpolation synthesises motion-compensated in-between frames (dense coarse-to-fine Lucas–Kanade, CPU; #780); Transform shutter angle motion blur (#415); Ultra Key - Setting applied as a parameter preset (#458). + Setting applied as a parameter preset (#458); rectangle and polygon masks round their corners + with circular fillets (`masks.add` `cornerRadius`, `masks.roundCorners`, every path keyframe; + #813). - **Estimate:** 60–110 h. ## G19. Speech to text off in release builds From 5f9908905c530d4cd6e217d230f8583287db5504 Mon Sep 17 00:00:00 2001 From: ReReddo <249398616+ReReddo@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:55:26 +0300 Subject: [PATCH 3/6] monitors: the wheel zooms about the pointer, the middle button pans (#807) Zooming a monitor took the zoom dropdown, which zooms about the middle of the picture, and then scrolling or the Hand tool to bring the part you wanted into view; at Fit the wheel did nothing. Now, on the Program and Source monitors: - The mouse wheel (or a pinch) zooms about the pointer, from Fit too, between the dropdown's 10% and 1600%: the point under the pointer stays under it. It zooms as fast as the Timeline's wheel zoom, through the same wheel_input. - A middle-button drag that starts over the picture pans it, whatever the tool; the Hand tool still pans with the left button. - Fit resets zoom and pan, and each monitor keeps its own, as before. Comparison View, which always fits both frames, ignores the wheel. Tests: view_ui (wheel zoom about the pointer, middle-button pan, the 10% floor, Fit, Source untouched). --- crates/ui-egui/src/panels/monitor.rs | 5 +- crates/ui-egui/src/panels/monitor_view.rs | 39 +++++++++++++-- crates/ui-egui/src/panels/timeline.rs | 8 ++-- crates/ui-egui/tests/view_ui.rs | 58 +++++++++++++++++++++++ docs/monitors.md | 2 +- 5 files changed, 100 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/panels/monitor.rs b/crates/ui-egui/src/panels/monitor.rs index a57d0483..36930535 100644 --- a/crates/ui-egui/src/panels/monitor.rs +++ b/crates/ui-egui/src/panels/monitor.rs @@ -287,8 +287,9 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) // Click/drag in the picture: the Hand tool pans a magnified picture, otherwise focus. let pic_resp = ui.interact(pic, egui::Id::new((prefix, "pic")), if show_picture { Sense::click_and_drag() } else { Sense::hover() }); app.auto.add(&format!("{prefix}.picture"), pic.intersect(video_area), "picture"); - if show_picture { - monitor_view::pan_input(app, ui, which, video_area, pic); + // (Comparison View always fits both frames) + if show_picture && !compare { + monitor_view::zoom_pan_input(app, ui, which, video_area, pic, display_w, ppp); } if which == Which::Program && show_picture { eyedropper(app, ui, &pic_resp, pic, target, rate, time); diff --git a/crates/ui-egui/src/panels/monitor_view.rs b/crates/ui-egui/src/panels/monitor_view.rs index 15e6ab6f..501ead7a 100644 --- a/crates/ui-egui/src/panels/monitor_view.rs +++ b/crates/ui-egui/src/panels/monitor_view.rs @@ -1,7 +1,8 @@ //! Monitor view options (the View menu and the monitors' wrench menus): Playback / Paused //! Resolution, High Quality Playback, Display Mode (Composite Video, Alpha, Red, Green, Blue, //! Multi-Camera, Audio Waveform, Comparison View, Video and Audio Waveform Split), Magnification -//! (Fit, 10%…1600%, scroll or Hand-tool panning), rulers, guides (dragged out of the rulers, Add +//! (Fit, 10%…1600%, the wheel zooming about the pointer, middle-button or Hand-tool panning), +//! rulers, guides (dragged out of the rulers, Add //! Guide…, Clear Guides, Lock Guides, templates saved in the user preferences) and Snap in Program //! Monitor (graphic drags snap to guides, the frame edges and centre). //! @@ -354,14 +355,42 @@ pub fn picture_rect(area: Rect, fw: f32, fh: f32, v: &MonitorView, ppp: f32) -> } } -/// Pan a magnified picture: scroll over it, or drag with the Hand tool. -pub fn pan_input(app: &mut FilmcraftApp, ui: &mut egui::Ui, w: Which, area: Rect, pic: Rect) { +/// Zoom and pan the picture `pic` shown in `area`: the wheel (or a pinch) zooms about the pointer, +/// from Fit too, between the zoom menu's 10% and 1600%; a middle-button drag, or a drag with the +/// Hand tool, pans a magnified picture. `fw` is the picture's width in frame pixels. +pub fn zoom_pan_input(app: &mut FilmcraftApp, ui: &mut egui::Ui, w: Which, area: Rect, pic: Rect, fw: f32, ppp: f32) { + let ctx = ui.ctx().clone(); + if ui.rect_contains_pointer(area) + && let Some(p) = ctx.pointer_hover_pos() + { + // as fast as the Timeline's own wheel zoom + let wheel = crate::panels::timeline::wheel_input(&ctx); + let f = if (wheel.pinch - 1.0).abs() > 1e-4 { wheel.pinch } else { (1.0 + wheel.delta.y * 0.01).clamp(0.5, 2.0) }; + if (f - 1.0).abs() > 1e-4 && pic.width() > 0.0 && fw > 0.0 { + let cur = pic.width() * ppp / fw; + let z = (cur * f).clamp(ZOOMS[0].1, ZOOMS[ZOOMS.len() - 1].1); + let k = z / cur; + // the point of the picture under the pointer stays under it + let centre = p - (p - pic.center()) * k; + let max = ((pic.size() * k - area.size()) / 2.0).max(Vec2::ZERO); + let v = view_mut(app, w); + v.zoom = Some(z); + v.pan = [(centre.x - area.center().x).clamp(-max.x, max.x), (centre.y - area.center().y).clamp(-max.y, max.y)]; + return; + } + } if view(app, w).zoom.is_none() { return; } let mut d = Vec2::ZERO; - if ui.rect_contains_pointer(area) { - d += ui.input(|i| i.smooth_scroll_delta); + // a middle-button drag that began over the picture pans it, whatever the tool + let held = egui::Id::new((prefix(w), "middle-pan")); + let (pressed, down, moved) = ui.input(|i| (i.pointer.button_pressed(egui::PointerButton::Middle), i.pointer.middle_down(), i.pointer.delta())); + let panning = down && (ui.data(|m| m.get_temp::(held)).unwrap_or(false) || (pressed && ui.rect_contains_pointer(area))); + ui.data_mut(|m| m.insert_temp(held, panning)); + if panning { + ctx.set_cursor_icon(egui::CursorIcon::Grabbing); + d += moved; } if app.ui.tool == Tool::Hand { let resp = ui.interact(area, egui::Id::new((prefix(w), "pan")), Sense::drag()); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index ca4fab0d..70cde2b6 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1902,11 +1902,11 @@ fn multicam_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, picked: &[&TrackItem } /// What the wheel and the trackpad did this frame. -struct WheelInput { +pub(crate) struct WheelInput { /// Scroll in points (wheel lines and pages converted), positive = content moves right / down. - delta: egui::Vec2, + pub(crate) delta: egui::Vec2, /// Pinch factor (1 = none). - pinch: f32, + pub(crate) pinch: f32, command: bool, alt: bool, shift: bool, @@ -1915,7 +1915,7 @@ struct WheelInput { /// The wheel events of this frame, read from the events themselves: egui turns Cmd + wheel into a /// zoom and Shift + wheel into a sideways scroll before anyone asks, and the Timeline has its own /// meaning for both. The modifiers are those of the events (a script's wheel carries its own). -fn wheel_input(ctx: &egui::Context) -> WheelInput { +pub(crate) fn wheel_input(ctx: &egui::Context) -> WheelInput { ctx.input(|i| { let mut w = WheelInput { delta: egui::Vec2::ZERO, pinch: 1.0, command: false, alt: false, shift: false }; for e in &i.events { diff --git a/crates/ui-egui/tests/view_ui.rs b/crates/ui-egui/tests/view_ui.rs index 1339ad6c..92b34a1f 100644 --- a/crates/ui-egui/tests/view_ui.rs +++ b/crates/ui-egui/tests/view_ui.rs @@ -183,6 +183,64 @@ fn view_menu_resolutions_and_display_modes() { assert_eq!((s["display"].as_str(), s["show_rulers"].as_bool()), (Some("Composite"), Some(true))); } +fn num(v: &Value) -> f64 { + v.as_f64().unwrap_or_else(|| panic!("not a number: {v}")) +} + +/// #807: the wheel over a monitor zooms about the pointer, a middle-button drag pans, Fit resets, +/// and each monitor keeps its own view. +#[test] +fn wheel_zooms_about_the_pointer_and_the_middle_button_pans() { + let mut d = Driver::demo(); + // at 100% the picture is larger than the monitor, so the element is the monitor's area + d.menu("view.magnification.100"); + let area = d.rect("program.picture"); + let centre = (area[0] + area[2] / 2.0, area[1] + area[3] / 2.0); + let at = (area[0] + area[2] * 0.25, area[1] + area[3] * 0.4); + // 50 points up: 1.5 times; the point under the pointer stays under it + d.ok("ui.scroll", json!({"x": at.0, "y": at.1, "dx": 0.0, "dy": 50.0})); + d.frames(2); + let p = d.program(); + assert!((num(&p["zoom"]) - 1.5).abs() < 1e-4, "{p}"); + let want = ((centre.0 - at.0) * 0.5, (centre.1 - at.1) * 0.5); + assert!((num(&p["pan"][0]) - want.0).abs() < 1.0 && (num(&p["pan"][1]) - want.1).abs() < 1.0, "pan {} for {want:?}", p["pan"]); + + // the middle button drags the picture, whatever the tool + let (zoom, pan) = (num(&p["zoom"]), [num(&p["pan"][0]), num(&p["pan"][1])]); + let send = |d: &mut Driver, e: egui::Event| { + d.harness.input_mut().events.push(e); + d.frames(1); + }; + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Middle, pressed, modifiers: Default::default() }; + let from = egui::pos2(centre.0 as f32, centre.1 as f32); + send(&mut d, egui::Event::PointerMoved(from)); + send(&mut d, button(from, true)); + for k in 1..=4 { + send(&mut d, egui::Event::PointerMoved(from + egui::vec2(-10.0 * k as f32, 5.0 * k as f32))); + } + send(&mut d, button(from + egui::vec2(-40.0, 20.0), false)); + let p = d.program(); + assert_eq!(num(&p["zoom"]), zoom, "a pan does not zoom"); + assert!((num(&p["pan"][0]) - (pan[0] - 40.0)).abs() < 1.0 && (num(&p["pan"][1]) - (pan[1] + 20.0)).abs() < 1.0, "pan {} after {pan:?}", p["pan"]); + + // zooming out stops at the menu's smallest level; Fit resets zoom and pan + for _ in 0..12 { + d.ok("ui.scroll", json!({"x": centre.0, "y": centre.1, "dx": 0.0, "dy": -50.0})); + } + d.frames(2); + assert!((num(&d.program()["zoom"]) - 0.1).abs() < 1e-6, "stops at 10%"); + d.menu("view.magnification.fit"); + let p = d.program(); + assert!(p["zoom"].is_null() && p["pan"] == json!([0.0, 0.0]), "{p}"); + + // from Fit, the wheel zooms in too; the Source monitor keeps its own view + d.ok("ui.scroll", json!({"x": centre.0, "y": centre.1, "dx": 0.0, "dy": 20.0})); + d.frames(2); + assert!(d.program()["zoom"].as_f64().is_some(), "zoomed in from Fit"); + let source = d.ok("ui.inspect", json!({}))["ui"]["source"].clone(); + assert!(source["zoom"].is_null() && source["pan"] == json!([0.0, 0.0]), "{source}"); +} + #[test] fn magnification_pans_and_guides_from_rulers() { let mut d = Driver::demo(); diff --git a/docs/monitors.md b/docs/monitors.md index 3610c9ea..f8cdd36d 100644 --- a/docs/monitors.md +++ b/docs/monitors.md @@ -15,7 +15,7 @@ focused monitor (Program by default). Code: `crates/ui-egui/src/panels/monitor_v | `view.display.multicam` | Display Mode ▸ Multi-Camera | the Program's multi-camera view (`multicam.toggleView`) | | `view.display.audioWaveform`, `view.display.videoAndWaveform` | Display Mode ▸ Audio Waveform / Video and Audio Waveform Split | Source Monitor: the clip's waveform (click to move the source playhead); audio-only clips always show it | | `view.display.comparison` | Display Mode ▸ Comparison View | Program: the reference frame (left) beside the current frame; the reference starts at the playhead and is stepped or reset with the buttons under it, or `view.compare.setReference {time\|seconds}` | -| `view.magnification.` | View ▸ Magnification and the zoom dropdown | 100% = one frame pixel per screen pixel; scroll or drag with the Hand tool to pan | +| `view.magnification.` | View ▸ Magnification and the zoom dropdown | 100% = one frame pixel per screen pixel. The mouse wheel (or a pinch) over a monitor zooms about the pointer, from Fit too, within 10%–1600%; drag with the middle mouse button, or with the Hand tool, to pan; Fit resets both. Each monitor keeps its own zoom and pan | | `view.showRulers`, `view.showGuides`, `view.lockGuides`, `view.clearGuides` | View | toggles take `{"enabled": bool}` | | `view.addGuide` | View ▸ Add Guide… | dialog, or `{"orientation": "vertical\|horizontal", "position": px}` | | `view.snapInProgramMonitor` | View ▸ Snap in Program Monitor | graphic moves snap to the frame edges / centre and the guides (6 pt) | From b9f60842bbd49a9a247ae389cb170d8e32edf8ab Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 00:56:35 +0200 Subject: [PATCH 4/6] #814: AAC Audio export (audio-only .m4a, or ADTS .aac) Problem: Premiere's "AAC Audio" format has no FilmCraft equivalent. AAC was only written inside H.264 / H.265 / AV1 movies; the only audio-only exports were Waveform Audio and AIFF. Fix: a new `aac` export format ("AAC Audio") that encodes the sequence mix with the existing AAC-LC encoder into an MPEG-4 audio file (.m4a, encoder delay in an edit list), or a raw ADTS stream when the path ends in .aac. Bitrate, sample rate, channels and loudness normalisation follow the audio settings. Adds an "AAC Audio 48 kHz 320 kbps" Audio Only preset, CLI extension guessing (.m4a / .aac), es/fr/ja/zh-cn strings, and updates G13 (also noting ProRes 4444 / XQ is already in the export UI). Co-Authored-By: Claude Opus 5.5 --- apps/filmcraft-cli/src/main.rs | 3 +- crates/engine/src/commands.rs | 2 +- crates/engine/src/export_tests.rs | 29 ++++ crates/engine/src/export_tools.rs | 3 +- crates/export/src/aac_out.rs | 201 ++++++++++++++++++++++++++++ crates/export/src/lib.rs | 17 ++- crates/export/src/presets.rs | 10 ++ crates/export/src/settings.rs | 10 +- crates/export/src/settings_tests.rs | 99 +++++++++++++- crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/zh-cn.tsv | 3 + docs/architecture.md | 1 + docs/file-format-parity.md | 2 +- docs/gaps.md | 6 +- 16 files changed, 382 insertions(+), 13 deletions(-) create mode 100644 crates/export/src/aac_out.rs diff --git a/apps/filmcraft-cli/src/main.rs b/apps/filmcraft-cli/src/main.rs index 9ead306d..10592444 100644 --- a/apps/filmcraft-cli/src/main.rs +++ b/apps/filmcraft-cli/src/main.rs @@ -79,7 +79,7 @@ SUBCOMMANDS [--scale f] [--quality 0-100] [--no-audio] [--queue] export the active sequence and wait for it to finish: with an export preset (`export --list-presets`; built-in or the user's), or - a format (h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|gif|wav|aiff, guessed + a format (h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|gif|wav|aiff|aac, guessed from the extension); --range entire|inOut|workArea, or a custom range in seconds; --settings is ExportSettings JSON merged over the preset; --scale renders at a fraction of the frame size (0.5 = @@ -209,6 +209,7 @@ fn format_for(path: &str) -> Option<&'static str> { "tif" | "tiff" => "tiff", "bmp" => "bmp", "aif" | "aiff" => "aiff", + "m4a" | "aac" => "aac", _ => return None, }) } diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index e3b162fd..c31894f3 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1285,7 +1285,7 @@ fn build() -> Vec { "Media…", ["File", "Export"], None, - r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|jpg|tga|dpx|gif|wav|aiff"?,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass|crf"?,"crf":0..51=23,"hardwareEncoding":"off|auto"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"alpha":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq|4444|4444xq"?,"dnxProfile":"lb|sq|hq|hqx"?,"apvProfile":"422-10|422-12|444-10|444-12"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#, + r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|jpg|tga|dpx|gif|wav|aiff|aac"?,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass|crf"?,"crf":0..51=23,"hardwareEncoding":"off|auto"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"alpha":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq|4444|4444xq"?,"dnxProfile":"lb|sq|hq|hqx"?,"apvProfile":"422-10|422-12|444-10|444-12"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#, has_seq, crate::export_tools::export_media ), diff --git a/crates/engine/src/export_tests.rs b/crates/engine/src/export_tests.rs index 6a8c5d71..82245e97 100644 --- a/crates/engine/src/export_tests.rs +++ b/crates/engine/src/export_tests.rs @@ -177,6 +177,16 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() { assert!((num(&j["format"]["duration"]) - 0.25).abs() < 0.01, "{name}: {}", j["format"]["duration"]); continue; } + "aac" => { + // AAC Audio: an MPEG-4 audio file, sound only + assert!(out.ends_with(".m4a"), "{name}: {out}"); + assert!(fmt_name.contains("m4a"), "{name}: {fmt_name}"); + assert_eq!(j["streams"].as_array().unwrap().len(), 1, "{name}"); + let a = stream(&j, "audio").unwrap(); + assert_eq!((a["codec_name"].as_str(), a["sample_rate"].as_str(), a["channels"].as_u64()), (Some("aac"), Some("48000"), Some(2)), "{name}"); + assert!((num(&j["format"]["duration"]) - 0.25).abs() < 0.03, "{name}: {}", j["format"]["duration"]); + continue; + } "gif" => { let v = stream(&j, "video").unwrap(); assert_eq!(v["codec_name"], "gif"); @@ -281,6 +291,25 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() { /// H.264 bitrate control over three seconds of the demo: the adaptive Match Source preset /// targets width × height × fps × bpp, the 1080p delivery presets their fixed targets; ffprobe's /// measured video bitrate stays within the VBR maximum and close to the target. +#[test] +fn aac_audio_keeps_a_dot_aac_path_as_adts_and_adds_m4a_otherwise() { + let mut s = demo(); + let dir = Scratch::new("aac-audio"); + let range = json!({"range": "custom", "startSeconds": 1.0, "endSeconds": 1.25, "wait": true, "format": "aac"}); + let run = |s: &mut Session, path: String| { + let mut p = range.clone(); + p["path"] = json!(path); + s.execute("file.exportMedia", p).unwrap()["path"].as_str().unwrap().to_string() + }; + let adts = run(&mut s, dir.path("Mix.aac")); + assert!(adts.ends_with("Mix.aac"), "{adts}"); + let b = std::fs::read(&adts).unwrap(); + assert_eq!((b[0], b[1]), (0xFF, 0xF1), "an ADTS header"); + let m4a = run(&mut s, dir.path("Mix")); + assert!(m4a.ends_with("Mix.m4a"), "{m4a}"); + assert_eq!(&std::fs::read(&m4a).unwrap()[4..8], b"ftyp"); +} + #[test] fn h264_presets_hit_their_bitrates() { let mut s = demo(); diff --git a/crates/engine/src/export_tools.rs b/crates/engine/src/export_tools.rs index 9e37a780..b714e284 100644 --- a/crates/engine/src/export_tools.rs +++ b/crates/engine/src/export_tools.rs @@ -363,7 +363,8 @@ fn with_extension(path: &str, settings: &ExportSettings) -> String { Some(e) if e.eq_ignore_ascii_case(ext) || (settings.format == Format::TiffSequence && e.eq_ignore_ascii_case("tiff")) - || (settings.format == Format::JpegSequence && e.eq_ignore_ascii_case("jpeg")) => + || (settings.format == Format::JpegSequence && e.eq_ignore_ascii_case("jpeg")) + || (settings.format == Format::Aac && e.eq_ignore_ascii_case("aac")) => { path.to_string() } diff --git a/crates/export/src/aac_out.rs b/crates/export/src/aac_out.rs new file mode 100644 index 00000000..0e18b1fd --- /dev/null +++ b/crates/export/src/aac_out.rs @@ -0,0 +1,201 @@ +//! AAC Audio: the sequence mix alone, AAC-LC from the same encoder as the MPEG-4 movies, in an +//! MPEG-4 audio file (`.m4a`, the encoder delay signalled by an edit list) or, when the path ends +//! in `.aac`, a raw ADTS stream (ISO/IEC 14496-3 §1.A.2; one header per access unit, no CRC). ADTS +//! cannot signal the encoder delay, so a player starts it with the priming samples. + +use std::io::Write; +use std::sync::Arc; +use std::sync::atomic::Ordering; + +use filmcraft_isobmff::{Brand, CodecConfig, Mp4Writer, TrackConfig, WriteSample, WriterOptions}; +use filmcraft_project::{ItemId, Project}; +use filmcraft_render::SourceProvider; +use filmcraft_time::TimeRange; + +use crate::audio_out::AudioOut; +use crate::{AudioEncoder, ExportError, ExportSettings, Format, Out, Progress, Result, audio_factories}; + +/// Whether `path` asks for a raw ADTS stream rather than an MPEG-4 file. +pub(crate) fn is_adts_path(path: &str) -> bool { + std::path::Path::new(path).extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("aac")) +} + +/// Where the encoded access units go. +enum Sink { + Mp4 { mux: Mp4Writer, track: usize, frame: u32 }, + Adts { out: Out, header: AdtsHeader }, +} + +impl Sink { + fn write(&mut self, units: Vec>) -> Result<()> { + let io = |e: std::io::Error| ExportError::Io(e.to_string()); + match self { + Sink::Mp4 { mux, track, frame } => { + for au in units { + mux.write_sample(*track, WriteSample { data: &au, duration: *frame, composition_offset: 0, is_sync: true }) + .map_err(|e| ExportError::Io(e.to_string()))?; + } + } + Sink::Adts { out, header } => { + for au in units { + out.write_all(&header.bytes(au.len())?).map_err(io)?; + out.write_all(&au).map_err(io)?; + } + } + } + Ok(()) + } + + fn finish(self, settings: &ExportSettings) -> Result { + match self { + Sink::Mp4 { mux, .. } => mux.finish().map_err(|e| ExportError::Io(e.to_string()))?.finish(settings), + Sink::Adts { out, .. } => out.finish(settings), + } + } +} + +/// The fixed fields of an ADTS header, from the AudioSpecificConfig. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct AdtsHeader { + /// `profile_ObjectType`: the audio object type minus one (1 = AAC-LC). + profile: u8, + sf_index: u8, + channel_config: u8, +} + +impl AdtsHeader { + /// Read the object type, sampling frequency index and channel configuration from an + /// AudioSpecificConfig; ADTS can carry only object types 1–4 with an indexed rate and a + /// channel configuration of 1–7. + pub(crate) fn from_asc(asc: &[u8]) -> Result { + let (Some(&a), Some(&b)) = (asc.first(), asc.get(1)) else { + return Err(ExportError::Encode("AAC: the encoder gave no AudioSpecificConfig".into())); + }; + let object_type = a >> 3; + let sf_index = ((a & 0x07) << 1) | (b >> 7); + let channel_config = (b >> 3) & 0x0F; + if !(1..=4).contains(&object_type) || sf_index > 12 || !(1..=7).contains(&channel_config) { + return Err(ExportError::Unsupported(format!( + "an ADTS .aac stream cannot carry this AAC configuration (object type {object_type}, rate index {sf_index}, channels {channel_config}): export .m4a instead" + ))); + } + Ok(AdtsHeader { profile: object_type - 1, sf_index, channel_config }) + } + + /// The 7-byte header (`protection_absent`) of an access unit of `payload` bytes. + pub(crate) fn bytes(&self, payload: usize) -> Result<[u8; 7]> { + // aac_frame_length is 13 bits and counts the header + let too_big = || ExportError::Encode(format!("AAC: a {payload}-byte frame does not fit ADTS")); + let len = u32::try_from(payload).ok().and_then(|n| n.checked_add(7)).filter(|n| *n < 1 << 13).ok_or_else(too_big)?; + Ok([ + 0xFF, + 0xF1, // syncword, MPEG-4, layer 0, protection absent + (self.profile << 6) | (self.sf_index << 2) | (self.channel_config >> 2), + ((self.channel_config & 0x03) << 6) | ((len >> 11) & 0x03) as u8, + ((len >> 3) & 0xFF) as u8, + (((len & 0x07) << 5) as u8) | 0x1F, // buffer fullness 0x7FF: variable rate + 0xFC, // … and one raw data block + ]) + } +} + +/// Encode the mix of `range` to `settings.path`; returns (bytes written, samples per channel). +pub(crate) fn export( + project: &Arc, + seq: ItemId, + settings: &ExportSettings, + range: TimeRange, + sources: &dyn SourceProvider, + progress: &Progress, +) -> Result<(u64, u64)> { + let cancelled = || progress.cancel.load(Ordering::Relaxed); + let mut a = AudioOut::new(project.clone(), seq, settings, range)?; + let mut enc: Box = audio_factories() + .read() + .unwrap_or_else(|e| e.into_inner()) + .iter() + .find_map(|f| f(Format::Aac, a.sr, a.channels as u32, settings)) + .ok_or_else(|| ExportError::Unsupported("AAC encoder not available".into()))??; + let entry = enc.sample_entry(); + let frames = a.remaining(); + // one second per chunk: memory stays flat however long the range is + let chunk = i64::from(a.sr.max(1)); + if !settings.part_of_batch { + progress.total.store(frames.div_ceil(chunk as u64).max(1), Ordering::Relaxed); + progress.set_status(format!("Exporting audio ({})", settings.format.label())); + } + a.measure(settings, sources, &cancelled)?; + *progress.loudness.lock().unwrap_or_else(|e| e.into_inner()) = a.loudness; + let mut sink = if is_adts_path(&settings.path) { + let CodecConfig::Aac(cfg) = &entry.codec else { + return Err(ExportError::Encode("AAC: the encoder's sample entry is not AAC".into())); + }; + Sink::Adts { header: AdtsHeader::from_asc(&cfg.asc)?, out: Out::create(settings)? } + } else { + let mut opts = WriterOptions::new(Brand::Mp4); + opts.metadata = settings.metadata.udta(); + let mut mux = Mp4Writer::new(Out::create(settings)?, opts).map_err(|e| ExportError::Io(e.to_string()))?; + let mut track = TrackConfig::new(entry, a.sr); + track.media_start = Some(i64::from(enc.priming())); + let track = mux.add_track(track).map_err(|e| ExportError::Io(e.to_string()))?; + Sink::Mp4 { mux, track, frame: enc.frame_size() } + }; + while let Some(planar) = a.pull(a.out.saturating_add(chunk), sources) { + if cancelled() { + return Err(ExportError::Cancelled); + } + sink.write(enc.encode(&planar)?)?; + if !settings.part_of_batch { + progress.done.fetch_add(1, Ordering::Relaxed); + } + } + sink.write(enc.flush()?)?; + let n = sink.finish(settings)?; + if settings.part_of_batch { + progress.done.store(1, Ordering::Relaxed); + } + Ok((n, frames)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn adts_header_fields_follow_the_asc() { + // AAC-LC (2), 48 kHz (index 3), stereo: 00010 0011 0010 000 + let h = AdtsHeader::from_asc(&[0x11, 0x90]).unwrap(); + assert_eq!(h, AdtsHeader { profile: 1, sf_index: 3, channel_config: 2 }); + let b = h.bytes(371).unwrap(); + assert_eq!(&b[..2], &[0xFF, 0xF1]); + assert_eq!(b[2] >> 6, 1); + assert_eq!((b[2] >> 2) & 0x0F, 3); + assert_eq!(((b[2] & 1) << 2) | (b[3] >> 6), 2); + let len = (u32::from(b[3] & 0x03) << 11) | (u32::from(b[4]) << 3) | u32::from(b[5] >> 5); + assert_eq!(len, 378); + assert_eq!(((u32::from(b[5] & 0x1F)) << 6) | u32::from(b[6] >> 2), 0x7FF); + assert_eq!(b[6] & 0x03, 0); + } + + #[test] + fn adts_refuses_what_it_cannot_signal() { + assert!(AdtsHeader::from_asc(&[]).is_err()); + assert!(AdtsHeader::from_asc(&[0x11]).is_err()); + // explicit sampling frequency (index 15) + assert!(AdtsHeader::from_asc(&[0x17, 0x90, 0, 0, 0]).is_err()); + // object type 5 (SBR) has no ADTS profile + assert!(AdtsHeader::from_asc(&[0x29, 0x90]).is_err()); + let h = AdtsHeader::from_asc(&[0x11, 0x90]).unwrap(); + assert!(h.bytes(8184).is_ok()); + assert!(h.bytes(8185).is_err()); + assert!(h.bytes(usize::MAX).is_err()); + } + + #[test] + fn only_a_dot_aac_path_is_adts() { + assert!(is_adts_path("/x/mix.aac")); + assert!(is_adts_path("mix.AAC")); + assert!(!is_adts_path("mix.m4a")); + assert!(!is_adts_path("aac")); + } +} diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 279384f2..9a7d15dc 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -15,6 +15,7 @@ use std::io::Write; +mod aac_out; mod audio_out; mod job; mod mxf_out; @@ -112,6 +113,10 @@ pub enum Format { Wav, #[serde(rename = "aiff", alias = "Aiff")] Aiff, + /// AAC Audio: the mix alone, AAC-LC in an MPEG-4 audio file (`.m4a`), or a raw ADTS stream when + /// the path ends in `.aac`. + #[serde(rename = "aac", alias = "Aac")] + Aac, /// MXF OP1a (SMPTE ST 378): frame-wrapped DNxHR, ProRes or H.264 ([`MxfVideoCodec`]) + PCM. #[serde(rename = "mxf-op1a", alias = "MxfOp1a")] MxfOp1a, @@ -143,6 +148,7 @@ impl Format { "gif" | "animatedgif" => Format::Gif, "wav" | "waveform" | "waveformaudio" => Format::Wav, "aif" | "aiff" | "aifc" => Format::Aiff, + "aac" | "aacaudio" | "m4a" | "adts" => Format::Aac, _ => return None, }) } @@ -165,6 +171,7 @@ impl Format { Format::Gif => "gif", Format::Wav => "wav", Format::Aiff => "aiff", + Format::Aac => "aac", Format::MxfOp1a => "mxf-op1a", Format::MxfOpAtom => "mxf-opatom", } @@ -182,6 +189,7 @@ impl Format { Format::Gif => "gif", Format::Wav => "wav", Format::Aiff => "aif", + Format::Aac => "m4a", Format::MxfOp1a | Format::MxfOpAtom => "mxf", } } @@ -203,6 +211,7 @@ impl Format { Format::Gif => "Animated GIF", Format::Wav => "Waveform Audio", Format::Aiff => "AIFF", + Format::Aac => "AAC Audio", Format::MxfOp1a => "MXF OP1a", Format::MxfOpAtom => "MXF OP-Atom", } @@ -220,6 +229,10 @@ impl Format { pub fn has_builtin_encoder(self) -> bool { !matches!(self, Format::Hevc | Format::Av1) } + /// Sound without picture: Waveform Audio, AIFF and AAC Audio. + pub fn is_audio_only(self) -> bool { + matches!(self, Format::Wav | Format::Aiff | Format::Aac) + } /// H.264 or H.265: MPEG-4 (or QuickTime) with AAC audio, set up with the same bitrate controls. pub fn is_h26x(self) -> bool { matches!(self, Format::H264 | Format::Hevc) @@ -232,7 +245,7 @@ impl Format { pub fn is_mp4_with(self, mux: Multiplexer) -> bool { self == Format::Av1 || (self.is_h26x() && mux == Multiplexer::Mp4) } - pub const ALL: [Format; 18] = [ + pub const ALL: [Format; 19] = [ Format::H264, Format::Hevc, Format::Av1, @@ -249,6 +262,7 @@ impl Format { Format::Gif, Format::Wav, Format::Aiff, + Format::Aac, Format::MxfOp1a, Format::MxfOpAtom, ]; @@ -1719,6 +1733,7 @@ pub fn export(project: &Arc, seq: ItemId, settings: &ExportSettings, so let cancelled = || progress.cancel.load(Ordering::Relaxed); let batch = rayon::current_num_threads().clamp(2, 16) as i64; let (bytes, nframes) = match settings.format { + Format::Aac => aac_out::export(project, seq, settings, range, sources, progress)?, Format::Wav | Format::Aiff => { let mut a = audio_out::AudioOut::new(project.clone(), seq, settings, range)?; let (ch, sr, bits) = (a.channels as u16, a.sr, settings.audio.bits); diff --git a/crates/export/src/presets.rs b/crates/export/src/presets.rs index 2a021d49..c6f04979 100644 --- a/crates/export/src/presets.rs +++ b/crates/export/src/presets.rs @@ -169,6 +169,16 @@ pub fn builtin_presets() -> Vec { p("Waveform Audio 48 kHz 16-bit", "Audio Only", "WAV, stereo 48 kHz 16-bit PCM", audio_only(Format::Wav, 16)), p("Waveform Audio 48 kHz 24-bit", "Audio Only", "WAV, stereo 48 kHz 24-bit PCM", audio_only(Format::Wav, 24)), p("AIFF 48 kHz 16-bit", "Audio Only", "AIFF, stereo 48 kHz 16-bit PCM", audio_only(Format::Aiff, 16)), + p( + "AAC Audio 48 kHz 320 kbps", + "Audio Only", + "MPEG-4 audio (.m4a), stereo 48 kHz AAC-LC at 320 kbps", + ExportSettings { + format: Format::Aac, + audio: AudioSettings { codec: AudioCodec::Aac, sample_rate: Some(48_000), channels: 2, bitrate_kbps: 320, bits: 16 }, + ..Default::default() + }, + ), ] } diff --git a/crates/export/src/settings.rs b/crates/export/src/settings.rs index 61ee9dbd..4bfa28ec 100644 --- a/crates/export/src/settings.rs +++ b/crates/export/src/settings.rs @@ -160,7 +160,7 @@ impl MxfVideoCodec { } } -/// Audio codec. `Auto` = AAC in MP4, PCM in QuickTime / WAV / AIFF. +/// Audio codec. `Auto` = AAC in MP4 and AAC Audio, PCM in QuickTime / WAV / AIFF. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum AudioCodec { @@ -432,6 +432,7 @@ impl ExportSettings { pub fn audio_codec(&self) -> AudioCodec { match (self.format, self.audio.codec) { (Format::Wav | Format::Aiff | Format::MxfOp1a | Format::MxfOpAtom, _) => AudioCodec::Pcm, + (Format::Aac, _) => AudioCodec::Aac, (f, _) if f.is_mp4_with(self.multiplexer) => AudioCodec::Aac, (_, AudioCodec::Auto) if self.format.is_mp4_video() => AudioCodec::Aac, (_, AudioCodec::Auto) => AudioCodec::Pcm, @@ -446,7 +447,7 @@ impl ExportSettings { /// Whether the format carries video. pub fn has_video(&self) -> bool { - !matches!(self.format, Format::Wav | Format::Aiff) + !self.format.is_audio_only() } /// File extension of the output. @@ -461,6 +462,7 @@ impl ExportSettings { let fps = r.rate.num as f64 / r.rate.den as f64; let px = r.width as f64 * r.height as f64; let video_bps = match self.video_format() { + Format::Aac => 0.0, Format::H264 | Format::Hevc | Format::Av1 => r.target_kbps as f64 * 1000.0, Format::ProRes => crate::prores_profile(&self.prores_profile).nominal_mbps_1080p30() * 1e6 * px / (1920.0 * 1080.0) * fps / 29.97, Format::DnxHr => { @@ -491,7 +493,7 @@ impl ExportSettings { Format::Gif => px * 0.6 * 8.0 * fps, Format::Wav | Format::Aiff | Format::MxfOp1a | Format::MxfOpAtom => 0.0, }; - let audio_bps = if self.has_audio() || matches!(self.format, Format::Wav | Format::Aiff) { + let audio_bps = if self.has_audio() || self.format.is_audio_only() { match self.audio_codec() { AudioCodec::Aac => self.audio.bitrate_kbps as f64 * 1000.0, _ => r.sample_rate as f64 * r.channels as f64 * if self.audio.bits >= 24 { 24.0 } else { 16.0 }, @@ -575,7 +577,7 @@ impl ExportSettings { } v }; - let audio = if self.has_audio() || matches!(self.format, Format::Wav | Format::Aiff) { + let audio = if self.has_audio() || self.format.is_audio_only() { let ch = match r.channels { 1 => "Mono", 6 => "5.1", diff --git a/crates/export/src/settings_tests.rs b/crates/export/src/settings_tests.rs index 8cfb3170..1b72f10b 100644 --- a/crates/export/src/settings_tests.rs +++ b/crates/export/src/settings_tests.rs @@ -218,6 +218,103 @@ fn wav_and_aiff_audio_only() { } } +/// Root-mean-square level of decoded planar audio. +fn rms(planes: &[Vec]) -> f32 { + let n = planes.iter().map(Vec::len).sum::().max(1); + (planes.iter().flatten().map(|s| s * s).sum::() / n as f32).sqrt() +} + +#[test] +fn aac_audio_writes_m4a_and_adts() { + let (p, seq, m) = matte([0.0, 0.0, 0.0, 1.0], 64, 36, Some(-6.0)); + let dir = Scratch::new("aac-audio"); + let range = Some(TimeRange::new(Tick::ZERO, Tick(TICKS_PER_SECOND))); + // .m4a: one AAC track, no video, the encoder delay in an edit list + let m4a = dir.path("a.m4a"); + let s = ExportSettings { format: Format::Aac, path: m4a.clone(), range, ..Default::default() }; + assert!(!s.has_video()); + assert_eq!(s.audio_codec(), AudioCodec::Aac); + assert_eq!(s.extension(), "m4a"); + let r = export(&p, seq, &s, &m, &Progress::default()).unwrap(); + assert_eq!(r.frames, 48_000, "one second of samples"); + let bytes = std::fs::read(&m4a).unwrap(); + let file = filmcraft_isobmff::open(bytes.clone()).unwrap(); + assert_eq!(file.tracks.len(), 1, "audio only"); + let t = &file.tracks[0]; + let filmcraft_isobmff::CodecConfig::Aac(cfg) = &t.entries[0].codec else { panic!("not AAC: {:?}", t.entries[0].codec) }; + assert_eq!((cfg.object_type, cfg.sample_rate, cfg.channel_config), (2, 48_000, 2)); + assert!(t.samples.len() >= 48_000 / 1024, "{} access units", t.samples.len()); + let mut dec = filmcraft_aac::Decoder::new(&cfg.asc).unwrap(); + let mut pcm: Vec> = Vec::new(); + for i in 0..t.samples.len() { + let out = dec.decode(&file.read_sample(&bytes, 0, i).unwrap()).unwrap(); + pcm.resize(out.len(), Vec::new()); + pcm.iter_mut().zip(out).for_each(|(a, b)| a.extend(b)); + } + // the -6 dBFS tone (RMS about 0.35) survives the round trip + assert!((0.2..0.5).contains(&rms(&pcm)), "rms {}", rms(&pcm)); + if let Some(j) = ffprobe_json(&["-show_streams", "-show_format"], &m4a) { + assert_eq!(j["streams"].as_array().unwrap().len(), 1); + let st = &j["streams"][0]; + assert_eq!(st["codec_name"], "aac"); + assert_eq!(st["sample_rate"], "48000"); + assert_eq!(st["channels"], 2); + let d: f64 = j["format"]["duration"].as_str().unwrap().parse().unwrap(); + assert!((d - 1.0).abs() < 0.03, "duration {d}"); + } + + // .aac: raw ADTS, mono 44.1 kHz at 128 kbps + let adts = dir.path("a.aac"); + let mut s = ExportSettings { format: Format::Aac, path: adts.clone(), range, ..Default::default() }; + s.audio = AudioSettings { sample_rate: Some(44_100), channels: 1, bitrate_kbps: 128, ..Default::default() }; + export(&p, seq, &s, &m, &Progress::default()).unwrap(); + let b = std::fs::read(&adts).unwrap(); + // AAC-LC 44.1 kHz (index 4) mono: 00010 0100 0001 000 + let mut dec = filmcraft_aac::Decoder::new(&[0x12, 0x08]).unwrap(); + let (mut pos, mut units, mut pcm) = (0usize, 0usize, vec![Vec::new()]); + while pos < b.len() { + let h = &b[pos..pos + 7]; + assert_eq!((h[0], h[1] & 0xF6), (0xFF, 0xF0), "ADTS sync at {pos}"); + assert_eq!(h[1] & 0x01, 1, "no CRC"); + assert_eq!(((h[2] >> 2) & 0x0F, ((h[2] & 1) << 2) | (h[3] >> 6)), (4, 1), "44.1 kHz mono"); + let len = ((h[3] as usize & 0x03) << 11) | ((h[4] as usize) << 3) | (h[5] as usize >> 5); + assert!(len > 7 && pos + len <= b.len(), "frame length {len} at {pos}"); + pcm[0].extend(dec.decode(&b[pos + 7..pos + len]).unwrap().swap_remove(0)); + pos += len; + units += 1; + } + assert!(units >= 44_100 / 1024, "{units} frames"); + assert!((0.2..0.5).contains(&rms(&pcm)), "rms {}", rms(&pcm)); + if let Some(j) = ffprobe_json(&["-show_streams", "-show_format"], &adts) { + assert_eq!(j["format"]["format_name"], "aac"); + let st = &j["streams"][0]; + assert_eq!(st["codec_name"], "aac"); + assert_eq!(st["sample_rate"], "44100"); + assert_eq!(st["channels"], 1); + } + if let Some(ffmpeg) = filmcraft_testkit::ffmpeg_or_skip("AAC Audio decode") { + for f in [&m4a, &adts] { + let out = std::process::Command::new(&ffmpeg).args(["-v", "error", "-xerror", "-i", f, "-f", "null", "-"]).output().unwrap(); + assert!(out.status.success() && out.stderr.is_empty(), "{f}: {}", String::from_utf8_lossy(&out.stderr)); + } + } +} + +#[test] +fn aac_audio_settings_and_names() { + assert_eq!(Format::from_name("m4a"), Some(Format::Aac)); + assert_eq!(Format::from_name("AAC Audio"), Some(Format::Aac)); + assert_eq!(Format::from_name(Format::Aac.id()), Some(Format::Aac)); + assert!(Format::ALL.contains(&Format::Aac) && Format::Aac.is_audio_only() && !Format::H264.is_audio_only()); + let s = ExportSettings { format: Format::Aac, include_audio: false, ..Default::default() }; + let sum = s.summary(1920, 1080, FrameRate::FPS_24, 48_000, Tick(10 * TICKS_PER_SECOND)); + assert_eq!(sum.video, "No video"); + assert_eq!(sum.audio, "AAC, 320 kbps, 48000 Hz, Stereo"); + assert_eq!(sum.estimated_bytes, 400_000, "320 kbps for 10 s, no picture"); + let preset = presets::find_builtin("AAC Audio 48 kHz 320 kbps").unwrap(); + assert_eq!(preset.settings.format, Format::Aac); +} + #[test] fn wav_export_honours_explicit_sample_rates() { let (project, sequence, sources) = matte([0.0, 0.0, 0.0, 1.0], 64, 36, Some(-6.0)); @@ -658,7 +755,7 @@ fn h265_is_a_format_that_needs_a_registered_encoder() { } assert_eq!(Format::from_name(Format::Hevc.id()), Some(Format::Hevc)); assert_eq!((Format::Hevc.extension(), Format::Hevc.label()), ("mp4", "H.265 (HEVC)")); - assert!(Format::ALL.contains(&Format::Hevc) && Format::ALL.len() == 18); + assert!(Format::ALL.contains(&Format::Hevc) && Format::ALL.len() == 19); let s: ExportSettings = serde_json::from_value(serde_json::json!({"format": "hevc"})).unwrap(); assert_eq!(s.format, Format::Hevc); assert_eq!(serde_json::to_value(&s).unwrap()["format"], "hevc"); diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 51d1cd03..4f76c350 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2974,6 +2974,9 @@ Step Forward 1 Frame Avanzar 1 fotograma Match Playhead Igualar cabezal Gang to Program Vincular al programa + AAC Audio AAC Audio + AAC Audio 48 kHz 320 kbps AAC Audio 48 kHz 320 kbps + MPEG-4 audio (.m4a), stereo 48 kHz AAC-LC at 320 kbps Audio MPEG-4 (.m4a), estéreo, AAC-LC de 48 kHz a 320 kbps AIFF AIFF AIFF 48 kHz 16-bit AIFF 48 kHz 16 bits AIFF, stereo 48 kHz 16-bit PCM AIFF, estéreo, PCM de 48 kHz y 16 bits diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index f9f8c660..5f17a4f4 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2976,6 +2976,9 @@ Step Forward 1 Frame Avancer d'une image Match Playhead Faire correspondre la tête de lecture Gang to Program Coupler au moniteur programme + AAC Audio AAC Audio + AAC Audio 48 kHz 320 kbps AAC Audio 48 kHz 320 kbps + MPEG-4 audio (.m4a), stereo 48 kHz AAC-LC at 320 kbps Audio MPEG-4 (.m4a), AAC-LC stéréo 48 kHz à 320 kbps AIFF AIFF AIFF 48 kHz 16-bit AIFF 48 kHz 16 bits AIFF, stereo 48 kHz 16-bit PCM AIFF, PCM stéréo 48 kHz 16 bits diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 79c7d025..630cbd4d 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2963,6 +2963,9 @@ Step Forward 1 Frame 1 フレーム進む Match Playhead 再生ヘッドに合わせる Gang to Program プログラムと連動 + AAC Audio AAC Audio + AAC Audio 48 kHz 320 kbps AAC Audio 48 kHz 320 kbps + MPEG-4 audio (.m4a), stereo 48 kHz AAC-LC at 320 kbps MPEG-4 オーディオ (.m4a)、ステレオ 48 kHz AAC-LC 320 kbps AIFF AIFF AIFF 48 kHz 16-bit AIFF 48 kHz 16-bit AIFF, stereo 48 kHz 16-bit PCM AIFF、ステレオ 48 kHz 16-bit PCM diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index 16472ef0..2d518866 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -2957,6 +2957,9 @@ Step Forward 1 Frame 前进 1 帧 Match Playhead 匹配播放头 Gang to Program 联动到节目 + AAC Audio AAC 音频 + AAC Audio 48 kHz 320 kbps AAC 音频 48 kHz 320 kbps + MPEG-4 audio (.m4a), stereo 48 kHz AAC-LC at 320 kbps MPEG-4 音频 (.m4a),立体声 48 kHz AAC-LC,320 kbps AIFF AIFF AIFF 48 kHz 16-bit AIFF 48 kHz 16 位 AIFF, stereo 48 kHz 16-bit PCM AIFF,立体声 48 kHz 16 位 PCM diff --git a/docs/architecture.md b/docs/architecture.md index ed1486af..cbd1e6ed 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -664,6 +664,7 @@ file.exportMedia {path, preset?, settings?, format?, range?, …} export.qui | `prores` | `filmcraft-prores` (Proxy / LT / 422 / HQ) | MOV | | `dnxhr` | `filmcraft-dnx` (LB / SQ / HQ / HQX) | MOV (`AVdh`) | | `mjpeg` | built in | MOV | +| `aac` | `filmcraft-aac` (AAC-LC, the audio settings' bitrate) | audio only: MPEG-4 audio `.m4a` (`isobmff`, priming in an edit list), or raw ADTS when the path ends in `.aac` | | `mxf-op1a` | DNxHR (default), ProRes or H.264 (Annex B, long GOP), `mxfVideoCodec`; PCM 16/24-bit | MXF OP1a (`filmcraft-mxf` writer: frame-wrapped, index with temporal offsets, start timecode) | | `mxf-opatom` | as `mxf-op1a` | Avid-style MXF OP-Atom: clip-wrapped picture at `path`, one mono PCM file per channel (`_A1.mxf` …, `Report::extra_files`) | | `png`, `tiff`, `bmp`, `jpg` | `image` | numbered stills `000.`, `001.` … | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 58c45cb6..ee18a834 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -68,7 +68,7 @@ only). "Tested" names the evidence. | WAV / BWF (timecode, iXML partial) | read + write | yes | yes | 90% | | AIFF / AIFC | read + write | yes | yes | 95% | | MP3 | read + write | read | **no** | 60% | -| AAC / M4A | read + write | yes | inside MP4 / MOV only, **no AAC-only file** | 70% | +| AAC / M4A | read + write | yes | yes (in MP4 / MOV, and AAC Audio: `.m4a` or ADTS `.aac`, #814) | 95% | | FLAC, Ogg, Opus | partial | yes | no | ahead on read | | WMA | read (Windows) | no | no | 0% | diff --git a/docs/gaps.md b/docs/gaps.md index aec13ce2..c5baf693 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -187,10 +187,10 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** ## G13. Delivery formats - **Missing:** AV1 export (software or hardware), MPEG-2 / DVD / Blu-ray, DCP, JPEG 2000 MXF, - AS-10 / AS-11, XDCAM HD MXF, P2, OpenEXR sequences, MP3 and AAC-only audio, - ProRes 4444 / XQ in the export UI (#342; the encoder has the profiles), interlaced encoding, + AS-10 / AS-11, XDCAM HD MXF, P2, OpenEXR sequences, MP3 audio, interlaced encoding, smart render, HEVC software encoder (HEVC exists only where a hardware encoder does). -- **Have:** PNG, TIFF, BMP, JPEG, Targa and DPX (10-bit) image sequences (#779). +- **Have:** PNG, TIFF, BMP, JPEG, Targa and DPX (10-bit) image sequences (#779); AAC Audio + (audio-only `.m4a`, or ADTS `.aac`; #814); ProRes 4444 / 4444 XQ in the export UI (#342). - **Estimate:** 60–110 h. Doc: [file-format-parity.md](file-format-parity.md). ## G14. Localization: 8 of the 12 key languages missing From fe52bc0b2cc4d1aefb532027c8ce95d7b67f7332 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 00:58:15 +0200 Subject: [PATCH 5/6] #821: import Targa and DPX stills and image sequences Problem: Premiere imports Targa and DPX as stills and numbered sequences. FilmCraft exports both (#779) but could not read either: the extensions were not importable and the image crate has no DPX reader, so even our own Targa / DPX renders could not come back in. Fix: clean-room decoders in filmcraft-media. Targa from the TGA 2.0 spec: image types 1/2/3 and RLE 9/10/11, colour maps, 15/16/24/32-bit, 8/16-bit grayscale, any origin. DPX from SMPTE ST 268: first element, luma / RGB / RGBA / ABGR, 8/16-bit, filled 10/12-bit (methods A and B), either byte order, padded lines, orientations 0-3. Both bound allocations by the data present and reject hostile headers with errors. Stills and image sequences share one decode_raster entry point; "tga" and "dpx" join the importable still extensions. Co-Authored-By: Claude Opus 5.5 --- crates/codecs/tests/media_oracle.rs | 63 +++++- crates/media/src/dpx.rs | 273 +++++++++++++++++++++++++ crates/media/src/lib.rs | 6 +- crates/media/src/sequence.rs | 15 +- crates/media/src/still.rs | 41 +++- crates/media/src/tga.rs | 299 ++++++++++++++++++++++++++++ docs/file-format-parity.md | 6 +- docs/gaps.md | 7 +- 8 files changed, 683 insertions(+), 27 deletions(-) create mode 100644 crates/media/src/dpx.rs create mode 100644 crates/media/src/tga.rs diff --git a/crates/codecs/tests/media_oracle.rs b/crates/codecs/tests/media_oracle.rs index db7745dd..4fc6638e 100644 --- a/crates/codecs/tests/media_oracle.rs +++ b/crates/codecs/tests/media_oracle.rs @@ -1,6 +1,7 @@ -//! Image sequences and Broadcast WAV against ffmpeg: ffmpeg writes numbered stills (PNG, JPEG, -//! TIFF, BMP, WebP when available) and BWF files; the sequence frames must equal ffmpeg's decode of -//! the same image2 sequence (lossless formats exactly, JPEG within a few levels), and the BWF +//! Image sequences, stills and Broadcast WAV against ffmpeg: ffmpeg writes numbered stills (PNG, +//! JPEG, TIFF, BMP, WebP when available, Targa, DPX), single Targa / DPX stills in every layout it +//! has, and BWF files; our frames must equal ffmpeg's decode of the same files (lossless formats +//! exactly, JPEG within a few levels, high bit depths within one 8-bit step), and the BWF //! TimeReference must give the start timecode. mod common; @@ -114,6 +115,62 @@ fn webp_sequence_lossless() { check(&ff, "webp", &["-c:v", "libwebp", "-lossless", "1"], 0); } +#[test] +fn targa_and_dpx_sequences_exact() { + let ff = filmcraft_testkit::require_ffmpeg!(); + check(&ff, "tga", &[], 0); + check(&ff, "dpx", &["-pix_fmt", "gbrp10be"], 1); +} + +/// One `ext` still in `pix_fmt` (95 × 63, so scan lines need padding; translucent when the format +/// has alpha) opened like an imported file must match ffmpeg's RGBA decode within `tol` (exactly +/// when `tol` is 0). +fn still_matches(ff: &Path, ext: &str, pix_fmt: &str, extra: &[&str], tol: u8) { + let name = format!("still_{pix_fmt}{}.{ext}", extra.join("")); + let alpha = if pix_fmt.contains('a') && pix_fmt != "pal8" { ",format=rgba,geq=r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':a='X*2+Y'" } else { "" }; + let vf = format!("scale=95:63{alpha}"); + let mut args = vec!["-f", "lavfi", "-i", "testsrc2=size=96x64", "-frames:v", "1", "-vf", &vf, "-pix_fmt", pix_fmt]; + args.extend_from_slice(extra); + let Some(f) = fixture(ff, &name, &args) else { + eprintln!("SKIPPED: ffmpeg cannot write {name}"); + return; + }; + let src = filmcraft_codecs::open_bytes(&name, bytes(&f)).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(src.info().kind, MediaKind::Still, "{name}"); + let v = src.info().video.as_ref().unwrap(); + assert_eq!((v.width, v.height), (95, 63), "{name}"); + let ours = rgba(&src.video_frame(FrameRequest::full(filmcraft_time::Tick(0))).unwrap()); + // deeper than 8 bits: ffmpeg's 16-bit decode, rounded to 8 bits (its own 8-bit conversion dithers) + let want: Vec = if tol == 0 { + ffmpeg_out(ff, &["-i", f.to_str().unwrap(), "-f", "rawvideo", "-pix_fmt", "rgba", "-"]) + } else { + let wide = ffmpeg_out(ff, &["-i", f.to_str().unwrap(), "-f", "rawvideo", "-pix_fmt", "rgba64le", "-"]); + wide.as_chunks::<2>().0.iter().map(|c| ((u32::from(u16::from_le_bytes(*c)) * 255 + 32767) / 65535) as u8).collect() + }; + assert_eq!(ours.len(), want.len(), "{name}"); + let d = ours.iter().zip(&want).map(|(a, b)| a.abs_diff(*b)).max().unwrap(); + assert!(d <= tol, "{name}: differs by {d}"); + assert_eq!(v.has_alpha, want.as_chunks::<4>().0.iter().any(|p| p[3] != 255), "{name}"); + eprintln!("{name}: max diff {d}"); +} + +#[test] +fn targa_stills_every_layout() { + let ff = filmcraft_testkit::require_ffmpeg!(); + for pix_fmt in ["bgr24", "bgra", "rgb555le", "gray", "pal8"] { + still_matches(&ff, "tga", pix_fmt, &[], 0); // run-length encoded (ffmpeg's default) + still_matches(&ff, "tga", pix_fmt, &["-rle", "0"], 0); + } +} + +#[test] +fn dpx_stills_every_layout() { + let ff = filmcraft_testkit::require_ffmpeg!(); + for (pix_fmt, tol) in [("rgb24", 0), ("rgba", 0), ("gray", 0), ("rgb48be", 1), ("rgba64be", 1), ("gray16be", 1), ("gbrp10be", 1), ("gbrp12be", 1)] { + still_matches(&ff, "dpx", pix_fmt, &[], tol); + } +} + #[test] fn missing_frames_hold_the_previous_frame() { let ff = filmcraft_testkit::require_ffmpeg!(); diff --git a/crates/media/src/dpx.rs b/crates/media/src/dpx.rs new file mode 100644 index 00000000..2d5832fa --- /dev/null +++ b/crates/media/src/dpx.rs @@ -0,0 +1,273 @@ +//! DPX (`.dpx`) stills, decoded clean-room from SMPTE ST 268 (Digital Picture Exchange). +//! +//! The first image element is decoded: luma, RGB, RGBA or ABGR, 8, 10, 12 or 16 bits per +//! component, big-endian (`SDPX`) or little-endian (`XPDS`) files. 10-bit components are filled into +//! 32-bit words (packing 1, method A: two padding bits at the bottom; packing 2, method B: at the +//! top), 12-bit components fill 16-bit words the same two ways. Scan lines start on a 32-bit +//! boundary and may carry end-of-line padding. Orientations 0–3 (either scan direction, either line +//! order) are honoured. Code values map linearly onto 8 bits (full range); a logarithmic (printing +//! density) transfer is shown as stored, without a film-log conversion. +//! +//! Not decoded (an "unsupported" error): run-length encoded elements, the 10/12-bit "packed" +//! layout (packing 0), Y'CbCr descriptors, 1-bit and floating-point components, transposed +//! orientations. + +use crate::{MediaError, Result, still::Raster}; + +/// Generic file + image information header: everything we read lies below this. +const HEADER: usize = 1408; +/// First image element of the image information header (72 bytes each). +const ELEMENT: usize = 780; +/// Largest picture we decode (16384 × 16384): bounds the allocation a header can ask for. +const MAX_PIXELS: usize = 1 << 28; +/// "Undefined" value of numeric header fields. +const UNDEFINED: u32 = u32::MAX; + +fn err(name: &str, what: &str) -> MediaError { + MediaError::Decode(format!("{name}: DPX: {what}")) +} + +fn unsupported(name: &str, what: String) -> MediaError { + MediaError::Unsupported(format!("{name}: DPX {what}")) +} + +/// Whether `bytes` starts with the DPX magic number in either byte order. +pub fn sniff(bytes: &[u8]) -> bool { + bytes.starts_with(b"SDPX") || bytes.starts_with(b"XPDS") +} + +/// Header and sample reads in the file's byte order. +#[derive(Clone, Copy)] +struct Order { + big: bool, +} + +impl Order { + fn u16(self, b: &[u8], at: usize) -> u16 { + let v = [b.get(at).copied().unwrap_or(0), b.get(at + 1).copied().unwrap_or(0)]; + if self.big { u16::from_be_bytes(v) } else { u16::from_le_bytes(v) } + } + + fn u32(self, b: &[u8], at: usize) -> u32 { + let v = core::array::from_fn(|i| b.get(at + i).copied().unwrap_or(0)); + if self.big { u32::from_be_bytes(v) } else { u32::from_le_bytes(v) } + } +} + +/// How components are stored in a scan line. +#[derive(Clone, Copy)] +enum Packing { + /// One byte per component. + Bytes, + /// One 16-bit word per component; the value is `word >> shift` (12-bit method A: 4). + Words { shift: u32 }, + /// Three 10-bit components per 32-bit word, the first in the high bits, above `pad` bits. + Ten { pad: u32 }, +} + +impl Packing { + /// Bytes of a scan line holding `comps` components, before end-of-line padding (lines start on + /// a 32-bit boundary). + fn line_bytes(self, comps: usize) -> Option { + let raw = match self { + Packing::Bytes => comps, + Packing::Words { .. } => comps.checked_mul(2)?, + Packing::Ten { .. } => comps.div_ceil(3).checked_mul(4)?, + }; + raw.checked_next_multiple_of(4) + } + + /// The `comps` component values of one scan line. + fn unpack(self, line: &[u8], comps: usize, order: Order, out: &mut Vec) { + out.clear(); + match self { + Packing::Bytes => out.extend(line.iter().take(comps).map(|&b| u16::from(b))), + Packing::Words { shift } => out.extend((0..comps).map(|i| order.u16(line, i * 2) >> shift)), + Packing::Ten { pad } => { + for w in 0..comps.div_ceil(3) { + let word = order.u32(line, w * 4); + for s in [20 + pad, 10 + pad, pad] { + if out.len() < comps { + out.push(((word >> s) & 0x3FF) as u16); + } + } + } + } + } + } +} + +/// Decode the first image element of a DPX file to straight-alpha RGBA8, top row first. +pub fn decode(name: &str, bytes: &[u8]) -> Result { + let order = match bytes.get(..4) { + Some(b"SDPX") => Order { big: true }, + Some(b"XPDS") => Order { big: false }, + _ => return Err(err(name, "not a DPX file")), + }; + if bytes.len() < HEADER { + return Err(err(name, "truncated header")); + } + let orientation = order.u16(bytes, 768); + let (w, h) = (order.u32(bytes, 772) as usize, order.u32(bytes, 776) as usize); + let el = ELEMENT; + let descriptor = bytes.get(el + 20).copied().unwrap_or(0); + let bits = bytes.get(el + 23).copied().unwrap_or(0); + let packing = order.u16(bytes, el + 24); + let encoding = order.u16(bytes, el + 26); + let element_offset = order.u32(bytes, el + 28); + let eol_padding = order.u32(bytes, el + 32); + if w == 0 || h == 0 { + return Err(err(name, "empty picture")); + } + let pixels = w.checked_mul(h).filter(|&n| n <= MAX_PIXELS).ok_or_else(|| err(name, "picture too large"))?; + // component order per pixel: indices of R, G, B, A (None: opaque) in the stored components + let (n, map): (usize, [Option; 4]) = match descriptor { + 6 => (1, [Some(0), Some(0), Some(0), None]), + 50 => (3, [Some(0), Some(1), Some(2), None]), + 51 => (4, [Some(0), Some(1), Some(2), Some(3)]), + 52 => (4, [Some(3), Some(2), Some(1), Some(0)]), + d => return Err(unsupported(name, format!("descriptor {d}"))), + }; + if encoding != 0 { + return Err(unsupported(name, "run-length encoding".into())); + } + let layout = match (bits, packing) { + (8, _) => Packing::Bytes, + (16, _) => Packing::Words { shift: 0 }, + (10, 1) => Packing::Ten { pad: 2 }, + (10, 2) => Packing::Ten { pad: 0 }, + (12, 1) => Packing::Words { shift: 4 }, + (12, 2) => Packing::Words { shift: 0 }, + (b, p) => return Err(unsupported(name, format!("{b}-bit components with packing {p}"))), + }; + let (flip_x, flip_y) = match orientation { + 0 => (false, false), + 1 => (true, false), + 2 => (false, true), + 3 => (true, true), + o => return Err(unsupported(name, format!("orientation {o}"))), + }; + let comps = w.checked_mul(n).ok_or_else(|| err(name, "picture too large"))?; + let pad = if eol_padding == UNDEFINED { 0 } else { eol_padding as usize }; + let stride = layout.line_bytes(comps).and_then(|l| l.checked_add(pad)).ok_or_else(|| err(name, "picture too large"))?; + let offset = match element_offset { + 0 | UNDEFINED => order.u32(bytes, 4) as usize, + o => o as usize, + }; + // the last line needs no end-of-line padding + let size = stride.checked_mul(h - 1).and_then(|s| s.checked_add(stride - pad)).ok_or_else(|| err(name, "picture too large"))?; + let data = offset.checked_add(size).and_then(|end| bytes.get(offset..end)).ok_or_else(|| err(name, "truncated image data"))?; + let max = (1u32 << bits) - 1; + let to8 = |v: u16| ((u32::from(v).min(max) * 255 + max / 2) / max) as u8; + let mut rgba = vec![0u8; pixels * 4]; + let mut line = Vec::with_capacity(comps); + for y in 0..h { + let src = data.get(y * stride..).unwrap_or_default(); + layout.unpack(src, comps, order, &mut line); + let dy = if flip_y { h - 1 - y } else { y }; + for x in 0..w { + let dx = if flip_x { w - 1 - x } else { x }; + let o = (dy * w + dx) * 4; + let Some(px) = rgba.get_mut(o..o + 4) else { continue }; + for (c, slot) in map.iter().enumerate() { + let v = match slot { + Some(i) => line.get(x * n + i).map_or(0, |&v| to8(v)), + None => 255, + }; + if let Some(d) = px.get_mut(c) { + *d = v; + } + } + } + } + let has_alpha = map[3].is_some() && rgba.as_chunks::<4>().0.iter().any(|p| p[3] != 255); + Ok(Raster { width: w as u32, height: h as u32, rgba, has_alpha }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A minimal DPX file (header + `data`) in the given byte order. + fn file(big: bool, w: u32, h: u32, descriptor: u8, bits: u8, packing: u16, orientation: u16, data: &[u8]) -> Vec { + let mut f = vec![0u8; 2048]; + let p32 = |f: &mut Vec, at: usize, v: u32| f[at..at + 4].copy_from_slice(&if big { v.to_be_bytes() } else { v.to_le_bytes() }); + let p16 = |f: &mut Vec, at: usize, v: u16| f[at..at + 2].copy_from_slice(&if big { v.to_be_bytes() } else { v.to_le_bytes() }); + f[0..4].copy_from_slice(if big { b"SDPX" } else { b"XPDS" }); + p32(&mut f, 4, 2048); + p16(&mut f, 768, orientation); + p16(&mut f, 770, 1); + p32(&mut f, 772, w); + p32(&mut f, 776, h); + f[ELEMENT + 20] = descriptor; + f[ELEMENT + 23] = bits; + p16(&mut f, ELEMENT + 24, packing); + p32(&mut f, ELEMENT + 28, UNDEFINED); + p32(&mut f, ELEMENT + 32, UNDEFINED); + f.extend_from_slice(data); + f + } + + #[test] + fn ten_bit_method_a_and_b() { + let word = |r: u32, g: u32, b: u32, pad: u32| ((r << (20 + pad)) | (g << (10 + pad)) | (b << pad)).to_be_bytes(); + let mut data = Vec::new(); + data.extend_from_slice(&word(1023, 0, 512, 2)); + data.extend_from_slice(&word(4, 1019, 0, 2)); + let r = decode("a.dpx", &file(true, 2, 1, 50, 10, 1, 0, &data)).unwrap(); + assert_eq!(r.rgba, [255, 0, 128, 255, 1, 254, 0, 255]); + assert!(!r.has_alpha); + let mut data = Vec::new(); + data.extend_from_slice(&word(1023, 0, 512, 0)); + data.extend_from_slice(&word(4, 1019, 0, 0)); + let r = decode("a.dpx", &file(true, 2, 1, 50, 10, 2, 0, &data)).unwrap(); + assert_eq!(r.rgba, [255, 0, 128, 255, 1, 254, 0, 255]); + } + + #[test] + fn rgba_components_roll_across_ten_bit_words() { + // 2 RGBA pixels = 8 components = 3 words; the last word is half empty + let comps = [1023u32, 0, 0, 1023, 0, 1023, 0, 0]; + let mut data = Vec::new(); + for c in comps.chunks(3) { + let get = |i: usize| c.get(i).copied().unwrap_or(0); + data.extend_from_slice(&((get(0) << 22) | (get(1) << 12) | (get(2) << 2)).to_le_bytes()); + } + let r = decode("a.dpx", &file(false, 2, 1, 51, 10, 1, 0, &data)).unwrap(); + assert_eq!(r.rgba, [255, 0, 0, 255, 0, 255, 0, 0]); + assert!(r.has_alpha); + } + + #[test] + fn eight_bit_lines_pad_to_words_and_orientation_flips() { + // 1 × 2 RGB: each 3-byte line padded to 4; bottom-to-top, right-to-left orientation 3 + let r = decode("a.dpx", &file(false, 1, 2, 50, 8, 0, 3, &[1, 2, 3, 0, 4, 5, 6, 0])).unwrap(); + assert_eq!(r.rgba, [4, 5, 6, 255, 1, 2, 3, 255]); + // 2 × 1 luma, 16-bit big-endian; ABGR, 12-bit method A + let r = decode("a.dpx", &file(true, 2, 1, 6, 16, 0, 0, &[0xFF, 0xFF, 0x80, 0x00])).unwrap(); + assert_eq!(r.rgba, [255, 255, 255, 255, 128, 128, 128, 255]); + let r = decode("a.dpx", &file(true, 1, 1, 52, 12, 1, 0, &[0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xF0])).unwrap(); + assert_eq!(r.rgba, [255, 0, 0, 128]); + } + + #[test] + fn hostile_headers_fail_cleanly() { + // huge dimensions with no data, truncated data, unsupported layouts + assert!(decode("a.dpx", &file(true, u32::MAX, u32::MAX, 50, 10, 1, 0, &[])).is_err()); + assert!(decode("a.dpx", &file(true, 4096, 4096, 50, 10, 1, 0, &[0; 64])).is_err()); + assert!(decode("a.dpx", &file(true, 2, 2, 50, 10, 1, 0, &[0; 12])).is_err()); + assert!(decode("a.dpx", &file(true, 1, 1, 100, 8, 0, 0, &[0; 4])).is_err()); + assert!(decode("a.dpx", &file(true, 1, 1, 50, 10, 0, 0, &[0; 4])).is_err()); + assert!(decode("a.dpx", &file(true, 1, 1, 50, 32, 0, 0, &[0; 16])).is_err()); + assert!(decode("a.dpx", &file(true, 1, 1, 50, 8, 0, 5, &[0; 4])).is_err()); + assert!(decode("a.dpx", &file(true, 0, 1, 50, 8, 0, 0, &[0; 4])).is_err()); + assert!(decode("a.dpx", b"SDPX").is_err()); + let mut f = file(true, 1, 1, 50, 8, 0, 0, &[0; 4]); + f[4..8].copy_from_slice(&u32::MAX.to_be_bytes()); + assert!(decode("a.dpx", &f).is_err()); + let mut f = file(true, 1, 1, 50, 8, 0, 0, &[0; 4]); + f[ELEMENT + 32..ELEMENT + 36].copy_from_slice(&(u32::MAX - 1).to_be_bytes()); + assert!(decode("a.dpx", &file(true, 1, 2, 50, 8, 0, 0, &[0; 4])).is_err()); + assert!(decode("a.dpx", &f).is_ok()); + } +} diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index bfc94de7..a73451e7 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -12,11 +12,13 @@ pub mod cache; pub mod cancel; pub mod digits; +pub mod dpx; pub mod generators; pub mod pending; pub mod reader; pub mod sequence; pub mod still; +pub mod tga; pub mod wav; use std::path::Path; @@ -169,7 +171,7 @@ pub trait MediaSource: Send + Sync { pub type SharedSource = Arc; /// File extensions we recognise at import (the media browser filters on these). -pub const STILL_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp"]; +pub const STILL_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "tga", "dpx"]; pub const AUDIO_EXTENSIONS: &[&str] = &["wav", "wave", "bwf", "aif", "aiff", "mp3", "mp2", "m4a", "aac", "flac", "ogg", "opus"]; pub const VIDEO_EXTENSIONS: &[&str] = &[ "mp4", "m4v", "mov", "mkv", "webm", "avi", "mxf", "mts", "m2ts", "m2t", "ts", "mpg", "mpeg", "vob", "mod", "tod", "m2v", "m1v", "mpv", "3gp", "y4m", "apv", @@ -193,7 +195,7 @@ pub fn open_bytes(name: &str, bytes: Arc<[u8]>, extra: &[Opener]) -> Result bool { - b.starts_with(b"\x89PNG") +/// Whether `b` (the contents of the file `name`) is a still image we decode. +pub fn sniff(name: &str, b: &[u8]) -> bool { + crate::dpx::sniff(b) + || crate::tga::sniff(name, b) + || b.starts_with(b"\x89PNG") || b.starts_with(&[0xFF, 0xD8, 0xFF]) || b.starts_with(b"GIF8") || (b.len() > 12 && &b[0..4] == b"RIFF" && &b[8..12] == b"WEBP") @@ -20,6 +23,31 @@ pub fn sniff(b: &[u8]) -> bool { || b.starts_with(b"BM") } +/// A decoded picture: straight-alpha RGBA8, top row first. +pub struct Raster { + pub width: u32, + pub height: u32, + pub rgba: Vec, + /// Whether any pixel is not fully opaque. + pub has_alpha: bool, +} + +/// Decode a still image file (`name` picks Targa, which has no magic number; the content picks +/// every other format). +pub fn decode_raster(name: &str, bytes: &[u8]) -> Result { + if crate::dpx::sniff(bytes) { + return crate::dpx::decode(name, bytes); + } + if crate::tga::sniff(name, bytes) { + return crate::tga::decode(name, bytes); + } + let img = image::load_from_memory(bytes).map_err(|e| MediaError::Decode(format!("{name}: {e}")))?; + let has_alpha = img.color().has_alpha(); + let rgba = img.to_rgba8(); + let (width, height) = rgba.dimensions(); + Ok(Raster { width, height, rgba: rgba.into_raw(), has_alpha }) +} + pub struct StillSource { info: MediaInfo, frame: Arc, @@ -27,11 +55,8 @@ pub struct StillSource { impl StillSource { pub fn decode(name: &str, bytes: &[u8]) -> Result { - let img = image::load_from_memory(bytes).map_err(|e| MediaError::Decode(format!("{name}: {e}")))?; - let has_alpha = img.color().has_alpha(); - let rgba = img.to_rgba8(); - let (w, h) = rgba.dimensions(); - Ok(Self::from_rgba(name, w, h, rgba.into_raw(), has_alpha, bytes.len() as u64)) + let r = decode_raster(name, bytes)?; + Ok(Self::from_rgba(name, r.width, r.height, r.rgba, r.has_alpha, bytes.len() as u64)) } pub fn from_rgba(name: &str, w: u32, h: u32, rgba: Vec, has_alpha: bool, file_size: u64) -> Self { diff --git a/crates/media/src/tga.rs b/crates/media/src/tga.rs new file mode 100644 index 00000000..06e9295d --- /dev/null +++ b/crates/media/src/tga.rs @@ -0,0 +1,299 @@ +//! Targa (`.tga`) stills, decoded clean-room from the Truevision TGA File Format Specification 2.0. +//! +//! Image types 1/2/3 (colour-mapped, true-colour, grayscale) and their run-length encoded forms +//! 9/10/11; 15/16-bit (5-5-5, the top bit as alpha when the descriptor says one attribute bit), +//! 24-bit BGR and 32-bit BGRA true-colour, 8-bit grayscale and 16-bit grayscale + alpha, colour maps +//! of 15/16/24/32-bit entries indexed by 8 or 16 bits; any origin corner. + +use crate::{MediaError, Result, still::Raster}; + +/// Length of the fixed header. +const HEADER: usize = 18; +/// TGA 2.0 files end with this signature (after two 32-bit area offsets). +const FOOTER: &[u8] = b"TRUEVISION-XFILE.\0"; +/// Largest picture we decode (16384 × 16384): bounds the allocation a header can ask for. +const MAX_PIXELS: usize = 1 << 28; + +fn err(name: &str, what: &str) -> MediaError { + MediaError::Decode(format!("{name}: Targa: {what}")) +} + +/// Whether `bytes` looks like a Targa file: a TGA 2.0 footer, or (for the original format, which +/// has no signature) a `.tga` name and a plausible header. +pub fn sniff(name: &str, bytes: &[u8]) -> bool { + let header_ok = || { + let Some(h) = bytes.first_chunk::
() else { return false }; + let (map_type, kind, depth) = (h[1], h[2], h[16]); + let (w, ht) = (u16::from_le_bytes([h[12], h[13]]), u16::from_le_bytes([h[14], h[15]])); + map_type <= 1 && matches!(kind, 1 | 2 | 3 | 9 | 10 | 11) && w > 0 && ht > 0 && matches!(depth, 8 | 15 | 16 | 24 | 32) + }; + let ext = name.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase()); + let named = matches!(ext.as_deref(), Some("tga" | "targa" | "icb" | "vda" | "vst")); + header_ok() && (named || bytes.ends_with(FOOTER)) +} + +/// Pixel layout of the stored values (after the colour-map lookup, for mapped images). +#[derive(Clone, Copy)] +enum Layout { + /// 15/16-bit `ARRRRRGG GGGBBBBB`, little-endian; `alpha`: the top bit is an alpha bit. + Rgb555 { + alpha: bool, + }, + Bgr24, + Bgra32, + Gray8, + GrayAlpha16, +} + +impl Layout { + fn true_colour(depth: u8, attr_bits: u8) -> Option { + Some(match depth { + 15 => Layout::Rgb555 { alpha: false }, + 16 => Layout::Rgb555 { alpha: attr_bits == 1 }, + 24 => Layout::Bgr24, + 32 => Layout::Bgra32, + _ => return None, + }) + } + + fn bytes(self) -> usize { + match self { + Layout::Rgb555 { .. } | Layout::GrayAlpha16 => 2, + Layout::Bgr24 => 3, + Layout::Bgra32 => 4, + Layout::Gray8 => 1, + } + } + + /// RGBA of one stored value (`p` holds exactly [`bytes`](Self::bytes) bytes). + fn rgba(self, p: &[u8]) -> [u8; 4] { + let b = |i: usize| p.get(i).copied().unwrap_or(0); + match self { + Layout::Rgb555 { alpha } => { + let v = u16::from_le_bytes([b(0), b(1)]); + let five = |s: u16| { + let c = ((v >> s) & 31) as u8; + (c << 3) | (c >> 2) + }; + let a = if !alpha || v & 0x8000 != 0 { 255 } else { 0 }; + [five(10), five(5), five(0), a] + } + Layout::Bgr24 => [b(2), b(1), b(0), 255], + Layout::Bgra32 => [b(2), b(1), b(0), b(3)], + Layout::Gray8 => [b(0), b(0), b(0), 255], + Layout::GrayAlpha16 => [b(0), b(0), b(0), b(1)], + } + } +} + +/// Decode a Targa file to straight-alpha RGBA8, top row first. +pub fn decode(name: &str, bytes: &[u8]) -> Result { + let h = bytes.first_chunk::
().ok_or_else(|| err(name, "truncated header"))?; + let (id_len, map_type, kind) = (usize::from(h[0]), h[1], h[2]); + let map_first = usize::from(u16::from_le_bytes([h[3], h[4]])); + let map_len = usize::from(u16::from_le_bytes([h[5], h[6]])); + let map_depth = h[7]; + let (w, ht) = (usize::from(u16::from_le_bytes([h[12], h[13]])), usize::from(u16::from_le_bytes([h[14], h[15]]))); + let (depth, descriptor) = (h[16], h[17]); + let attr_bits = descriptor & 0x0F; + let (right_to_left, top_to_bottom) = (descriptor & 0x10 != 0, descriptor & 0x20 != 0); + if w == 0 || ht == 0 { + return Err(err(name, "empty picture")); + } + let pixels = w.checked_mul(ht).filter(|&n| n <= MAX_PIXELS).ok_or_else(|| err(name, "picture too large"))?; + let rle = kind >= 9; + // stored value layout and, for colour-mapped images, the palette + let mut palette: Vec<[u8; 4]> = Vec::new(); + let map_entry_layout = if map_type == 1 { + Some(Layout::true_colour(map_depth, if map_depth == 16 { attr_bits.min(1) } else { 0 }).ok_or_else(|| err(name, "colour map entry size"))?) + } else { + None + }; + let mut at = HEADER.checked_add(id_len).ok_or_else(|| err(name, "header"))?; + if let Some(l) = map_entry_layout { + let size = map_len.checked_mul(l.bytes()).ok_or_else(|| err(name, "colour map"))?; + let end = at.checked_add(size).ok_or_else(|| err(name, "colour map"))?; + let map = bytes.get(at..end).ok_or_else(|| err(name, "truncated colour map"))?; + palette = map.chunks_exact(l.bytes()).map(|e| l.rgba(e)).collect(); + at = end; + } + let (layout, mapped) = match kind & 7 { + 1 => { + if map_type != 1 || !matches!(depth, 8 | 16) { + return Err(err(name, "colour-mapped picture without an 8/16-bit index and a colour map")); + } + (if depth == 8 { Layout::Gray8 } else { Layout::GrayAlpha16 }, true) + } + 2 => (Layout::true_colour(depth, attr_bits).ok_or_else(|| err(name, "true-colour pixel depth"))?, false), + 3 => match depth { + 8 => (Layout::Gray8, false), + 16 => (Layout::GrayAlpha16, false), + _ => return Err(err(name, "grayscale pixel depth")), + }, + _ => return Err(MediaError::Unsupported(format!("{name}: Targa image type {kind}"))), + }; + let data = bytes.get(at..).unwrap_or_default(); + let bpp = layout.bytes(); + // the stored values, in file order + let stored: Vec = if rle { + unpack_rle(name, data, pixels, bpp)? + } else { + let n = pixels.checked_mul(bpp).ok_or_else(|| err(name, "picture too large"))?; + data.get(..n).ok_or_else(|| err(name, "truncated pixel data"))?.to_vec() + }; + let mut rgba = vec![0u8; pixels * 4]; + for (i, v) in stored.chunks_exact(bpp).enumerate().take(pixels) { + let (x, y) = (i % w, i / w); + let x = if right_to_left { w - 1 - x } else { x }; + let y = if top_to_bottom { y } else { ht - 1 - y }; + let px = if mapped { + let byte = |i: usize| v.get(i).copied().unwrap_or(0); + let index = if bpp == 1 { usize::from(byte(0)) } else { usize::from(u16::from_le_bytes([byte(0), byte(1)])) }; + *index.checked_sub(map_first).and_then(|k| palette.get(k)).ok_or_else(|| err(name, "colour index outside the colour map"))? + } else { + layout.rgba(v) + }; + let o = (y * w + x) * 4; + if let Some(d) = rgba.get_mut(o..o + 4) { + d.copy_from_slice(&px); + } + } + // 32-bit files from writers that leave the attribute bits unset often store zero alpha + // everywhere: such a picture is opaque, not invisible + let alpha_used = match (layout, mapped) { + (Layout::Bgra32, false) => attr_bits != 0 || rgba.as_chunks::<4>().0.iter().any(|p| p[3] != 0), + _ => true, + }; + if !alpha_used { + rgba.as_chunks_mut::<4>().0.iter_mut().for_each(|p| p[3] = 255); + } + let has_alpha = rgba.as_chunks::<4>().0.iter().any(|p| p[3] != 255); + Ok(Raster { width: w as u32, height: ht as u32, rgba, has_alpha }) +} + +/// Expand run-length packets into `pixels` stored values of `bpp` bytes each. Packets may span +/// scan lines (the TGA 2.0 spec discourages it; older writers do it). +fn unpack_rle(name: &str, mut data: &[u8], pixels: usize, bpp: usize) -> Result> { + // a two-byte packet expands to at most 128 values: a header asking for more is lying + let bound = data.len().saturating_mul(64).saturating_add(128); + if pixels > bound { + return Err(err(name, "truncated run-length data")); + } + let total = pixels.checked_mul(bpp).ok_or_else(|| err(name, "picture too large"))?; + let mut out = Vec::with_capacity(total); + while out.len() < total { + let (&head, rest) = data.split_first().ok_or_else(|| err(name, "truncated run-length data"))?; + let count = usize::from(head & 0x7F) + 1; + let want = (total - out.len()).min(count * bpp); + if head & 0x80 != 0 { + let v = rest.get(..bpp).ok_or_else(|| err(name, "truncated run-length data"))?; + for _ in 0..want / bpp { + out.extend_from_slice(v); + } + data = rest.get(bpp..).unwrap_or_default(); + } else { + let n = count * bpp; + let v = rest.get(..n).ok_or_else(|| err(name, "truncated run-length data"))?; + out.extend_from_slice(v.get(..want).unwrap_or(v)); + data = rest.get(n..).unwrap_or_default(); + } + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn header(kind: u8, w: u16, h: u16, depth: u8, descriptor: u8) -> Vec { + let mut b = vec![0, 0, kind, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + b.extend_from_slice(&w.to_le_bytes()); + b.extend_from_slice(&h.to_le_bytes()); + b.extend_from_slice(&[depth, descriptor]); + b + } + + #[test] + fn bottom_up_bgr_is_flipped() { + let mut f = header(2, 2, 2, 24, 0); + // bottom row first: blue, green; then top row: red, white + f.extend_from_slice(&[255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255]); + let r = decode("a.tga", &f).unwrap(); + assert_eq!((r.width, r.height, r.has_alpha), (2, 2, false)); + assert_eq!(r.rgba, [255, 0, 0, 255, 255, 255, 255, 255, 0, 0, 255, 255, 0, 255, 0, 255]); + } + + #[test] + fn rle_runs_and_raw_packets_cross_lines() { + let mut f = header(10, 3, 2, 32, 0x28); + // a run of 4 translucent red, then 2 raw pixels + f.extend_from_slice(&[0x83, 0, 0, 255, 128, 0x01, 1, 2, 3, 255, 4, 5, 6, 255]); + let r = decode("a.tga", &f).unwrap(); + assert!(r.has_alpha); + assert_eq!(&r.rgba[..16], &[255, 0, 0, 128].repeat(4)[..]); + assert_eq!(&r.rgba[16..], &[3, 2, 1, 255, 6, 5, 4, 255]); + } + + #[test] + fn colour_map_and_16_bit_alpha() { + let mut f = header(1, 2, 1, 8, 0x20); + f[1] = 1; // colour map present + f[3..5].copy_from_slice(&1u16.to_le_bytes()); // first entry index 1 + f[5..7].copy_from_slice(&2u16.to_le_bytes()); + f[7] = 24; + f.extend_from_slice(&[10, 20, 30, 40, 50, 60, 2, 1]); + let r = decode("a.tga", &f).unwrap(); + assert_eq!(r.rgba, [60, 50, 40, 255, 30, 20, 10, 255]); + f[18 + 6] = 0; // index 0 is below the map's first entry + assert!(decode("a.tga", &f).is_err()); + + let mut f = header(2, 2, 1, 16, 0x21); + f.extend_from_slice(&0xFC00u16.to_le_bytes()); // opaque full red + f.extend_from_slice(&0x03E0u16.to_le_bytes()); // transparent full green + let r = decode("a.tga", &f).unwrap(); + assert_eq!(r.rgba, [255, 0, 0, 255, 0, 255, 0, 0]); + } + + #[test] + fn zero_alpha_32_bit_without_attribute_bits_is_opaque() { + let mut f = header(2, 1, 1, 32, 0); + f.extend_from_slice(&[1, 2, 3, 0]); + let r = decode("a.tga", &f).unwrap(); + assert_eq!((r.rgba.as_slice(), r.has_alpha), (&[3, 2, 1, 255][..], false)); + } + + #[test] + fn hostile_headers_fail_without_allocating() { + // 65535 × 65535 RLE from a few bytes, truncated raw data, bad types + let mut f = header(10, 65535, 65535, 32, 0); + f.extend_from_slice(&[0xFF, 1, 2, 3, 4]); + assert!(decode("a.tga", &f).is_err()); + let mut f = header(2, 4, 4, 24, 0); + f.extend_from_slice(&[0; 10]); + assert!(decode("a.tga", &f).is_err()); + assert!(decode("a.tga", &header(2, 1, 1, 7, 0)).is_err()); + assert!(decode("a.tga", &header(1, 1, 1, 8, 0)).is_err()); + assert!(decode("a.tga", &[0; 5]).is_err()); + for n in 0..40 { + let mut f = header(9, 3, 3, 8, 0); + f[1] = 1; + f[5] = 2; + f[7] = 16; + f.extend_from_slice(&[0x82, 1, 0, 1, 0x01, 0, 0]); + f.truncate(n); + let _ = decode("a.tga", &f); + } + } + + #[test] + fn sniffs_by_footer_or_name() { + let mut f = header(2, 1, 1, 24, 0); + f.extend_from_slice(&[0, 0, 0]); + assert!(sniff("x.TGA", &f)); + assert!(!sniff("x.bin", &f)); + f.extend_from_slice(&[0; 8]); + f.extend_from_slice(FOOTER); + assert!(sniff("x.bin", &f)); + assert!(!sniff("x.tga", b"\x89PNG\r\n\x1a\n0000000000")); + } +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index b62d7853..2ac9b655 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -56,8 +56,8 @@ only). "Tested" names the evidence. | WebP | — | yes | no | ahead | | **PSD** (layers as sequence) | read (layered) | no | — | 0% | | **OpenEXR** | read + write | no | sequences (half float, linear RGB / premultiplied RGBA, uncompressed; #806) | 35% | -| **DPX** | read + write | no | sequences (10-bit RGB, BT.709; #779) | 40% | -| Targa | read + write | no | sequences (24-bit, 32-bit with alpha; #779) | 40% | +| **DPX** | read + write | yes: luma / RGB / RGBA, 8–16-bit, filled 10/12-bit, either byte order (#821); no packed 10/12-bit, RLE or Y'CbCr | sequences (10-bit RGB, BT.709; #779) | 80% | +| Targa | read + write | yes: every image type, RLE, colour maps, 15–32-bit (#821) | sequences (24-bit, 32-bit with alpha; #779) | 95% | | HEIF / HEIC | read | no | — | 0% | | Radiance HDR, AI / EPS, ICO, PTL | read | no | — | 0% | @@ -84,7 +84,7 @@ only). "Tested" names the evidence. ## Summary **File formats dimension: ~60%, 140–240 h** (codecs counted separately). The beta blocker is -`.prproj`. After it, by user impact: AVI and WMV import, EXR / DPX / PSD stills, AAF / OMF +`.prproj`. After it, by user impact: AVI and WMV import, EXR / PSD stills, AAF / OMF validation in Avid and Pro Tools, DVD / Blu-ray / DCP / broadcast MXF delivery. ## Revision history diff --git a/docs/gaps.md b/docs/gaps.md index 83fb12b3..8b4b1bb5 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -165,9 +165,10 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** substreams (played as the 5.1 core), AVI (#598; listed as an extension but no demuxer), WMV / ASF, DV / DVCPRO / DV100, MPEG-4 Part 2, FLAC in MP4 (#603), camera RAW (R3D incl. R3D NE, ARRIRAW, Sony RAW / X-OCN, Canon RAW, BRAW, ProRes RAW; #345, #383), JPEG 2000 / - JPEG XS MXF, Cinema DNG; stills: PSD, EXR, DPX, Targa, HEIF / HEIC, Radiance HDR, AI / EPS; - transparent video (#402); Sony start timecode in `rtmd` (#460); stereo 3D flag (#500); MKV - multi-audio (#601). + JPEG XS MXF, Cinema DNG; stills: PSD, EXR, HEIF / HEIC, Radiance HDR, AI / EPS, and the rarer + DPX layouts (packed 10/12-bit, run-length, Y'CbCr; Targa and DPX stills and sequences import + since #821, deeper than 8 bits kept at 8); transparent video (#402); Sony start timecode in + `rtmd` (#460); stereo 3D flag (#500); MKV multi-audio (#601). - **Estimate:** 120–200 h (camera RAW alone 60–100 h, and some SDKs have licences we cannot use: clean-room from public specs only where specs exist). Docs: [codec-parity.md](codec-parity.md), [file-format-parity.md](file-format-parity.md). From 2ff3f5e266838700c292a35aaf762e3d4dcef980 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 19:28:41 -0400 Subject: [PATCH 6/6] Release prep: 0.6.0 and a Linux riscv64 release build (#830) - release.yml: new `linux-riscv64` job, cross-compiled on ubuntu-24.04 against ports.ubuntu.com riscv64 libs (ported from GridCraft), tar.gz only, CLI smoke-tested under QEMU. Installs libasound2-dev:riscv64 for cpal/ALSA; the VA-API and NVDEC/NVENC paths are loaded with libloading at run time, so they need no riscv64 libraries. The draft-release job now needs it, so its tarball is uploaded and covered by SHA256SUMS.txt. - packaging/linux/package.sh: CROSS_ARCH / CROSS_TARGET / CROSS_COMPILE / EMULATOR support, as in GridCraft. - README and docs/releasing.md list the riscv64 tarball. - cargo xtask version set 0.6.0 (Cargo.toml, Cargo.lock); refresh contributors. Co-authored-by: Claude Opus 5.5 --- .github/workflows/release.yml | 82 ++++- Cargo.lock | 98 +++--- Cargo.toml | 2 +- README.md | 3 + contributors/contributors.json | 614 +++++++++++++++++++++++++++++---- docs/releasing.md | 1 + packaging/linux/package.sh | 27 +- 7 files changed, 693 insertions(+), 134 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 08eba22b..0ebde2f4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,9 +4,9 @@ # The jobs that sign (macOS, Windows) and the one that publishes the draft run in the `release` # environment (restricted to the `release` branch), which holds the signing secrets. Every secret # is optional: missing ones produce unsigned artifacts and a warning, never a failed build. The -# jobs that sign nothing (version, Linux, Flatpak, FreeBSD, web) get no environment and no -# secrets, so a dispatch on any other branch dry-runs them; there the signing jobs are refused, -# so the draft-release job (which needs them) is skipped and nothing is published. +# jobs that sign nothing (version, Linux incl. riscv64, Flatpak, FreeBSD, web) get no environment +# and no secrets, so a dispatch on any other branch dry-runs them; there the signing jobs are +# refused, so the draft-release job (which needs them) is skipped and nothing is published. name: Release on: @@ -231,6 +231,80 @@ jobs: path: dist/release/* if-no-files-found: error + linux-riscv64: + name: Linux riscv64 + needs: version + # Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release + # from ports.ubuntu.com, so the binaries need that release's glibc (24.04: 2.39). 22.04's + # riscv64 ports lag its amd64 updates, which breaks the multiarch install. tar.gz only; the + # CLI is smoke-tested under QEMU. Audio links ALSA (cpal), hence libasound2-dev:riscv64; the + # hardware video paths (VA-API, NVDEC/NVENC) are loaded at run time with libloading, so they + # need no riscv64 libraries and fall back to the pure-Rust codecs when absent. + runs-on: ubuntu-24.04 + # No `environment: release`: this job signs nothing, so it gets no release secrets. + env: + FILMCRAFT_VERSION: ${{ needs.version.outputs.version }} + FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: storytold/craft-fonts + ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately + path: craft-fonts + - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable + with: + targets: riscv64gc-unknown-linux-gnu + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + key: release-linux-riscv64 + - name: Apply version override + if: inputs.version != '' + run: cargo xtask version set "$FILMCRAFT_VERSION" + - name: Install cross-compilation dependencies + run: | + . /etc/os-release + sudo dpkg --add-architecture riscv64 + # Pin the existing (archive.ubuntu.com) sources to amd64; riscv64 comes from ports. + if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then + sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources + fi + if [ -f /etc/apt/sources.list ]; then + sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list + sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list + fi + for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do + echo "deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports $suite main universe restricted multiverse" + done | sudo tee /etc/apt/sources.list.d/riscv64.list + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross libc6-dev:riscv64 qemu-user \ + libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 \ + libxi-dev:riscv64 libgl1-mesa-dev:riscv64 libasound2-dev:riscv64 + - name: Build and package (tar.gz) + env: + CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts + CRAFT_FONTS_REQUIRED: "1" + CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc + PKG_CONFIG_SYSROOT_DIR: / + PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig + PKG_CONFIG_ALLOW_CROSS: "1" + CROSS_ARCH: riscv64 + CROSS_TARGET: riscv64gc-unknown-linux-gnu + CROSS_COMPILE: riscv64-linux-gnu- + EMULATOR: qemu-riscv64 + QEMU_LD_PREFIX: /usr/riscv64-linux-gnu + run: packaging/linux/package.sh --formats tar + - name: Check the tarball + run: | + tar -tzf dist/release/filmcraft-*-linux-riscv64.tar.gz \ + | grep -E '/bin/filmcraft$|/bin/filmcraft-cli$|/share/applications/ai\.storyteller\.filmcraft\.desktop$' + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: release-linux-riscv64 + path: dist/release/*.tar.gz + if-no-files-found: error + flatpak: name: Flatpak ${{ matrix.arch }} needs: [version, linux] @@ -365,7 +439,7 @@ jobs: release: name: Draft GitHub Release - needs: [version, macos, windows, linux, flatpak, freebsd, web] + needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web] runs-on: ubuntu-latest environment: release permissions: diff --git a/Cargo.lock b/Cargo.lock index 5529201a..1db02133 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1665,7 +1665,7 @@ dependencies = [ [[package]] name = "filmcraft" -version = "0.5.0" +version = "0.6.0" dependencies = [ "arboard", "chrono", @@ -1697,7 +1697,7 @@ dependencies = [ [[package]] name = "filmcraft-aac" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -1707,7 +1707,7 @@ dependencies = [ [[package]] name = "filmcraft-ac3" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-testkit", "thiserror 2.0.21", @@ -1715,7 +1715,7 @@ dependencies = [ [[package]] name = "filmcraft-apv" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -1725,14 +1725,14 @@ dependencies = [ [[package]] name = "filmcraft-audio-dsp" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-testkit", ] [[package]] name = "filmcraft-automation" -version = "0.5.0" +version = "0.6.0" dependencies = [ "base64 0.22.1", "filmcraft-engine", @@ -1748,7 +1748,7 @@ dependencies = [ [[package]] name = "filmcraft-av1" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -1759,14 +1759,14 @@ dependencies = [ [[package]] name = "filmcraft-bitstream" -version = "0.5.0" +version = "0.6.0" dependencies = [ "proptest", ] [[package]] name = "filmcraft-captions" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-color", "filmcraft-project", @@ -1779,11 +1779,11 @@ dependencies = [ [[package]] name = "filmcraft-cfb" -version = "0.5.0" +version = "0.6.0" [[package]] name = "filmcraft-cli" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-automation", "filmcraft-codecs", @@ -1804,7 +1804,7 @@ dependencies = [ [[package]] name = "filmcraft-codecs" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-aac", "filmcraft-ac3", @@ -1838,7 +1838,7 @@ dependencies = [ [[package]] name = "filmcraft-color" -version = "0.5.0" +version = "0.6.0" dependencies = [ "proptest", "serde", @@ -1846,7 +1846,7 @@ dependencies = [ [[package]] name = "filmcraft-dnx" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -1856,7 +1856,7 @@ dependencies = [ [[package]] name = "filmcraft-edit" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-geom", "filmcraft-media", @@ -1869,7 +1869,7 @@ dependencies = [ [[package]] name = "filmcraft-engine" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-audio-dsp", "filmcraft-captions", @@ -1901,7 +1901,7 @@ dependencies = [ [[package]] name = "filmcraft-export" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-aac", "filmcraft-apv", @@ -1931,7 +1931,7 @@ dependencies = [ [[package]] name = "filmcraft-format" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-media", "filmcraft-project", @@ -1944,7 +1944,7 @@ dependencies = [ [[package]] name = "filmcraft-frame" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-color", "filmcraft-time", @@ -1954,7 +1954,7 @@ dependencies = [ [[package]] name = "filmcraft-geom" -version = "0.5.0" +version = "0.6.0" dependencies = [ "proptest", "serde", @@ -1962,7 +1962,7 @@ dependencies = [ [[package]] name = "filmcraft-golden" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-color", "filmcraft-frame", @@ -1979,7 +1979,7 @@ dependencies = [ [[package]] name = "filmcraft-gpu" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-color", "filmcraft-export", @@ -1998,7 +1998,7 @@ dependencies = [ [[package]] name = "filmcraft-h264" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -2008,7 +2008,7 @@ dependencies = [ [[package]] name = "filmcraft-h264enc" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -2018,7 +2018,7 @@ dependencies = [ [[package]] name = "filmcraft-hevc" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -2028,7 +2028,7 @@ dependencies = [ [[package]] name = "filmcraft-interchange" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-cfb", "filmcraft-color", @@ -2045,7 +2045,7 @@ dependencies = [ [[package]] name = "filmcraft-isobmff" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -2056,7 +2056,7 @@ dependencies = [ [[package]] name = "filmcraft-matroska" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-testkit", "serde_json", @@ -2064,7 +2064,7 @@ dependencies = [ [[package]] name = "filmcraft-media" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-color", "filmcraft-frame", @@ -2077,7 +2077,7 @@ dependencies = [ [[package]] name = "filmcraft-mpeg2v" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-testkit", "rayon", @@ -2086,7 +2086,7 @@ dependencies = [ [[package]] name = "filmcraft-mpegts" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-testkit", "thiserror 2.0.21", @@ -2094,28 +2094,28 @@ dependencies = [ [[package]] name = "filmcraft-mxf" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-testkit", ] [[package]] name = "filmcraft-ogg" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-testkit", ] [[package]] name = "filmcraft-opus" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-testkit", ] [[package]] name = "filmcraft-platform" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-bitstream", "filmcraft-codecs", @@ -2141,7 +2141,7 @@ dependencies = [ [[package]] name = "filmcraft-project" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-color", "filmcraft-geom", @@ -2155,7 +2155,7 @@ dependencies = [ [[package]] name = "filmcraft-prores" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -2165,7 +2165,7 @@ dependencies = [ [[package]] name = "filmcraft-render" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-audio-dsp", "filmcraft-captions", @@ -2185,7 +2185,7 @@ dependencies = [ [[package]] name = "filmcraft-scopes" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-color", "serde", @@ -2194,7 +2194,7 @@ dependencies = [ [[package]] name = "filmcraft-speech" -version = "0.5.0" +version = "0.6.0" dependencies = [ "candle-core", "candle-nn", @@ -2212,14 +2212,14 @@ dependencies = [ [[package]] name = "filmcraft-testkit" -version = "0.5.0" +version = "0.6.0" dependencies = [ "png", ] [[package]] name = "filmcraft-text" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-color", "harfrust", @@ -2231,7 +2231,7 @@ dependencies = [ [[package]] name = "filmcraft-time" -version = "0.5.0" +version = "0.6.0" dependencies = [ "proptest", "serde", @@ -2239,7 +2239,7 @@ dependencies = [ [[package]] name = "filmcraft-tts" -version = "0.5.0" +version = "0.6.0" dependencies = [ "candle-core", "candle-nn", @@ -2253,7 +2253,7 @@ dependencies = [ [[package]] name = "filmcraft-tts-text" -version = "0.5.0" +version = "0.6.0" dependencies = [ "serde", "thiserror 2.0.21", @@ -2261,7 +2261,7 @@ dependencies = [ [[package]] name = "filmcraft-ui-egui" -version = "0.5.0" +version = "0.6.0" dependencies = [ "cpu-time", "eframe", @@ -2298,7 +2298,7 @@ dependencies = [ [[package]] name = "filmcraft-vp9" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-bitstream", "rayon", @@ -2307,7 +2307,7 @@ dependencies = [ [[package]] name = "filmcraft-web" -version = "0.5.0" +version = "0.6.0" dependencies = [ "eframe", "egui", @@ -7332,7 +7332,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-testkit", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 1d306cff..a8990aa7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/*", "xtask"] [workspace.package] -version = "0.5.0" +version = "0.6.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" diff --git a/README.md b/README.md index e74820ab..0ce67cb3 100644 --- a/README.md +++ b/README.md @@ -362,6 +362,9 @@ and web backend defaults are unchanged. | Fedora/RHEL/openSUSE | `filmcraft--linux-x86_64.rpm` | `filmcraft--linux-aarch64.rpm` | | | Tarball | `filmcraft--linux-x86_64.tar.gz` | `filmcraft--linux-aarch64.tar.gz` | Unpack and run `./install.sh` | +RISC-V (riscv64): `filmcraft--linux-riscv64.tar.gz` (tarball only; needs glibc 2.39+, e.g. Ubuntu 24.04 +or Debian 13). + The tarball installer places FilmCraft, its command-line tool and desktop integration in `~/.local`, without administrator permissions. For all users, run `sudo ./install.sh --prefix /usr/local` instead. Running the installer again updates the installation. diff --git a/contributors/contributors.json b/contributors/contributors.json index 29b65c4c..52531d0e 100644 --- a/contributors/contributors.json +++ b/contributors/contributors.json @@ -2,23 +2,23 @@ "schema": 1, "repo": "storytold/filmcraft", "branch": "main", - "head": "7b6c134472287db4367fca80bcb498240ba36567", - "head_date": "2026-10-09T15:02:38Z", - "commits": 539, + "head": "9efe8f3112194b58a30cf0449b569306077c67d4", + "head_date": "2026-10-10T22:34:28Z", + "commits": 812, "unattributed_commits": 0, "contributors": [ { "login": "echelon", "people_entry": true, "github_name": null, - "prs": 48, - "commits": 337, - "lines_added": 311167, - "lines_deleted": 13200, + "prs": 49, + "commits": 366, + "lines_added": 313219, + "lines_deleted": 14128, "binary_added": 74, "binary_deleted": 0, "first_commit": "2026-09-30T09:22:00Z", - "last_commit": "2026-10-09T15:02:38Z" + "last_commit": "2026-10-10T20:08:58Z" }, { "login": "storybored379", @@ -154,9 +154,9 @@ "login": "carlitosteso3", "people_entry": false, "github_name": null, - "prs": 15, - "commits": 15, - "lines_added": 1740, + "prs": 16, + "commits": 16, + "lines_added": 2009, "lines_deleted": 207, "binary_added": 0, "binary_deleted": 0, @@ -232,27 +232,53 @@ "login": "DWAA1660", "people_entry": false, "github_name": null, - "prs": 2, - "commits": 2, - "lines_added": 209, - "lines_deleted": 0, + "prs": 3, + "commits": 3, + "lines_added": 441, + "lines_deleted": 10, "binary_added": 0, "binary_deleted": 0, "first_commit": "2026-10-07T22:57:38Z", - "last_commit": "2026-10-08T23:52:30Z" + "last_commit": "2026-10-09T22:01:08Z" }, { "login": "txiki739", "people_entry": false, "github_name": null, - "prs": 5, - "commits": 8, - "lines_added": 7976, - "lines_deleted": 3333, + "prs": 8, + "commits": 11, + "lines_added": 8160, + "lines_deleted": 3347, "binary_added": 0, "binary_deleted": 0, "first_commit": "2026-10-07T23:41:48Z", - "last_commit": "2026-10-09T09:28:24Z" + "last_commit": "2026-10-09T14:06:49Z" + }, + { + "login": "mutombopr", + "people_entry": false, + "github_name": null, + "prs": 4, + "commits": 19, + "lines_added": 10421, + "lines_deleted": 337, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-08T03:57:26Z", + "last_commit": "2026-10-10T17:46:20Z" + }, + { + "login": "ahmedhosnypro", + "people_entry": true, + "github_name": null, + "prs": 2, + "commits": 5, + "lines_added": 473, + "lines_deleted": 80, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-08T05:32:08Z", + "last_commit": "2026-10-09T22:00:30Z" }, { "login": "wstock", @@ -284,14 +310,14 @@ "login": "MrCloudy2", "people_entry": false, "github_name": null, - "prs": 1, - "commits": 1, - "lines_added": 132, - "lines_deleted": 4, + "prs": 6, + "commits": 7, + "lines_added": 1293, + "lines_deleted": 46, "binary_added": 0, "binary_deleted": 0, "first_commit": "2026-10-08T08:53:54Z", - "last_commit": "2026-10-08T08:53:54Z" + "last_commit": "2026-10-10T15:14:34Z" }, { "login": "switch87", @@ -336,27 +362,27 @@ "login": "MarekSpodBiedry", "people_entry": false, "github_name": null, - "prs": 2, - "commits": 2, - "lines_added": 74, - "lines_deleted": 6, + "prs": 29, + "commits": 29, + "lines_added": 1048, + "lines_deleted": 137, "binary_added": 0, "binary_deleted": 0, "first_commit": "2026-10-08T13:44:56Z", - "last_commit": "2026-10-09T08:45:28Z" + "last_commit": "2026-10-10T21:22:52Z" }, { "login": "Tom1tk", "people_entry": false, "github_name": null, - "prs": 2, - "commits": 2, - "lines_added": 47, - "lines_deleted": 26, + "prs": 3, + "commits": 3, + "lines_added": 70, + "lines_deleted": 30, "binary_added": 0, "binary_deleted": 0, "first_commit": "2026-10-08T13:47:40Z", - "last_commit": "2026-10-08T14:56:39Z" + "last_commit": "2026-10-09T14:19:08Z" }, { "login": "Pathos0925", @@ -375,14 +401,14 @@ "login": "mjohnson7cd", "people_entry": false, "github_name": null, - "prs": 2, - "commits": 1, - "lines_added": 100, - "lines_deleted": 2, + "prs": 3, + "commits": 2, + "lines_added": 121, + "lines_deleted": 5, "binary_added": 0, "binary_deleted": 0, "first_commit": "2026-10-08T20:36:28Z", - "last_commit": "2026-10-08T20:36:28Z" + "last_commit": "2026-10-10T00:47:33Z" }, { "login": "Oldrich333", @@ -401,14 +427,14 @@ "login": "cshapiro", "people_entry": false, "github_name": null, - "prs": 3, - "commits": 5, - "lines_added": 3128, - "lines_deleted": 111, + "prs": 4, + "commits": 6, + "lines_added": 3165, + "lines_deleted": 116, "binary_added": 0, "binary_deleted": 0, "first_commit": "2026-10-08T23:28:32Z", - "last_commit": "2026-10-09T02:57:30Z" + "last_commit": "2026-10-09T15:59:56Z" }, { "login": "rossaai", @@ -440,14 +466,14 @@ "login": "JordyUSA", "people_entry": false, "github_name": null, - "prs": 2, - "commits": 3, - "lines_added": 4158, - "lines_deleted": 283, + "prs": 6, + "commits": 9, + "lines_added": 4948, + "lines_deleted": 405, "binary_added": 0, "binary_deleted": 0, "first_commit": "2026-10-09T01:35:57Z", - "last_commit": "2026-10-09T02:53:28Z" + "last_commit": "2026-10-10T15:06:45Z" }, { "login": "jerrykimo", @@ -466,14 +492,27 @@ "login": "swillunderscore", "people_entry": true, "github_name": null, - "prs": 1, - "commits": 3, - "lines_added": 1008, - "lines_deleted": 13, + "prs": 2, + "commits": 6, + "lines_added": 2407, + "lines_deleted": 63, "binary_added": 0, "binary_deleted": 0, "first_commit": "2026-10-09T01:58:37Z", - "last_commit": "2026-10-09T02:19:21Z" + "last_commit": "2026-10-10T00:20:25Z" + }, + { + "login": "jnohclee-rgb", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 19, + "lines_deleted": 1, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T02:14:06Z", + "last_commit": "2026-10-09T02:14:06Z" }, { "login": "IppSec", @@ -492,14 +531,14 @@ "login": "sanyamsingh001", "people_entry": true, "github_name": null, - "prs": 2, - "commits": 2, - "lines_added": 323, - "lines_deleted": 25, + "prs": 5, + "commits": 5, + "lines_added": 1169, + "lines_deleted": 93, "binary_added": 0, "binary_deleted": 0, "first_commit": "2026-10-09T04:06:37Z", - "last_commit": "2026-10-09T05:44:39Z" + "last_commit": "2026-10-09T23:00:34Z" }, { "login": "ItzBloxy", @@ -513,6 +552,435 @@ "binary_deleted": 0, "first_commit": "2026-10-09T09:27:48Z", "last_commit": "2026-10-09T10:17:08Z" + }, + { + "login": "dmatviichuk", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 464, + "lines_deleted": 6, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T12:18:42Z", + "last_commit": "2026-10-09T12:18:42Z" + }, + { + "login": "David-T-Campos", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 7, + "lines_added": 61, + "lines_deleted": 8, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T15:25:06Z", + "last_commit": "2026-10-09T15:27:55Z" + }, + { + "login": "lloyd-nich", + "people_entry": false, + "github_name": null, + "prs": 0, + "commits": 3, + "lines_added": 1477, + "lines_deleted": 121, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T15:58:49Z", + "last_commit": "2026-10-10T07:02:57Z" + }, + { + "login": "haranishi", + "people_entry": false, + "github_name": null, + "prs": 4, + "commits": 4, + "lines_added": 3700, + "lines_deleted": 92, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T17:14:19Z", + "last_commit": "2026-10-10T08:34:16Z" + }, + { + "login": "vbasky", + "people_entry": false, + "github_name": null, + "prs": 10, + "commits": 11, + "lines_added": 1314, + "lines_deleted": 215, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T17:37:44Z", + "last_commit": "2026-10-10T16:41:59Z" + }, + { + "login": "ossini", + "people_entry": true, + "github_name": "Oskar Sobania", + "prs": 2, + "commits": 12, + "lines_added": 1381, + "lines_deleted": 149, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T18:43:22Z", + "last_commit": "2026-10-10T17:17:47Z" + }, + { + "login": "liangforstudy", + "people_entry": true, + "github_name": null, + "prs": 3, + "commits": 3, + "lines_added": 575, + "lines_deleted": 20, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T21:22:05Z", + "last_commit": "2026-10-10T09:40:58Z" + }, + { + "login": "ReReddo", + "people_entry": true, + "github_name": null, + "prs": 12, + "commits": 14, + "lines_added": 1918, + "lines_deleted": 126, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T21:30:29Z", + "last_commit": "2026-10-10T19:45:20Z" + }, + { + "login": "MasakatsuFunaki", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 261, + "lines_deleted": 13, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T21:49:24Z", + "last_commit": "2026-10-09T21:49:24Z" + }, + { + "login": "maccagnit", + "people_entry": false, + "github_name": null, + "prs": 4, + "commits": 4, + "lines_added": 221, + "lines_deleted": 30, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T23:00:05Z", + "last_commit": "2026-10-10T15:22:56Z" + }, + { + "login": "digitaldecaycreations-lgtm", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 11, + "lines_deleted": 0, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T23:20:58Z", + "last_commit": "2026-10-09T23:20:58Z" + }, + { + "login": "dylanmtaylor", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 37, + "lines_deleted": 42, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T00:04:17Z", + "last_commit": "2026-10-10T00:04:17Z" + }, + { + "login": "sawizzle", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 2, + "lines_added": 23, + "lines_deleted": 6, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T00:06:15Z", + "last_commit": "2026-10-10T04:50:49Z" + }, + { + "login": "luniiya", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 697, + "lines_deleted": 6, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T00:14:55Z", + "last_commit": "2026-10-10T00:14:55Z" + }, + { + "login": "chuygarcia92", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 38, + "lines_deleted": 33, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T02:20:17Z", + "last_commit": "2026-10-10T02:20:17Z" + }, + { + "login": "Hathor875", + "people_entry": true, + "github_name": "Krzysztof Cieślik", + "prs": 1, + "commits": 1, + "lines_added": 109, + "lines_deleted": 4, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T02:21:06Z", + "last_commit": "2026-10-10T02:21:06Z" + }, + { + "login": "Guilhermeb-Ferrarezi", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 2, + "lines_added": 161, + "lines_deleted": 1, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T02:43:20Z", + "last_commit": "2026-10-10T02:48:16Z" + }, + { + "login": "mitchmckinstry", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 3, + "lines_added": 399, + "lines_deleted": 11, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T03:14:19Z", + "last_commit": "2026-10-10T03:25:58Z" + }, + { + "login": "gyroninja", + "people_entry": false, + "github_name": null, + "prs": 2, + "commits": 2, + "lines_added": 178, + "lines_deleted": 12, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T04:32:42Z", + "last_commit": "2026-10-10T10:14:18Z" + }, + { + "login": "hadmark", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 4, + "lines_added": 3608, + "lines_deleted": 4, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T06:08:41Z", + "last_commit": "2026-10-10T06:32:49Z" + }, + { + "login": "SilentYeti", + "people_entry": false, + "github_name": null, + "prs": 2, + "commits": 3, + "lines_added": 1562, + "lines_deleted": 72, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T06:17:11Z", + "last_commit": "2026-10-10T09:17:16Z" + }, + { + "login": "adurr21", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 287, + "lines_deleted": 68, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T07:03:03Z", + "last_commit": "2026-10-10T07:03:03Z" + }, + { + "login": "maxm11", + "people_entry": true, + "github_name": null, + "prs": 3, + "commits": 6, + "lines_added": 487, + "lines_deleted": 38, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T07:19:38Z", + "last_commit": "2026-10-10T14:18:27Z" + }, + { + "login": "Ni-zav", + "people_entry": false, + "github_name": null, + "prs": 10, + "commits": 13, + "lines_added": 498, + "lines_deleted": 65, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T08:47:24Z", + "last_commit": "2026-10-10T21:55:59Z" + }, + { + "login": "ivandrozdov1505-coder", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 404, + "lines_deleted": 5, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T10:40:22Z", + "last_commit": "2026-10-10T10:40:22Z" + }, + { + "login": "KattKnight", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 3, + "lines_added": 335, + "lines_deleted": 9, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T10:56:07Z", + "last_commit": "2026-10-10T13:00:21Z" + }, + { + "login": "keller-lukas", + "people_entry": true, + "github_name": null, + "prs": 1, + "commits": 2, + "lines_added": 480, + "lines_deleted": 15, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T11:06:02Z", + "last_commit": "2026-10-10T11:06:02Z" + }, + { + "login": "4papuce", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 39, + "lines_deleted": 0, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T12:43:28Z", + "last_commit": "2026-10-10T12:43:28Z" + }, + { + "login": "vangel76", + "people_entry": true, + "github_name": null, + "prs": 49, + "commits": 53, + "lines_added": 11727, + "lines_deleted": 752, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T13:47:11Z", + "last_commit": "2026-10-10T22:34:28Z" + }, + { + "login": "abdelkabirouadoukou", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 30, + "lines_deleted": 8, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T15:54:00Z", + "last_commit": "2026-10-10T15:54:00Z" + }, + { + "login": "impostazioni", + "people_entry": false, + "github_name": null, + "prs": 3, + "commits": 3, + "lines_added": 596, + "lines_deleted": 34, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T17:29:09Z", + "last_commit": "2026-10-10T20:52:28Z" + }, + { + "login": "alex-brazh", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 159, + "lines_deleted": 4, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T19:55:52Z", + "last_commit": "2026-10-10T19:55:52Z" + }, + { + "login": "LloydNicholson", + "people_entry": false, + "github_name": null, + "prs": 3, + "commits": 0, + "lines_added": 0, + "lines_deleted": 0, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": null, + "last_commit": null } ], "models": [ @@ -521,27 +989,27 @@ "company": "Anthropic", "model": "Claude Opus", "version": "5.5", - "commits": 474, - "lines_added": 346706, - "lines_deleted": 19057 + "commits": 602, + "lines_added": 374868, + "lines_deleted": 20972 }, { "id": "anthropic/claude-sonnet/5.5", "company": "Anthropic", "model": "Claude Sonnet", "version": "5.5", - "commits": 18, - "lines_added": 13935, - "lines_deleted": 854 + "commits": 25, + "lines_added": 18034, + "lines_deleted": 957 }, { "id": "openai/codex", "company": "OpenAI", "model": "Codex", "version": "", - "commits": 11, - "lines_added": 2057, - "lines_deleted": 361 + "commits": 16, + "lines_added": 3654, + "lines_deleted": 503 }, { "id": "anthropic/claude-fable/5.1", @@ -557,9 +1025,9 @@ "company": "Anthropic", "model": "Claude", "version": "", - "commits": 1, - "lines_added": 235, - "lines_deleted": 22 + "commits": 2, + "lines_added": 496, + "lines_deleted": 35 }, { "id": "anthropic/claude-opus/4.8", diff --git a/docs/releasing.md b/docs/releasing.md index 631e2ee9..58c28374 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -50,6 +50,7 @@ signing jobs are refused, and the draft-release job, which needs them, is skippe | Windows on ARM64 | `filmcraft--windows-arm64.msi`, `filmcraft--windows-arm64-portable.zip` | `windows-latest` (cross-compiled) | | Linux x86_64 | `filmcraft--linux-x86_64.{AppImage,deb,rpm,tar.gz}` | `ubuntu-22.04` | | Linux aarch64 | `filmcraft--linux-aarch64.{AppImage,deb,rpm,tar.gz}` | `ubuntu-22.04-arm` | +| Linux riscv64 | `filmcraft--linux-riscv64.tar.gz` | `ubuntu-24.04`, cross-compiled (glibc 2.39+; CLI smoke-tested under QEMU) | | AppImage updates | `filmcraft--linux-{x86_64,aarch64}.AppImage.zsync` | with the AppImage | | Flatpak | `filmcraft--linux-x86_64.flatpak`, `filmcraft--linux-aarch64.flatpak` | `ubuntu-24.04`, `ubuntu-24.04-arm` (repackaged Linux tarball) | | FreeBSD 14 x86_64 | `filmcraft--freebsd-x86_64.tar.gz` | FreeBSD 14.3 VM on `ubuntu-latest` | diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index b2d00bc3..5001bf71 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Build and package FilmCraft for Linux ( is x86_64 or aarch64): +# Build and package FilmCraft for Linux ( is x86_64 or aarch64, or riscv64 when cross-compiling): # # $DIST/filmcraft--linux-.AppImage any distro with glibc >= the build host's # $DIST/filmcraft--linux-.AppImage.zsync delta updates (needs zsyncmake) @@ -13,6 +13,10 @@ # (downloaded into $CARGO_TARGET_DIR if missing). Build on an old distro (CI: Ubuntu 22.04, # glibc 2.35) so the binaries run on newer ones. Optional: desktop-file-validate, appstreamcli, # zsyncmake (the zsync package) for the AppImage's .zsync. +# +# Cross-compiling (CI's riscv64 job, tar only): set CROSS_ARCH (riscv64), CROSS_TARGET (the Rust +# target), CROSS_COMPILE (binutils prefix, for strip) and optionally EMULATOR (qemu-riscv64) to +# smoke-test the CLI. set -euo pipefail # shellcheck source=../env.sh . "$(dirname "${BASH_SOURCE[0]}")/../env.sh" @@ -25,13 +29,14 @@ while [ $# -gt 0 ]; do case "$1" in --skip-build) SKIP_BUILD=1; shift ;; --formats) FORMATS="$2"; shift 2 ;; - -h | --help) sed -n '2,16p' "$0"; exit 0 ;; + -h | --help) sed -n '2,21p' "$0"; exit 0 ;; *) echo "unknown argument: $1" >&2; exit 2 ;; esac done -ARCH="$(uname -m)" +ARCH="${CROSS_ARCH:-$(uname -m)}" case "$ARCH" in + riscv64) DEB_ARCH=riscv64 ;; x86_64) DEB_ARCH=amd64 ;; aarch64 | arm64) ARCH=aarch64; DEB_ARCH=arm64 ;; *) echo "unsupported architecture $ARCH" >&2; exit 2 ;; @@ -42,9 +47,13 @@ BASENAME="filmcraft-$VERSION-linux-$ARCH" echo "==> FilmCraft $VERSION for Linux $ARCH ($FORMATS)" if [ "$SKIP_BUILD" = 0 ]; then - (cd "$ROOT" && cargo build --release --locked -p filmcraft -p filmcraft-cli) + if [ -n "${CROSS_TARGET:-}" ]; then + (cd "$ROOT" && cargo build --release --locked -p filmcraft -p filmcraft-cli --target "$CROSS_TARGET") + else + (cd "$ROOT" && cargo build --release --locked -p filmcraft -p filmcraft-cli) + fi fi -BIN="$CARGO_TARGET_DIR/release" +BIN="$CARGO_TARGET_DIR/${CROSS_TARGET:+$CROSS_TARGET/}release" WORK="$CARGO_TARGET_DIR/linux-package" STAGE="$WORK/root" rm -rf "$WORK" @@ -52,7 +61,7 @@ rm -rf "$WORK" # ---- stage an FHS tree (shared by every format) ------------------------------------------------- install -Dm755 "$BIN/filmcraft" "$STAGE/usr/bin/filmcraft" install -Dm755 "$BIN/filmcraft-cli" "$STAGE/usr/bin/filmcraft-cli" -strip "$STAGE/usr/bin/filmcraft" "$STAGE/usr/bin/filmcraft-cli" 2>/dev/null || true +"${CROSS_COMPILE:-}strip" "$STAGE/usr/bin/filmcraft" "$STAGE/usr/bin/filmcraft-cli" 2>/dev/null || true install -Dm644 "$HERE/$APP_ID.desktop" "$STAGE/usr/share/applications/$APP_ID.desktop" install -Dm644 "$HERE/$APP_ID.mime.xml" "$STAGE/usr/share/mime/packages/$APP_ID.xml" mkdir -p "$STAGE/usr/share/metainfo" @@ -136,6 +145,10 @@ if has appimage; then fi fi -"$STAGE/usr/bin/filmcraft-cli" --version +if [ -z "${CROSS_TARGET:-}" ]; then + "$STAGE/usr/bin/filmcraft-cli" --version +elif [ -n "${EMULATOR:-}" ]; then + "$EMULATOR" "$STAGE/usr/bin/filmcraft-cli" --version +fi echo "==> done" ls -lh "$DIST"