From fcdcc07359c197faf6f2d22f31fe33b8d49a0664 Mon Sep 17 00:00:00 2001 From: MrCloudy2 Date: Thu, 8 Oct 2026 11:33:19 +0200 Subject: [PATCH 01/64] #219: right-clicking a cut opens the edit point menu Right-clicking a clip's edge on the timeline now selects that edit point and opens Premiere's edit point menu instead of the clip menu: Ripple Trim In, Ripple Trim Out, Roll Edit, Trim In and Trim Out (each re-selects the cut with that trim type, greyed out when the needed side has no clip), Apply Default Transitions, and Join Through Edits. The body of a clip still opens the clip menu. Join Through Edits from this menu joins only the right-clicked cut (and its linked partner): `sequence.joinThroughEdits` takes a new `cut: [left, right]` parameter, since `clips: [id]` also matched any other through edit touching the same piece. Co-Authored-By: Claude Opus 5.5 --- crates/edit/src/through.rs | 8 +- crates/engine/src/sequence_tools.rs | 35 ++++- crates/engine/src/sequence_tools_tests.rs | 35 +++++ crates/ui-egui/src/panels/timeline.rs | 109 +++++++++++++- crates/ui-egui/tests/edit_point_menu_ui.rs | 160 +++++++++++++++++++++ 5 files changed, 339 insertions(+), 8 deletions(-) create mode 100644 crates/ui-egui/tests/edit_point_menu_ui.rs diff --git a/crates/edit/src/through.rs b/crates/edit/src/through.rs index 0905149a..547ae785 100644 --- a/crates/edit/src/through.rs +++ b/crates/edit/src/through.rs @@ -59,6 +59,11 @@ pub fn through_edits(seq: &Sequence) -> Vec { /// piece is listed. The left piece absorbs the right one (keeping its own effects and attributes); /// a transition sitting on the cut is removed. Returns the number of edits joined. pub fn join_through_edits(seq: &mut Sequence, only: &[ClipId]) -> usize { + join_through_edits_where(seq, |a, b| only.is_empty() || only.contains(&a) || only.contains(&b)) +} + +/// Join the through edits on unlocked tracks whose (left, right) pieces `wanted` accepts. +pub fn join_through_edits_where(seq: &mut Sequence, wanted: impl Fn(ClipId, ClipId) -> bool) -> usize { let mut n = 0; for tr in seq.all_tracks_mut() { if tr.locked { @@ -67,8 +72,7 @@ pub fn join_through_edits(seq: &mut Sequence, only: &[ClipId]) -> usize { let mut i = 0; while i + 1 < tr.items.len() { let (a, b) = (&tr.items[i], &tr.items[i + 1]); - let wanted = only.is_empty() || only.contains(&a.id) || only.contains(&b.id); - if !(wanted && is_through_edit(a, b)) { + if !(wanted(a.id, b.id) && is_through_edit(a, b)) { i += 1; continue; } diff --git a/crates/engine/src/sequence_tools.rs b/crates/engine/src/sequence_tools.rs index 5183c10e..e66c5001 100644 --- a/crates/engine/src/sequence_tools.rs +++ b/crates/engine/src/sequence_tools.rs @@ -8,7 +8,7 @@ //! | `sequence.goToNextGapInTrack` / `sequence.goToPrevGapInTrack` | Sequence ▸ Go to Gap ▸ Next / Previous in Track | | //! | `sequence.selectionFollowsPlayhead` | Sequence ▸ Selection Follows Playhead | | //! | `sequence.showThroughEdits` | Sequence ▸ Show Through Edits | | -//! | `sequence.joinThroughEdits` | (clip context menu) Join Through Edits | | +//! | `sequence.joinThroughEdits` | (clip and edit point context menus) Join Through Edits | | //! | `sequence.throughEdits` | query: the through edits of the active sequence | | //! | `sequence.makeSubsequence` | Sequence ▸ Make Subsequence | Shift+U | //! | `sequence.deleteTracks` | Sequence ▸ Delete Tracks… | | @@ -103,7 +103,15 @@ fn groups() -> Vec<(&'static str, Vec)> { s.state.show_through_edits = bool_p(p, "on").unwrap_or(!s.state.show_through_edits); Ok(json!({"showThroughEdits": s.state.show_through_edits})) }), - spec("sequence.joinThroughEdits", "Join Through Edits", &[], None, r#"{"clips":[id]?,"all":bool?}"#, has_seq, join_through_edits), + spec( + "sequence.joinThroughEdits", + "Join Through Edits", + &[], + None, + r#"{"clips":[id]?,"cut":[id,id]?,"all":bool?}"#, + has_seq, + join_through_edits, + ), query("sequence.throughEdits", "List Through Edits", "{}", |s, _| { let q = s.active_sequence().ok_or(EngineError::NoSequence)?; Ok(Value::Array( @@ -326,6 +334,9 @@ fn go_to_gap(s: &mut Session, p: &Value, next: bool, in_track: bool) -> Result Result { + if let Some(cut) = p.get("cut") { + return join_through_edit_at(s, cut); + } let all = bool_p(p, "all").unwrap_or(false) || (p.get("clips").is_none() && p.get("clip").is_none() && s.state.selection.is_empty()); let only = if all { Vec::new() } else { with_links(s, &clips_p(s, p)) }; let q = s.active_sequence().ok_or(EngineError::NoSequence)?; @@ -343,6 +354,26 @@ fn join_through_edits(s: &mut Session, p: &Value) -> Result { Ok(json!({"joined": n})) } +/// `sequence.joinThroughEdits {cut: [left, right]}`: join just that one cut (the edit point menu, +/// #219), with the cuts between the same pieces' linked partners when linked selection is on. +fn join_through_edit_at(s: &mut Session, cut: &Value) -> Result { + let ids: Vec = cut.as_array().map(|a| a.iter().filter_map(|v| v.as_u64().map(ClipId)).collect()).unwrap_or_default(); + let [left, right] = ids[..] else { + return Err(EngineError::Other("sequence.joinThroughEdits: `cut` needs two clip ids, [left, right]".into())); + }; + let q = s.active_sequence().ok_or(EngineError::NoSequence)?; + if !edit::through::through_edits(q).iter().any(|e| e.left == left && e.right == right) { + return Err(EngineError::Other("that cut is not a through edit".into())); + } + let (lefts, rights) = (with_links(s, &[left]), with_links(s, &[right])); + let n = s.edit_sequence("Join Through Edits", |q, _, st| { + let n = edit::through::join_through_edits_where(q, |a, b| lefts.contains(&a) && rights.contains(&b)); + st.selection.retain(|c| q.find_item(*c).is_some()); + Ok(n) + })?; + Ok(json!({"joined": n})) +} + // ---------- subsequence ---------- /// Sequence ▸ Make Subsequence: a new sequence (same settings and track layout) holding copies of the diff --git a/crates/engine/src/sequence_tools_tests.rs b/crates/engine/src/sequence_tools_tests.rs index d9df881f..a592abe7 100644 --- a/crates/engine/src/sequence_tools_tests.rs +++ b/crates/engine/src/sequence_tools_tests.rs @@ -142,6 +142,41 @@ fn through_edits_show_and_join() { assert_eq!(s.execute("sequence.throughEdits", json!({})).unwrap().as_array().unwrap().len(), n - 2); } +/// `cut: [left, right]` (the timeline's edit point menu, #219) joins exactly that cut and its +/// linked partner, even when one of its pieces is also part of another through edit. +#[test] +fn join_one_through_edit_by_its_cut() { + let mut s = demo(); + let v1 = s.active_sequence().unwrap().video_tracks[0].id.0; + for frame in [30, 40] { + s.execute("playhead.set", json!({"frame": frame})).unwrap(); + s.execute("sequence.addEditAllTracks", json!({})).unwrap(); + } + let te = s.execute("sequence.throughEdits", json!({})).unwrap(); + let on_v1: Vec<&Value> = te.as_array().unwrap().iter().filter(|e| e["track"] == json!(v1)).collect(); + assert_eq!(on_v1.len(), 2); + // the second cut: its left piece is also the right piece of the first one + let (second, first) = (on_v1[1], on_v1[0]); + assert_eq!(second["left"], first["right"]); + let n = te.as_array().unwrap().len(); + let r = s.execute("sequence.joinThroughEdits", json!({"cut": [second["left"], second["right"]]})).unwrap(); + assert_eq!(r["joined"], json!(2), "V1 + linked A1"); + let te = s.execute("sequence.throughEdits", json!({})).unwrap(); + assert_eq!(te.as_array().unwrap().len(), n - 2); + assert!(te.as_array().unwrap().iter().any(|e| e["left"] == first["left"] && e["right"] == first["right"]), "the first cut stays"); +} + +#[test] +fn join_by_cut_rejects_hostile_parameters() { + let mut s = demo(); + let items = s.active_sequence().unwrap().video_tracks[0].items.clone(); + let before = s.active_sequence().unwrap().clone(); + for cut in [json!([]), json!([1]), json!([1, 2, 3]), json!("x"), json!([-1, 2]), json!([u64::MAX, 0]), json!([items[0].id.0, items[1].id.0])] { + assert!(s.execute("sequence.joinThroughEdits", json!({"cut": cut})).is_err(), "{cut}"); + } + assert_eq!(s.active_sequence().unwrap(), &before, "nothing changed"); +} + #[test] fn make_subsequence_from_in_out_and_from_selection() { let mut s = demo(); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 0172243c..e1f6b450 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1620,6 +1620,84 @@ const CLIP_MENU: &[&[(&str, &str)]] = &[ &[("Reveal in Project", "clip.revealInProject"), ("Join Through Edits", "sequence.joinThroughEdits")], ]; +/// The cut right-clicked on a track (#219): the clip ending there and the clip starting there, +/// either missing at the ends of a gap. Its menu offers the edit point's trim types, Apply Default +/// Transitions and Join Through Edits, like Premiere's edit point menu. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct EditPointTarget { + left: Option, + right: Option, +} + +impl EditPointTarget { + fn at(seq: &Sequence, track: TrackId, clip: ClipId, edge: filmcraft_edit::Edge) -> Option { + let tr = seq.track(track)?; + let it = tr.item(clip)?; + Some(match edge { + filmcraft_edit::Edge::Out => Self { left: Some(clip), right: tr.items.iter().find(|x| x.start == it.end()).map(|x| x.id) }, + filmcraft_edit::Edge::In => Self { left: tr.items.iter().find(|x| x.end() == it.start).map(|x| x.id), right: Some(clip) }, + }) + } +} + +/// The edit point menu's trim types: label, id (`timeline.editPointMenu.`), trim kind, and +/// which side of the cut it selects (the incoming clip's In or the outgoing clip's Out). +const EDIT_POINT_TYPES: &[(&str, &str, &str, filmcraft_edit::Edge)] = &[ + ("Ripple Trim In", "rippleIn", "ripple", filmcraft_edit::Edge::In), + ("Ripple Trim Out", "rippleOut", "ripple", filmcraft_edit::Edge::Out), + ("Roll Edit", "roll", "roll", filmcraft_edit::Edge::Out), + ("Trim In", "trimIn", "trim", filmcraft_edit::Edge::In), + ("Trim Out", "trimOut", "trim", filmcraft_edit::Edge::Out), +]; + +/// The edit point context menu (#219). +fn edit_point_menu(app: &mut FilmcraftApp, ctx: &egui::Context, ui: &mut egui::Ui, seq: &Sequence, target: EditPointTarget) { + let selected = app.session.state.edit_points.first().copied(); + for &(label, id, kind, edge) in EDIT_POINT_TYPES { + let clip = match edge { + filmcraft_edit::Edge::In => target.right, + filmcraft_edit::Edge::Out => target.left, + }; + // a roll needs a clip on both sides of the cut + let clip = clip.filter(|_| kind != "roll" || (target.left.is_some() && target.right.is_some())); + let out = edge == filmcraft_edit::Edge::Out; + let current = selected.is_some_and(|ep| Some(ep.clip) == clip && ep.out == out && serde_json::to_value(ep.kind).ok() == Some(json!(kind))); + let text = if current { format!("✓ {label}") } else { label.to_string() }; + let r = ui.add_enabled(clip.is_some(), egui::Button::new(text)); + app.auto.add(&format!("timeline.editPointMenu.{id}"), r.rect, label); + if r.clicked() + && let Some(c) = clip + { + let e = if out { "out" } else { "in" }; + if let Err(e) = app.session.execute("trim.selectEditPoint", json!({"clip": c.0, "edge": e, "kind": kind})) { + app.ui.status = e.to_string(); + } + ui.close(); + } + } + ui.separator(); + let r = ui.add_enabled(app.session.is_enabled("trim.applyDefaultTransition"), egui::Button::new("Apply Default Transitions")); + app.auto.add("timeline.editPointMenu.applyDefaultTransitions", r.rect, "Apply Default Transitions"); + if r.clicked() { + if let Err(e) = crate::menus::invoke(app, ctx, "trim.applyDefaultTransition", json!({})) { + app.ui.status = e; + } + ui.close(); + } + // only this cut (and its linked partners), and only when it is a through edit + let through = filmcraft_edit::through::through_edits(seq).iter().any(|e| Some(e.left) == target.left && Some(e.right) == target.right); + let r = ui.add_enabled(through, egui::Button::new("Join Through Edits")); + app.auto.add("timeline.editPointMenu.joinThroughEdits", r.rect, "Join Through Edits"); + if r.clicked() + && let (Some(left), Some(right)) = (target.left, target.right) + { + if let Err(e) = app.session.execute("sequence.joinThroughEdits", json!({"cut": [left.0, right.0]})) { + app.ui.status = e.to_string(); + } + ui.close(); + } +} + /// The clip menu's Multi-Camera submenu: Enable, Flatten, and the cameras of the selected nested /// sequence clips (greyed out when none of the selected clips is a nested sequence). fn multicam_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, picked: &[&TrackItem]) { @@ -2065,16 +2143,39 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & } } - // ---- context menu on clips (right-clicking an unselected clip selects it first) + // ---- context menu on clips (right-clicking an unselected clip selects it first); on a clip's + // edge, the edit point menu instead (right-clicking an unselected edit point selects it first) + let edit_point_menu_id = egui::Id::new("timeline.editPointMenu.target"); if resp.secondary_clicked() && let Some(p) = resp.interact_pointer_pos() - && let Hit::Clip { clip, .. } = hit(seq, layout, p) - && !app.session.state.selection.contains(&clip) + && let Hit::Clip { clip, edge, track } = hit(seq, layout, p) { - let _ = app.session.execute("timeline.select", json!({"clips": [clip.0]})); + let target = edge.and_then(|e| EditPointTarget::at(seq, track, clip, e).map(|t| (t, e))); + ctx.data_mut(|d| d.insert_temp(edit_point_menu_id, target.map(|(t, _)| t))); + if let Some((t, e)) = target { + let on_it = app.session.state.edit_points.iter().any(|ep| (ep.out && Some(ep.clip) == t.left) || (!ep.out && Some(ep.clip) == t.right)); + if !on_it { + let kind = match tool { + Tool::Rolling => "roll", + Tool::Ripple => "ripple", + _ => { + let (dist, neighbour) = edge_geometry(seq, layout, track, clip, e, p.x); + selection_trim_kind(app.session.prefs.trim.selection_tool_roll_ripple, false, false, dist, neighbour) + } + }; + let edge = if e == filmcraft_edit::Edge::In { "in" } else { "out" }; + let _ = app.session.execute("trim.selectEditPoint", json!({"clip": clip.0, "edge": edge, "kind": kind})); + } + } else if !app.session.state.selection.contains(&clip) { + let _ = app.session.execute("timeline.select", json!({"clips": [clip.0]})); + } } resp.context_menu(|ui| { ui.set_min_width(220.0); + if let Some(target) = ctx.data(|d| d.get_temp::>(edit_point_menu_id)).flatten() { + edit_point_menu(app, &ctx, ui, seq, target); + return; + } let sel = app.session.state.selection.clone(); let picked: Vec<&filmcraft_project::TrackItem> = sel.iter().filter_map(|c| seq.find_item(*c).map(|(_, it)| it)).collect(); let all_enabled = !picked.is_empty() && picked.iter().all(|it| it.enabled); diff --git a/crates/ui-egui/tests/edit_point_menu_ui.rs b/crates/ui-egui/tests/edit_point_menu_ui.rs new file mode 100644 index 00000000..d4e259b5 --- /dev/null +++ b/crates/ui-egui/tests/edit_point_menu_ui.rs @@ -0,0 +1,160 @@ +//! Headless UI test of the timeline's edit point menu (#219): right-clicking a cut between two +//! clips offers its trim types, Apply Default Transitions and Join Through Edits, as in Premiere; +//! right-clicking the body of a clip still opens the clip menu. + +use std::sync::mpsc::{Sender, channel}; + +use egui_kittest::Harness; +use filmcraft_engine::Session; +use filmcraft_engine::project::{ClipId, Sequence}; +use filmcraft_ui_egui::FilmcraftApp; +use filmcraft_ui_egui::control::ControlRequest; +use serde_json::{Value, json}; + +struct Driver { + harness: Harness<'static, FilmcraftApp>, + tx: Sender, +} + +impl Driver { + fn new() -> Self { + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + let (tx, rx) = channel(); + let app = FilmcraftApp::new(s).with_control(rx); + let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000).build_eframe(move |_cc| app); + let mut d = Driver { harness, tx }; + d.frames(4); + d + } + + fn frames(&mut self, n: usize) { + for _ in 0..n { + // the control channel's clicks and keys enter through the input hook + let ctx = self.harness.ctx.clone(); + let mut raw = std::mem::take(self.harness.input_mut()); + eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw); + *self.harness.input_mut() = raw; + self.harness.step(); + } + } + + fn ok(&mut self, method: &str, params: Value) -> Value { + let (req, reply) = ControlRequest::new(method, params.clone()); + self.tx.send(req).unwrap(); + for _ in 0..600 { + self.frames(1); + if let Ok(v) = reply.try_recv() { + assert_eq!(v["ok"], json!(true), "{method} {params} failed: {v}"); + return v["result"].clone(); + } + } + panic!("no reply to {method} {params}"); + } + + fn exec(&mut self, command: &str, params: Value) -> Value { + self.ok("engine.execute", json!({"command": command, "params": params})) + } + + fn click(&mut self, id: &str) { + self.ok("ui.click", json!({"id": id})); + self.frames(3); + } + + fn has(&mut self, id: &str) -> bool { + let v = self.ok("ui.elements", json!({"prefix": id})); + v.as_array().unwrap().iter().any(|e| e["id"] == json!(id)) + } + + fn seq(&mut self) -> Sequence { + self.harness.state_mut().session.active_sequence().unwrap().clone() + } + + /// Right-click `clip` at `fx` of its width (0.0 = its In edge, 0.5 = its middle). + fn right_click(&mut self, clip: ClipId, fx: f64) { + self.ok("ui.click", json!({"id": format!("timeline.clip.{}", clip.0), "fx": fx, "button": "right"})); + self.frames(3); + } + + /// Two different clips on V1 that touch, with no transition on the cut (a cut, not a through + /// edit): (outgoing, incoming). + fn cut_on_v1(&mut self) -> (ClipId, ClipId) { + let seq = self.seq(); + let tr = &seq.video_tracks[0]; + let bare = |a: ClipId, b: ClipId| !tr.transitions.iter().any(|t| t.from == Some(a) || t.to == Some(b)); + tr.items + .windows(2) + .find(|w| w[0].end() == w[1].start && w[0].item != w[1].item && bare(w[0].id, w[1].id)) + .map(|w| (w[0].id, w[1].id)) + .expect("two touching clips on V1") + } +} + +#[test] +fn right_clicking_a_cut_opens_the_edit_point_menu() { + let mut d = Driver::new(); + let (a, b) = d.cut_on_v1(); + d.right_click(b, 0.0); + for id in ["rippleIn", "rippleOut", "roll", "trimIn", "trimOut", "applyDefaultTransitions", "joinThroughEdits"] { + assert!(d.has(&format!("timeline.editPointMenu.{id}")), "{id}"); + } + assert!(!d.has("timeline.clipMenu.edit.cut"), "not the clip menu"); + let eps = d.harness.state_mut().session.state.edit_points.clone(); + assert_eq!(eps.len(), 1, "the cut is selected as an edit point"); + assert!((eps[0].clip == a && eps[0].out) || (eps[0].clip == b && !eps[0].out), "{eps:?}"); + + // Trim In: the incoming clip's In edge, as a regular trim + d.click("timeline.editPointMenu.trimIn"); + let ep = d.harness.state_mut().session.state.edit_points.clone(); + assert_eq!(ep.len(), 1); + assert_eq!((ep[0].clip, ep[0].out, serde_json::to_value(ep[0].kind).unwrap()), (b, false, json!("trim"))); + + // Apply Default Transitions puts a transition on the cut + d.right_click(b, 0.0); + let before = d.seq().video_tracks[0].transitions.len(); + d.click("timeline.editPointMenu.applyDefaultTransitions"); + assert_eq!(d.seq().video_tracks[0].transitions.len(), before + 1, "a transition was added"); +} + +#[test] +fn the_body_of_a_clip_still_opens_the_clip_menu() { + let mut d = Driver::new(); + let (_, b) = d.cut_on_v1(); + d.right_click(b, 0.0); + d.ok("ui.key", json!({"key": "Escape"})); + d.frames(3); + d.right_click(b, 0.5); + assert!(d.has("timeline.clipMenu.edit.cut"), "the clip menu"); + assert!(!d.has("timeline.editPointMenu.rippleIn"), "not the edit point menu"); + assert!(d.harness.state_mut().session.state.selection.contains(&b), "the clip is selected"); +} + +#[test] +fn join_through_edits_joins_only_the_right_clicked_cut() { + let mut d = Driver::new(); + let first = d.seq().video_tracks[0].items[0].clone(); + // two through edits inside the first clip on V1 + let secs = |t: i64| t as f64 / filmcraft_time::TICKS_PER_SECOND as f64; + let (start, len) = (secs(first.start.0), secs(first.duration.0)); + for f in [0.3, 0.6] { + d.exec("playhead.set", json!({"seconds": start + len * f})); + assert_eq!(d.exec("timeline.razor", json!({"track": "V1"}))["cuts"], json!(1)); + } + d.frames(2); + let n = d.seq().video_tracks[0].items.len(); + let pieces: Vec = d.seq().video_tracks[0].items.iter().take(3).map(|i| i.id).collect(); + // the cut between the 2nd and 3rd pieces + d.right_click(pieces[2], 0.0); + d.click("timeline.editPointMenu.joinThroughEdits"); + let items = d.seq().video_tracks[0].items.clone(); + assert_eq!(items.len(), n - 1, "one cut joined"); + assert_eq!((items[0].id, items[1].id), (pieces[0], pieces[1]), "the other through edit is still there"); + + // a cut between two different clips is not a through edit + let (_, b) = d.cut_on_v1(); + d.right_click(b, 0.0); + assert!(d.has("timeline.editPointMenu.joinThroughEdits")); + let before = d.seq().video_tracks[0].items.len(); + d.click("timeline.editPointMenu.joinThroughEdits"); + assert_eq!(d.seq().video_tracks[0].items.len(), before, "disabled: nothing joined"); +} From 17921200d7853c1deb4bddf3de8440c25a8e9bb6 Mon Sep 17 00:00:00 2001 From: txiki739 <111882715+txiki739@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:37:09 +0200 Subject: [PATCH 02/64] #346: the from-source Flatpak includes the CLI and MCP server The release bundle manifest installs filmcraft and filmcraft-cli; the from-source manifest built and installed only the desktop app, so a Flatpak built from source had no headless CLI or stdio MCP server. It now builds -p filmcraft -p filmcraft-cli and installs /app/bin/filmcraft-cli. The default command stays filmcraft; the CLI runs with `flatpak run --command=filmcraft-cli ai.storyteller.filmcraft` (the manifest header and docs/releasing.md show it). Runtime, SDK and finish-args are unchanged. packaging-lint now also checks that both manifests install the same programs, {filmcraft, filmcraft-cli}; it fails on the old manifest. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/packaging-lint.yml | 5 +++++ docs/releasing.md | 4 +++- packaging/linux/flatpak/ai.storyteller.filmcraft.yml | 9 ++++++++- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index bf7c41d3..3b9d81cd 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -64,6 +64,11 @@ jobs: assert m["id"] == "ai.storyteller.filmcraft" and m["command"] == "filmcraft", m["id"] for key in ("runtime", "runtime-version", "sdk", "finish-args"): assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}" + # Both install the app and the CLI/MCP server (#346). + import re + def programs(m): + return {p for c in m["modules"][0]["build-commands"] for p in re.findall(r"/app/bin/([\w.-]+)", c)} + assert programs(src) == programs(bundle) == {"filmcraft", "filmcraft-cli"}, f"Flatpak manifests install {programs(src)} vs {programs(bundle)}" print("Flatpak manifests ok") EOF - name: FreeBSD package layout (stub binaries) diff --git a/docs/releasing.md b/docs/releasing.md index 14e699c5..631e2ee9 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -138,7 +138,9 @@ runs `filmcraft-cli --version` in the sandbox. Users install it with `flatpak install --user `; the freedesktop runtime comes from Flathub. `packaging/linux/flatpak/ai.storyteller.filmcraft.yml` is the from-source manifest for a Flathub submission (its header says how to build it). Both manifests must keep the same runtime and -`finish-args` (packaging-lint checks this). +`finish-args`, and install the same programs: the app and `filmcraft-cli`, which runs the +headless commands and the MCP server in the sandbox +(`flatpak run --command=filmcraft-cli ai.storyteller.filmcraft mcp`). packaging-lint checks this. ### FreeBSD diff --git a/packaging/linux/flatpak/ai.storyteller.filmcraft.yml b/packaging/linux/flatpak/ai.storyteller.filmcraft.yml index 3bbc1ecd..ef8addc7 100644 --- a/packaging/linux/flatpak/ai.storyteller.filmcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.filmcraft.yml @@ -7,6 +7,11 @@ # flatpak-builder --user --install --force-clean build-flatpak packaging/linux/flatpak/ai.storyteller.filmcraft.yml # flatpak run ai.storyteller.filmcraft # +# The command-line tool and its MCP server run in the same sandbox, without opening a window: +# +# flatpak run --command=filmcraft-cli ai.storyteller.filmcraft --version +# flatpak run --command=filmcraft-cli ai.storyteller.filmcraft mcp +# # For Flathub, replace the `dir` source with a `git` source pinned to a release tag and commit. id: ai.storyteller.filmcraft runtime: org.freedesktop.Platform @@ -49,8 +54,10 @@ modules: CARGO_HOME: /run/build/filmcraft/cargo CARGO_NET_OFFLINE: 'true' build-commands: - - cargo --offline build --release --locked -p filmcraft + - cargo --offline build --release --locked -p filmcraft -p filmcraft-cli - install -Dm755 target/release/filmcraft /app/bin/filmcraft + # The headless CLI and its stdio MCP server, as in the release bundle (#346). + - install -Dm755 target/release/filmcraft-cli /app/bin/filmcraft-cli - install -Dm644 packaging/linux/ai.storyteller.filmcraft.desktop /app/share/applications/ai.storyteller.filmcraft.desktop - install -Dm644 packaging/linux/ai.storyteller.filmcraft.mime.xml /app/share/mime/packages/ai.storyteller.filmcraft.xml - mkdir -p /app/share/icons && cp -R assets/app-icon/hicolor /app/share/icons/ From ccaa7212c1e1d1ca07aaa0be861e8a4b4dc8f1a9 Mon Sep 17 00:00:00 2001 From: txiki739 <111882715+txiki739@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:06:49 +0200 Subject: [PATCH 03/64] #323: AAF files open in readers that load the meta-dictionary The Root's MetaDictionary was written with no properties. pyaaf2 loads its ClassDefinitions and TypeDefinitions sets to merge them with its built-in model and refused every file: "cannot find index stream: ClassDefinitions-3 index". The MetaDictionary now has both sets (pids 0x0003, 0x0004, keyed by the definition AUID 0x0005), empty: they only carry extensions to the baseline model and everything FilmCraft writes is baseline. An empty strong-reference set also writes its real key size (16 for AUIDs, 32 for MobIDs) instead of 0. Co-Authored-By: Claude Opus 5.5 --- crates/interchange/src/aaf/ids.rs | 4 ++++ crates/interchange/src/aaf/store.rs | 14 +++++++++++++- crates/interchange/src/aaf/tests.rs | 26 ++++++++++++++++++++++++++ crates/interchange/src/aaf/write.rs | 10 +++++++--- 4 files changed, 50 insertions(+), 4 deletions(-) diff --git a/crates/interchange/src/aaf/ids.rs b/crates/interchange/src/aaf/ids.rs index c010f5a7..262d1d0b 100644 --- a/crates/interchange/src/aaf/ids.rs +++ b/crates/interchange/src/aaf/ids.rs @@ -82,6 +82,10 @@ pub(crate) mod pid { // Root pub const ROOT_META_DICTIONARY: u16 = 0x0001; pub const ROOT_HEADER: u16 = 0x0002; + // MetaDictionary and MetaDefinition + pub const META_CLASS_DEFINITIONS: u16 = 0x0003; + pub const META_TYPE_DEFINITIONS: u16 = 0x0004; + pub const META_IDENTIFICATION: u16 = 0x0005; // Header pub const BYTE_ORDER: u16 = 0x3B01; pub const LAST_MODIFIED: u16 = 0x3B02; diff --git a/crates/interchange/src/aaf/store.rs b/crates/interchange/src/aaf/store.rs index c89b143d..69b17e88 100644 --- a/crates/interchange/src/aaf/store.rs +++ b/crates/interchange/src/aaf/store.rs @@ -172,6 +172,8 @@ fn prop_name(pid: u16) -> &'static str { match pid { 0x0001 => "MetaDictionary", 0x0002 => "Header", + 0x0003 => "ClassDefinitions", + 0x0004 => "TypeDefinitions", 0x3B03 => "Content", 0x3B04 => "Dictionary", 0x3B06 => "IdentificationList", @@ -200,6 +202,16 @@ fn prop_name(pid: u16) -> &'static str { } } +/// The key size of a strong reference set keyed by `key_pid`, for a set with no entry to take it +/// from: AUIDs (definitions) are 16 bytes, MobIDs 32. +fn set_key_size(key_pid: u16) -> usize { + match key_pid { + 0x0005 | 0x1B01 => 16, + 0x4401 | 0x2701 => 32, + _ => 0, + } +} + fn storage_name(pid: u16) -> String { let suffix = format!("-{pid:x}"); let name: String = prop_name(pid).chars().take(22 - suffix.len()).collect(); @@ -269,7 +281,7 @@ impl W { index.extend_from_slice(&(items.len() as u32).to_le_bytes()); // first free key index.extend_from_slice(&u32::MAX.to_le_bytes()); // last free key let set_key = if let Value::StrongSet(_, k) = v { Some(*k) } else { None }; - let key_size = set_key.map(|k| items.first().and_then(|i| i.data(k)).map_or(0, <[u8]>::len)).unwrap_or(0); + let key_size = set_key.map(|k| items.first().and_then(|i| i.data(k)).map_or_else(|| set_key_size(k), <[u8]>::len)).unwrap_or(0); if let Some(k) = set_key { index.extend_from_slice(&k.to_le_bytes()); index.push(key_size as u8); diff --git a/crates/interchange/src/aaf/tests.rs b/crates/interchange/src/aaf/tests.rs index 4cfbf62e..1cfe1c2e 100644 --- a/crates/interchange/src/aaf/tests.rs +++ b/crates/interchange/src/aaf/tests.rs @@ -94,6 +94,32 @@ fn keys(dict: &Obj, p: u16) -> HashSet> { dict.objs(p).iter().filter_map(|d| d.data(pid::IDENTIFICATION).map(<[u8]>::to_vec)).collect() } +/// #323: readers that load the meta-dictionary (pyaaf2) need its ClassDefinitions and +/// TypeDefinitions sets. They are empty: everything written uses the baseline classes and types +/// those readers already know. +#[test] +fn the_meta_dictionary_has_its_class_and_type_definition_sets() { + let (p, s) = project(); + for small in [false, true] { + let (bytes, _) = export(&p, s, &AafOptions { small_sectors: small, ..Default::default() }).unwrap(); + let cf = filmcraft_cfb::CompoundFile::open(&bytes).unwrap(); + assert_eq!(cf.entries[cf.find("MetaDictionary-1").unwrap()].clsid, ids::META_DICTIONARY); + for name in ["ClassDefinitions-3", "TypeDefinitions-4"] { + // u32 count, u32 first free key, u32 last free key, u16 key pid, u8 key size + let index = cf.read_path(&format!("MetaDictionary-1/{name} index")).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(index.len(), 15, "{name}: an empty set"); + assert_eq!(index[0..4], 0u32.to_le_bytes(), "{name}: no entries"); + assert_eq!(u16::from_le_bytes([index[12], index[13]]), pid::META_IDENTIFICATION, "{name}: keyed by the definition's AUID"); + assert_eq!(index[14], 16, "{name}: 16-byte keys"); + } + let root = store::read(&bytes).unwrap(); + let meta = root.strong(pid::ROOT_META_DICTIONARY).unwrap(); + for set in [pid::META_CLASS_DEFINITIONS, pid::META_TYPE_DEFINITIONS] { + assert!(matches!(meta.get(set), Some(Value::StrongSet(items, k)) if items.is_empty() && *k == pid::META_IDENTIFICATION), "{set:#06x}"); + } + } +} + #[test] fn written_files_have_the_required_structure() { let (p, s) = project(); diff --git a/crates/interchange/src/aaf/write.rs b/crates/interchange/src/aaf/write.rs index f0876cfe..ea0ea8e8 100644 --- a/crates/interchange/src/aaf/write.rs +++ b/crates/interchange/src/aaf/write.rs @@ -349,9 +349,13 @@ pub(crate) fn write(doc: &Document, version: filmcraft_cfb::Version, report: &mu .with(pid::CONTENT, Value::Strong(Box::new(content))) .with(pid::DICTIONARY, Value::Strong(Box::new(dict))) .with(pid::IDENTIFICATION_LIST, Value::StrongVec(vec![ident])); - let root = Obj::new(ROOT) - .with(pid::ROOT_META_DICTIONARY, Value::Strong(Box::new(Obj::new(META_DICTIONARY)))) - .with(pid::ROOT_HEADER, Value::Strong(Box::new(header))); + // The meta-dictionary holds only extensions to the baseline object model, and everything written + // here is baseline, so its class and type definition sets are empty; readers that load them + // (pyaaf2) still need the sets to be there (#323). + let meta = Obj::new(META_DICTIONARY) + .with(pid::META_CLASS_DEFINITIONS, Value::StrongSet(Vec::new(), pid::META_IDENTIFICATION)) + .with(pid::META_TYPE_DEFINITIONS, Value::StrongSet(Vec::new(), pid::META_IDENTIFICATION)); + let root = Obj::new(ROOT).with(pid::ROOT_META_DICTIONARY, Value::Strong(Box::new(meta))).with(pid::ROOT_HEADER, Value::Strong(Box::new(header))); store::write(&root, version).map_err(Error::Other) } From 1eaeac9b1fe9264f816d3012e1e09fc186f8b905 Mon Sep 17 00:00:00 2001 From: Tom <39281660+Tom1tk@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:19:08 +0100 Subject: [PATCH 04/64] Windows: skip AV1 oracle tests when ffmpeg lacks libsvtav1/libdav1d --- crates/av1/tests/adhoc.rs | 2 +- crates/av1/tests/common/mod.rs | 21 ++++++++++++++++++++- crates/av1/tests/conformance.rs | 4 ++-- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/crates/av1/tests/adhoc.rs b/crates/av1/tests/adhoc.rs index d72a220e..e140b976 100644 --- a/crates/av1/tests/adhoc.rs +++ b/crates/av1/tests/adhoc.rs @@ -7,7 +7,7 @@ use common::*; #[test] #[ignore] fn adhoc_compare() { - let Some(ff) = ffmpeg() else { return }; + let Some(ff) = ffmpeg_dav1d() else { return }; let Some(path) = std::env::var_os("AV1_IVF") else { return }; let path = std::path::PathBuf::from(path); let pics = decode_all(&path).unwrap(); diff --git a/crates/av1/tests/common/mod.rs b/crates/av1/tests/common/mod.rs index 7998dbd1..0892fd69 100644 --- a/crates/av1/tests/common/mod.rs +++ b/crates/av1/tests/common/mod.rs @@ -6,8 +6,27 @@ use filmcraft_av1::{Decoder, Picture}; use std::path::{Path, PathBuf}; use std::process::Command; +/// ffmpeg with libsvtav1 (fixtures) and libdav1d (reference), or `None` after reporting a skip. pub fn ffmpeg() -> Option { - filmcraft_testkit::ffmpeg_or_skip("av1 oracle") + ffmpeg_with(&[("-encoders", "libsvtav1"), ("-decoders", "libdav1d")]) +} + +/// ffmpeg with libdav1d, for checks that decode existing files only. +pub fn ffmpeg_dav1d() -> Option { + ffmpeg_with(&[("-decoders", "libdav1d")]) +} + +/// Builds such as the Windows "essentials" ones lack these libraries; skip rather than fail. +fn ffmpeg_with(needs: &[(&str, &str)]) -> Option { + let ff = filmcraft_testkit::ffmpeg_or_skip("av1 oracle")?; + for &(list, lib) in needs { + let out = Command::new(&ff).args(["-hide_banner", list]).output().map(|o| String::from_utf8_lossy(&o.stdout).into_owned()).unwrap_or_default(); + if !out.contains(lib) { + filmcraft_testkit::skip("av1 oracle", &format!("ffmpeg has no {lib} (see `ffmpeg {list}`)")); + return None; + } + } + Some(ff) } pub fn fixture_dir() -> PathBuf { diff --git a/crates/av1/tests/conformance.rs b/crates/av1/tests/conformance.rs index d309bac6..033f590e 100644 --- a/crates/av1/tests/conformance.rs +++ b/crates/av1/tests/conformance.rs @@ -9,7 +9,7 @@ const VECTORS: &[&str] = #[test] fn conformance_vectors() { - let Some(ff) = ffmpeg() else { return }; + let Some(ff) = ffmpeg_dav1d() else { return }; for name in VECTORS { let Some(path) = test_vector(name) else { return }; check_file(&ff, name, &path); @@ -21,7 +21,7 @@ fn conformance_vectors() { #[test] #[ignore] fn conformance_vectors_extended() { - let Some(ff) = ffmpeg() else { return }; + let Some(ff) = ffmpeg_dav1d() else { return }; let list: Vec = match std::env::var("AV1_VECTORS") { Ok(v) => v.split(',').map(str::to_string).collect(), Err(_) => EXTENDED.iter().map(|s| s.to_string()).collect(), From 2d520d7a2fe9adb67f9e091db8f5ca4bfa111060 Mon Sep 17 00:00:00 2001 From: carlitosteso3 <47792165+carlitosteso3@users.noreply.github.com> Date: Thu, 8 Oct 2026 08:57:44 -0500 Subject: [PATCH 05/64] #211: audio plays while scrubbing "Play audio while scrubbing in Source and Program Monitors" (audio.scrubAudio, Shift+S) was saved and toggled but never read. Now, while the playhead moves with a mouse button down and playback is stopped, an 80 ms grain of the sequence's sound (4 ms fades at its ends) plays from the new position. It is detected once per frame in the app loop, so the Timeline ruler, the monitors' scrub bars and dragged timecodes all scrub audibly. One output stream stays open for the whole scrub and each move only re-aims it; it closes shortly after the button is released, when the setting is off, or when playback starts and takes the device. Rebased on main, where Source monitor playback (source_playback) arrived: it owns the audio device while it plays too, so a scrub stream still lingering when Source playback starts no longer stops its sound on release. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 5 + crates/ui-egui/src/scrub.rs | 264 ++++++++++++++++++++++++++++++++++++ 2 files changed, 269 insertions(+) create mode 100644 crates/ui-egui/src/scrub.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 83d18261..c38121d7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -46,6 +46,7 @@ pub mod panels; pub mod perf; #[cfg(not(target_arch = "wasm32"))] pub mod play_ahead; +pub mod scrub; pub mod source_playback; pub mod state; pub mod theme; @@ -223,6 +224,8 @@ pub struct FilmcraftApp { pub playback: Playback, pub source_playback: source_playback::SourcePlayback, pub audio: Option>, + /// Audio during scrubbing (#211). + pub scrub: scrub::ScrubAudio, pub hooks: HostHooks, pub dialog: Option, pub file_dialogs: panels::file_dialogs::FileDialogState, @@ -426,6 +429,7 @@ impl FilmcraftApp { playback: Playback { speed: 1.0, ..Default::default() }, source_playback: Default::default(), audio: None, + scrub: Default::default(), hooks: HostHooks::default(), // Unsaved changes left by a session that died are offered first thing. dialog: recovery.then_some(Dialog::Recovery), @@ -1351,6 +1355,7 @@ impl FilmcraftApp { self.handle_shortcuts(&ctx); self.advance_playback(&ctx); self.advance_source_playback(&ctx); + self.scrub_audio(&ctx); let t = self.tokens; let full = ui.max_rect(); ui.painter().rect_filled(full, 0.0, t.app_bg); diff --git a/crates/ui-egui/src/scrub.rs b/crates/ui-egui/src/scrub.rs new file mode 100644 index 00000000..9f8f93fe --- /dev/null +++ b/crates/ui-egui/src/scrub.rs @@ -0,0 +1,264 @@ +//! Audio during scrubbing (#211): Settings ▸ Audio ▸ "Play audio while scrubbing in Source and +//! Program Monitors" (`audio.scrubAudio`, Shift+S). +//! +//! While the playhead moves with a mouse button down (the Timeline ruler, a monitor's scrub bar, +//! a dragged timecode) and playback is stopped, a short grain of the sequence's sound plays from +//! the new position. One output stream stays open for the whole scrub: each move only re-aims it, +//! so the device is not reopened every frame. The stream closes when the button is released and +//! the grain has played, when the setting is off, or when playback starts. + +use std::sync::{Arc, Mutex, PoisonError}; + +use filmcraft_time::Tick; + +use crate::FilmcraftApp; + +/// Length of the grain played after each move. +pub const GRAIN_S: f64 = 0.08; +/// Fade in / out at the grain's ends, so it doesn't click. +const RAMP_S: f64 = 0.004; +/// How long the stream stays open after the last move once the button is up. +const LINGER_S: f64 = 0.25; + +/// What the audio callback plays next: `left` of `total` frames from `cursor`. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +struct Grain { + cursor: i64, + left: i64, + total: i64, +} + +#[derive(Default)] +pub struct ScrubAudio { + grain: Arc>, + /// The scrub stream is open. + open: bool, + last_tick: Option, + last_move: f64, +} + +impl ScrubAudio { + pub fn is_open(&self) -> bool { + self.open + } +} + +impl FilmcraftApp { + /// Once a frame: play a grain when the playhead moved under a held mouse button. + pub(crate) fn scrub_audio(&mut self, ctx: &egui::Context) { + let (now, down) = ctx.input(|i| (i.time, i.pointer.any_down())); + if self.scrub_tick(now, down) { + ctx.request_repaint_after(std::time::Duration::from_secs_f64(LINGER_S)); + } + } + + /// [`FilmcraftApp::scrub_audio`] with the clock and the button state given. Returns whether + /// the scrub stream is still open. + pub fn scrub_tick(&mut self, now: f64, down: bool) -> bool { + let ph = self.session.playhead(); + let prev = self.scrub.last_tick.replace(ph); + if self.playback.playing || self.source_playback.clock.playing { + // playback (Program or Source) owns the device: it stopped the scrub stream when it + // started, and the scrub must not stop it when the button is released + self.scrub.open = false; + return false; + } + let on = self.session.prefs.audio.scrub_audio; + if on && down && prev.is_some_and(|p| p != ph) { + self.scrub_grain(ph, now); + } + if self.scrub.open && (!on || (!down && now - self.scrub.last_move >= LINGER_S)) { + if let Some(a) = self.audio.as_mut() { + a.stop(); + } + self.scrub.open = false; + } + self.scrub.open + } + + /// Aim the scrub stream at `at` (opening it if needed) and play one grain. + fn scrub_grain(&mut self, at: Tick, now: f64) { + let Some(seq_id) = self.session.state.active_sequence else { return }; + let Some(a) = self.audio.as_ref() else { return }; + let sr = a.sample_rate(); + if sr == 0 { + return; + } + let total = ((GRAIN_S * sr as f64) as i64).max(1); + { + let mut g = self.scrub.grain.lock().unwrap_or_else(PoisonError::into_inner); + *g = Grain { cursor: at.to_units_floor(sr as i64), left: total, total }; + } + self.scrub.last_move = now; + if self.scrub.open { + return; + } + let mix = crate::playback_mix(&self.session, seq_id, sr); + let fill = grain_fill(mix, self.scrub.grain.clone(), ((RAMP_S * sr as f64) as i64).max(1)); + let Some(a) = self.audio.as_mut() else { return }; + a.stop(); + match a.start(fill) { + Ok(_) => self.scrub.open = true, + Err(e) => log::warn!("audio output unavailable for scrubbing: {e}"), + } + } +} + +/// The scrub stream's callback: the rest of the current grain (faded at its ends), then silence. +fn grain_fill(mut mix: impl FnMut(i64, &mut [f32], usize) + Send + 'static, grain: Arc>, ramp: i64) -> Box { + Box::new(move |buf: &mut [f32], ch: usize| { + let ch = ch.max(1); + let n = (buf.len() / ch) as i64; + let (g, take) = { + let mut g = grain.lock().unwrap_or_else(PoisonError::into_inner); + let now = *g; + let take = g.left.clamp(0, n); + g.cursor = g.cursor.saturating_add(take); + g.left -= take; + (now, take) + }; + buf.fill(0.0); + let Some(part) = buf.get_mut(..(take as usize).saturating_mul(ch)) else { return }; + if part.is_empty() { + return; + } + mix(g.cursor, part, ch); + let done = g.total - g.left; + for (i, frame) in part.chunks_mut(ch).enumerate() { + let pos = done + i as i64; + let gain = (pos.min(g.total - 1 - pos) as f32 / ramp as f32).clamp(0.0, 1.0); + frame.iter_mut().for_each(|s| *s *= gain); + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn run(fill: &mut Box, frames: usize) -> Vec { + let mut buf = vec![9.0; frames * 2]; + fill(&mut buf, 2); + buf + } + + #[test] + fn a_grain_plays_once_from_its_cursor_then_silence() { + let grain = Arc::new(Mutex::new(Grain { cursor: 1000, left: 300, total: 300 })); + let starts = Arc::new(Mutex::new(Vec::new())); + let seen = starts.clone(); + let mix = move |c: i64, b: &mut [f32], _: usize| { + seen.lock().unwrap().push(c); + b.fill(1.0); + }; + let mut fill = grain_fill(mix, grain.clone(), 10); + let a = run(&mut fill, 256); + let b = run(&mut fill, 256); + let c = run(&mut fill, 256); + assert_eq!(*starts.lock().unwrap(), vec![1000, 1256], "continues where it left off"); + assert_eq!(a[0], 0.0, "fades in"); + assert_eq!(a[200], 1.0); + assert!(b[..44 * 2].iter().all(|s| *s < 1.0 + f32::EPSILON) && b[43 * 2 + 1] < 0.2, "fades out at its end"); + assert!(b[44 * 2..].iter().all(|s| *s == 0.0), "then silence"); + assert!(c.iter().all(|s| *s == 0.0)); + // a new move re-aims it + *grain.lock().unwrap() = Grain { cursor: 5000, left: 300, total: 300 }; + run(&mut fill, 256); + assert_eq!(starts.lock().unwrap().last(), Some(&5000)); + } + + type Fill = Box; + + /// An output that keeps the callback it was started with. + struct Out { + fill: Arc>>, + } + + impl crate::AudioOut for Out { + fn start(&mut self, fill: Fill) -> Result { + *self.fill.lock().unwrap() = Some(fill); + Ok(48_000) + } + fn stop(&mut self) { + *self.fill.lock().unwrap() = None; + } + fn sample_rate(&self) -> u32 { + 48_000 + } + fn played_frames(&self) -> Option { + None + } + } + + fn app(scrub_audio: bool) -> (FilmcraftApp, Arc>>) { + let mut s = filmcraft_engine::Session::default(); + s.execute("file.openDemoProject", serde_json::json!({})).unwrap(); + s.prefs.audio.scrub_audio = scrub_audio; + let mut app = FilmcraftApp::new(s); + let fill = Arc::new(Mutex::new(None)); + app.audio = Some(Box::new(Out { fill: fill.clone() })); + (app, fill) + } + + fn loudness(fill: &Arc>>) -> f32 { + let mut g = fill.lock().unwrap(); + let Some(f) = g.as_mut() else { return 0.0 }; + let mut buf = vec![0.0; 4096 * 2]; // more than a grain + f(&mut buf, 2); + buf.iter().map(|s| s.abs()).sum() + } + + /// #211: moving the playhead with the button down plays the sequence's sound at the new spot; + /// releasing the button closes the stream. + #[test] + fn scrubbing_plays_a_grain_and_stops_after_release() { + let (mut app, fill) = app(true); + let rate = app.session.sequence_rate(); + app.session.set_playhead(rate.tick_of(48)); + assert!(!app.scrub_tick(0.0, true), "pressing alone plays nothing"); + app.session.set_playhead(rate.tick_of(60)); + assert!(app.scrub_tick(0.02, true), "a move opens the stream"); + assert!(loudness(&fill) > 0.0, "the demo's music is heard"); + assert_eq!(loudness(&fill), 0.0, "one grain, then silence"); + app.session.set_playhead(rate.tick_of(72)); + assert!(app.scrub_tick(0.04, true)); + assert!(loudness(&fill) > 0.0, "the next move plays again on the same stream"); + assert!(app.scrub_tick(0.05, false), "lingers briefly after release"); + assert!(!app.scrub_tick(0.05 + LINGER_S, false), "then closes"); + assert!(fill.lock().unwrap().is_none()); + } + + #[test] + fn nothing_plays_with_the_setting_off_or_while_playing() { + let (mut app, fill) = app(false); + let rate = app.session.sequence_rate(); + app.scrub_tick(0.0, true); + app.session.set_playhead(rate.tick_of(60)); + assert!(!app.scrub_tick(0.02, true)); + assert!(fill.lock().unwrap().is_none()); + + let (mut app, _) = self::app(true); + app.playback.playing = true; + app.scrub_tick(0.0, true); + app.session.set_playhead(rate.tick_of(60)); + assert!(!app.scrub_tick(0.02, true), "playback owns the device"); + } + + /// Source monitor playback (added on main next to this feature) owns the device too: a scrub + /// that was still lingering when Source playback started must not stop its sound on release. + #[test] + fn source_playback_owns_the_device() { + let (mut app, fill) = app(true); + let rate = app.session.sequence_rate(); + app.scrub_tick(0.0, true); + app.session.set_playhead(rate.tick_of(60)); + assert!(app.scrub_tick(0.02, true), "a grain opened the scrub stream"); + // Source playback starts (it replaces the stream with its own) + app.source_playback.clock.playing = true; + let source_fill: Fill = Box::new(|buf: &mut [f32], _| buf.fill(0.5)); + *fill.lock().unwrap() = Some(source_fill); + assert!(!app.scrub_tick(0.05 + LINGER_S, false)); + assert!(fill.lock().unwrap().is_some(), "the Source playback stream is still playing"); + assert!(!app.scrub.is_open()); + } +} From d7b7027f420d6f9bfa745727d98cab9e839638e2 Mon Sep 17 00:00:00 2001 From: lloyd Date: Fri, 9 Oct 2026 17:58:49 +0200 Subject: [PATCH 06/64] M8: MP4 and Matroska colour from the bitstream when the container has none MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ffmpeg writes MP4 without a `colr` box unless asked (`+write_colr`), and color_from only read `colr` (or vpcC / apvC), so every such file fell back to BT.709 limited: an HEVC PQ file was detected as rec709 / hdr false, a full-range H.264 file as limited range, and BT.601 SD with BT.709 primaries. Decoded frames were right (the decoders read the VUI); the media info, HDR detection and Interpret Footage defaults were not. Matroska without a `Colour` element had the same gap. New `stream_color` reads the colour a codec configuration carries in its bitstream with the decoders' own parsers: the VUI of the SPS in avcC / hvcC and the color_config of the AV1 sequence header in av1C. Precedence: `colr` (nclx / nclc) or Matroska `Colour`, then vpcC / apvC / the bitstream, field by field (what the container leaves unspecified falls through), then the existing size defaults (BT.601 matrix for SD, BT.709 otherwise). nclc has no range flag, so the bitstream's range now stands; an ICC `colr` no longer hides the bitstream's code points. A truncated or corrupt parameter set gives no colour and the defaults. Tests build avcC / hvcC / av1C bit by bit: HEVC PQ → rec2100-pq, full-range H.264 → Full, BT.601 SD (NTSC and PAL) primaries, unspecified VUI keeps the defaults, colr wins, Matroska without Colour, and every truncation and single-bit flip of each configuration under catch_unwind. An ffmpeg-oracle test checks real x264 / x265 files against their `+write_colr` remux. Co-Authored-By: Claude Opus 5.5 --- crates/codecs/src/hw_frame.rs | 2 +- crates/codecs/src/lib.rs | 3 + crates/codecs/src/mkv.rs | 49 ++-- crates/codecs/src/mp4.rs | 72 +++-- crates/codecs/src/stream_color.rs | 92 +++++++ crates/codecs/src/stream_color_tests.rs | 340 ++++++++++++++++++++++++ crates/codecs/src/tests.rs | 29 ++ 7 files changed, 518 insertions(+), 69 deletions(-) create mode 100644 crates/codecs/src/stream_color.rs create mode 100644 crates/codecs/src/stream_color_tests.rs diff --git a/crates/codecs/src/hw_frame.rs b/crates/codecs/src/hw_frame.rs index 59537d80..b37fc4d0 100644 --- a/crates/codecs/src/hw_frame.rs +++ b/crates/codecs/src/hw_frame.rs @@ -53,7 +53,7 @@ pub struct FrameStreamInfo { } /// The OBUs of a low-overhead AV1 sample: `(type, payload)`, stopping at the first malformed one. -fn obus(data: &[u8]) -> Vec<(u8, &[u8])> { +pub(crate) fn obus(data: &[u8]) -> Vec<(u8, &[u8])> { let mut out = Vec::new(); let mut pos = 0usize; while let Some(&h) = data.get(pos) { diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index fb405cd7..a31da3a7 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -28,6 +28,7 @@ pub mod mp4; pub mod mpeg; pub mod mxf; pub mod ogg; +mod stream_color; pub mod video; use std::sync::{Arc, RwLock}; @@ -178,4 +179,6 @@ mod audio_timing_tests; #[cfg(test)] mod rounded_pts_tests; #[cfg(test)] +mod stream_color_tests; +#[cfg(test)] mod tests; diff --git a/crates/codecs/src/mkv.rs b/crates/codecs/src/mkv.rs index e1fa1482..70222b37 100644 --- a/crates/codecs/src/mkv.rs +++ b/crates/codecs/src/mkv.rs @@ -15,7 +15,7 @@ use std::collections::HashMap; use std::sync::{Arc, Mutex}; -use filmcraft_color::{ColorInfo, Matrix, Primaries, Range, Transfer}; +use filmcraft_color::{ColorInfo, Matrix, Transfer}; use filmcraft_frame::{AudioBuffer, VideoFrame}; use filmcraft_isobmff::{AvcConfig, CodecConfig, FourCc, HevcConfig, PcmConfig, SampleEntry, VpcConfig}; use filmcraft_matroska::{Codec, MkvFile, TrackKind}; @@ -24,6 +24,7 @@ use filmcraft_time::{FrameRate, Tick}; use crate::audio::{PacketDecoder, decode_pcm}; use crate::gop::{GopCache, VideoSamples}; +use crate::stream_color::ColorCodes; use crate::video::VideoDecoder; use crate::{CodecError, make_video_decoder}; @@ -211,32 +212,24 @@ fn hdr_metadata(c: &filmcraft_matroska::Colour) -> Option (ColorInfo, bool) { - let mut c = ColorInfo { matrix: filmcraft_frame::default_matrix(w, h), transfer: Transfer::Bt709, primaries: Primaries::Bt709, range: Range::Limited }; - let Some(col) = &v.colour else { return (c, false) }; - let mut explicit = false; - if let Some(m) = col.matrix_coefficients.and_then(|m| Matrix::from_code(m as u8)) { - c.matrix = m; - explicit = true; - } - if let Some(t) = col.transfer_characteristics.and_then(|t| Transfer::from_code(t as u8)) { - c.transfer = t; - explicit = true; - } - if let Some(p) = col.primaries { - c.primaries = match p { - 9 => Primaries::Bt2020, - 12 => Primaries::P3D65, - 5 => Primaries::Bt601_625, - 6 => Primaries::Bt601_525, - _ => Primaries::Bt709, +/// Colour of a video track: the `Colour` element wins; what it leaves unspecified, or all of it +/// when there is none, comes from the stream's own description (the SPS VUI in an `avcC` / +/// `hvcC`, the sequence header in an `av1C`); the rest defaults by frame size. The flag says +/// whether `Colour` signals anything that overrides the decoder. +fn color_of(v: &filmcraft_matroska::VideoInfo, w: u32, h: u32, stream: Option) -> (ColorInfo, bool) { + // H.273 code points; `Range`: 1 broadcast (limited), 2 full, 0 / 3 not signalled here + let code = |c: Option| c.unwrap_or(2); + let container = v.colour.as_ref().map(|col| { + let range = match col.range { + Some(1) => Some(false), + Some(2) => Some(true), + _ => None, }; - } - if col.full_range() { - c.range = Range::Full; - explicit = true; - } - (c, explicit) + ColorCodes::from_wide(code(col.primaries), code(col.transfer_characteristics), code(col.matrix_coefficients), range) + }); + let explicit = container.is_some_and(|c| Matrix::from_code(c.matrix).is_some() || Transfer::from_code(c.transfer).is_some() || c.full_range == Some(true)); + let sources: Vec = [container, stream].into_iter().flatten().collect(); + (crate::stream_color::resolve(w, h, &sources), explicit) } fn codec_label(c: &Codec) -> String { @@ -297,11 +290,11 @@ impl MkvSource { FrameRate::from_f64(1.0 / step) } }; - let (color, explicit) = color_of(&v, w, h); + ventry = sample_entry(&t.codec, &t.codec_private, t.video.as_ref(), w as u16, h as u16); + let (color, explicit) = color_of(&v, w, h, ventry.as_ref().and_then(|e| crate::stream_color::from_codec_config(&e.codec))); if explicit { explicit_color = Some(color); } - ventry = sample_entry(&t.codec, &t.codec_private, t.video.as_ref(), w as u16, h as u16); let secs = file.duration_ns().unwrap_or(0) as f64 / 1e9; let bitrate = (secs > 0.0).then(|| (t.samples.iter().map(|s| s.size as u64).sum::() as f64 * 8.0 / secs) as u64); let ((w, h), par) = if rotation % 2 == 1 { ((h, w), (v.pixel_aspect().1, v.pixel_aspect().0)) } else { ((w, h), v.pixel_aspect()) }; diff --git a/crates/codecs/src/mp4.rs b/crates/codecs/src/mp4.rs index 2cdcad31..5e085593 100644 --- a/crates/codecs/src/mp4.rs +++ b/crates/codecs/src/mp4.rs @@ -7,7 +7,7 @@ use std::collections::HashMap; use std::sync::{Arc, Mutex}; -use filmcraft_color::{ColorInfo, Matrix, Primaries, Range, Transfer}; +use filmcraft_color::ColorInfo; use filmcraft_frame::{AudioBuffer, VideoFrame}; use filmcraft_isobmff::{CodecConfig, Mp4File, TrackKind}; use filmcraft_media::{AudioStreamInfo, FrameRequest, MediaError, MediaInfo, MediaKind, MediaSource, SharedSource, VideoStreamInfo}; @@ -97,48 +97,40 @@ fn hdr_metadata(md: Option<&filmcraft_isobmff::MasteringDisplay>, cll: Option<(u }) } +/// Colour of a video sample entry. The `colr` box (`nclx` / `nclc`) wins; what it leaves +/// unspecified, or all of it when there is none (ffmpeg writes MP4 without `colr` by default), +/// comes from the stream's own description: `vpcC` / `apvC`, or the SPS VUI in `avcC` / `hvcC` +/// and the sequence header in `av1C`. The rest defaults by frame size. fn color_from(entry: &filmcraft_isobmff::SampleEntry, w: u32, h: u32) -> ColorInfo { - let mut c = ColorInfo { matrix: filmcraft_frame::default_matrix(w, h), transfer: Transfer::Bt709, primaries: Primaries::Bt709, range: Range::Limited }; - // VP9 and APV carry their colour description in vpcC / apvC (used when there is no colr box). - let vpc = match &entry.codec { - CodecConfig::Vp9(v) => Some(filmcraft_isobmff::ColorInfo::Nclx { - primaries: v.colour_primaries as u16, - transfer: v.transfer_characteristics as u16, - matrix: v.matrix_coefficients as u16, - full_range: v.full_range, - }), - CodecConfig::Apv(a) if a.color_description_present => Some(filmcraft_isobmff::ColorInfo::Nclx { - primaries: a.color_primaries as u16, - transfer: a.transfer_characteristics as u16, - matrix: a.matrix_coefficients as u16, - full_range: a.full_range, - }), + use crate::stream_color::ColorCodes; + use filmcraft_isobmff::ColorInfo as Colr; + let colr = match entry.video.as_ref().and_then(|v| v.color.as_ref()) { + Some(Colr::Nclx { primaries, transfer, matrix, full_range }) => { + Some(ColorCodes::from_wide((*primaries).into(), (*transfer).into(), (*matrix).into(), Some(*full_range))) + } + // QuickTime `nclc` has no range flag + Some(Colr::Nclc { primaries, transfer, matrix }) => Some(ColorCodes::from_wide((*primaries).into(), (*transfer).into(), (*matrix).into(), None)), + // an ICC profile, or no `colr`: no code points _ => None, }; - if let Some(col) = entry.video.as_ref().and_then(|v| v.color.as_ref()).or(vpc.as_ref()) { - let (p, t, m, full) = match col { - filmcraft_isobmff::ColorInfo::Nclx { primaries, transfer, matrix, full_range } => (*primaries, *transfer, *matrix, *full_range), - filmcraft_isobmff::ColorInfo::Nclc { primaries, transfer, matrix } => (*primaries, *transfer, *matrix, false), - _ => return c, - }; - if let Some(m) = Matrix::from_code(m as u8) { - c.matrix = m; - } - if let Some(t) = Transfer::from_code(t as u8) { - c.transfer = t; - } - c.primaries = match p { - 9 => Primaries::Bt2020, - 12 => Primaries::P3D65, - 5 => Primaries::Bt601_625, - 6 => Primaries::Bt601_525, - _ => Primaries::Bt709, - }; - if full { - c.range = Range::Full; - } - } - c + let stream = match &entry.codec { + CodecConfig::Vp9(v) => Some(ColorCodes { + primaries: v.colour_primaries, + transfer: v.transfer_characteristics, + matrix: v.matrix_coefficients, + full_range: Some(v.full_range), + }), + CodecConfig::Apv(a) if a.color_description_present => Some(ColorCodes { + primaries: a.color_primaries, + transfer: a.transfer_characteristics, + matrix: a.matrix_coefficients, + full_range: Some(a.full_range), + }), + CodecConfig::Apv(_) => None, + codec => crate::stream_color::from_codec_config(codec), + }; + let sources: Vec = [colr, stream].into_iter().flatten().collect(); + crate::stream_color::resolve(w, h, &sources) } impl Mp4Source { diff --git a/crates/codecs/src/stream_color.rs b/crates/codecs/src/stream_color.rs new file mode 100644 index 00000000..d01d6a64 --- /dev/null +++ b/crates/codecs/src/stream_color.rs @@ -0,0 +1,92 @@ +//! The colour description a video stream carries in its own bitstream, for containers that don't +//! repeat it: the H.264 / HEVC sequence parameter set's VUI (from `avcC` / `hvcC`) and the AV1 +//! sequence header's `color_config` (from `av1C`), read with the decoders' own parsers. +//! +//! ffmpeg writes MP4 without a `colr` box unless asked (`-movflags +write_colr`), so for most +//! files the bitstream is the only place their colour is described. [`resolve`] merges it with +//! what the container signals, which wins. + +use filmcraft_color::{ColorInfo, Matrix, Range, Transfer}; +use filmcraft_isobmff::{AvcConfig, CodecConfig, HevcConfig}; + +use crate::video::primaries_from_code; + +/// ITU-T H.273 `Unspecified`. +const UNSPECIFIED: u8 = 2; + +/// Colour code points (ITU-T H.273 / ISO/IEC 23091-2) and range as one place in a file (a +/// container colour box, a sequence header) signals them. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ColorCodes { + pub primaries: u8, + pub transfer: u8, + pub matrix: u8, + /// `None`: this place doesn't say. + pub full_range: Option, +} + +impl ColorCodes { + /// From wider code points (a `colr` box or a Matroska `Colour` element): a value that doesn't + /// fit a byte is no H.273 code point, so it counts as unspecified. + pub fn from_wide(primaries: u64, transfer: u64, matrix: u64, full_range: Option) -> Self { + let code = |v: u64| u8::try_from(v).unwrap_or(UNSPECIFIED); + Self { primaries: code(primaries), transfer: code(transfer), matrix: code(matrix), full_range } + } +} + +/// The colour a video codec configuration's bitstream signals: the VUI of the first SPS in an +/// `avcC` / `hvcC`, or the `color_config` of the sequence header in an `av1C`. `None` for other +/// codecs, an SPS without VUI, and a configuration that is truncated or corrupt. +pub fn from_codec_config(codec: &CodecConfig) -> Option { + match codec { + CodecConfig::Avc(c) => avc(c), + CodecConfig::Hevc(c) => hevc(c), + CodecConfig::Av1(c) => av1(&c.config_obus), + _ => None, + } +} + +fn avc(c: &AvcConfig) -> Option { + // NAL header byte: nal_unit_type 7 is an SPS + let nal = c.sps.iter().find(|n| n.first().is_some_and(|h| h & 0x1f == 7))?; + let sps = filmcraft_h264::params::Sps::parse(&filmcraft_bitstream::unescape_rbsp(nal.get(1..)?)).ok()?; + let v = sps.vui?; + Some(ColorCodes { primaries: v.colour_primaries, transfer: v.transfer_characteristics, matrix: v.matrix_coefficients, full_range: Some(v.full_range) }) +} + +fn hevc(c: &HevcConfig) -> Option { + // two-byte NAL header: nal_unit_type 33 is an SPS (whatever array the record files it under) + let nal = c.arrays.iter().flat_map(|a| &a.nalus).find(|n| n.first().is_some_and(|h| (h >> 1) & 0x3f == 33))?; + let sps = filmcraft_hevc::params::Sps::parse(&filmcraft_bitstream::unescape_rbsp(nal.get(2..)?)).ok()?; + let v = sps.vui?; + Some(ColorCodes { primaries: v.colour_primaries, transfer: v.transfer_characteristics, matrix: v.matrix_coefficients, full_range: Some(v.full_range) }) +} + +fn av1(config_obus: &[u8]) -> Option { + // OBU type 1: the sequence header + let (_, payload) = crate::hw_frame::obus(config_obus).into_iter().find(|(t, _)| *t == 1)?; + let c = filmcraft_av1::SequenceHeader::parse(payload).ok()?.color; + Some(ColorCodes { primaries: c.color_primaries, transfer: c.transfer_characteristics, matrix: c.matrix_coefficients, full_range: Some(c.color_range) }) +} + +/// Colour from the code points a file signals, most authoritative first (the container's colour +/// box, then the bitstream's): the matrix, transfer, primaries and range each come from the first +/// source that signals a value we know. What no source signals keeps the defaults: the matrix by +/// frame size ([`filmcraft_frame::default_matrix`]: BT.601 for SD, BT.709 otherwise), BT.709 +/// transfer and primaries, limited range. +pub fn resolve(width: u32, height: u32, sources: &[ColorCodes]) -> ColorInfo { + let mut c = ColorInfo { matrix: filmcraft_frame::default_matrix(width, height), ..ColorInfo::REC709 }; + if let Some(m) = sources.iter().find_map(|s| Matrix::from_code(s.matrix)) { + c.matrix = m; + } + if let Some(t) = sources.iter().find_map(|s| Transfer::from_code(s.transfer)) { + c.transfer = t; + } + if let Some(p) = sources.iter().find_map(|s| primaries_from_code(s.primaries)) { + c.primaries = p; + } + if sources.iter().find_map(|s| s.full_range) == Some(true) { + c.range = Range::Full; + } + c +} diff --git a/crates/codecs/src/stream_color_tests.rs b/crates/codecs/src/stream_color_tests.rs new file mode 100644 index 00000000..1c6526fe --- /dev/null +++ b/crates/codecs/src/stream_color_tests.rs @@ -0,0 +1,340 @@ +//! Colour from the bitstream when the container has none (`crate::stream_color`): ffmpeg writes MP4 +//! without a `colr` box by default, and an HEVC PQ file imported as Rec. 709 SDR, a full-range +//! H.264 file as limited range, and a BT.601 SD file with BT.709 primaries. The parameter sets +//! are built here bit by bit; no media files. + +use std::io::Cursor; +use std::sync::Arc; + +use filmcraft_bitstream::{BitWriter, escape_rbsp}; +use filmcraft_color::{ColorInfo, ColorSpace, Matrix, Primaries, Range, Transfer}; +use filmcraft_isobmff::{Av1Config, AvcConfig, Brand, CodecConfig, HevcConfig, HevcNalArray, Mp4Writer, SampleEntry, TrackConfig, WriteSample, WriterOptions}; +use filmcraft_matroska::{MkvWriter, MuxOptions, TrackKind, TrackSpec}; +use filmcraft_media::MediaSource; + +use crate::stream_color::{ColorCodes, from_codec_config, resolve}; + +/// The video signal description of a VUI: (colour_primaries, transfer_characteristics, +/// matrix_coefficients) when `colour_description_present_flag` is set, and the range flag. +#[derive(Clone, Copy)] +struct Signal { + codes: Option<(u8, u8, u8)>, + full_range: bool, +} + +const PQ_2020: Signal = Signal { codes: Some((9, 16, 9)), full_range: false }; +const SMPTE_170M: Signal = Signal { codes: Some((6, 6, 6)), full_range: false }; +const FULL_ONLY: Signal = Signal { codes: None, full_range: true }; + +fn signal(w: &mut BitWriter, s: Signal) { + w.write_bit(true); // video_signal_type_present_flag + w.write_bits(5, 3); // video_format: unspecified + w.write_bit(s.full_range); + w.write_bit(s.codes.is_some()); // colour_description_present_flag + if let Some((p, t, m)) = s.codes { + w.write_bits(p.into(), 8); + w.write_bits(t.into(), 8); + w.write_bits(m.into(), 8); + } +} + +/// A Baseline H.264 SPS NAL unit (ITU-T H.264 §7.3.2.1.1, VUI per Annex E.1.1) of +/// `mbs_w`×`mbs_h` macroblocks, with a VUI carrying `vui` (no VUI when `None`). +fn h264_sps(mbs_w: u32, mbs_h: u32, vui: Option) -> Vec { + let mut w = BitWriter::new(); + w.write_bits(66, 8); // profile_idc: Baseline + w.write_bits(0, 8); // constraint flags + w.write_bits(30, 8); // level_idc + w.write_ue(0); // seq_parameter_set_id + w.write_ue(0); // log2_max_frame_num_minus4 + w.write_ue(2); // pic_order_cnt_type + w.write_ue(1); // max_num_ref_frames + w.write_bit(false); // gaps_in_frame_num_value_allowed_flag + w.write_ue(mbs_w - 1); + w.write_ue(mbs_h - 1); + w.write_bits(0b110, 3); // frame_mbs_only, direct_8x8_inference, no cropping + w.write_bit(vui.is_some()); // vui_parameters_present_flag + if let Some(s) = vui { + w.write_bit(false); // aspect_ratio_info_present_flag + w.write_bit(false); // overscan_info_present_flag + signal(&mut w, s); + w.write_bit(false); // chroma_loc_info_present_flag + w.write_bit(false); // timing_info_present_flag + w.write_bit(false); // nal_hrd_parameters_present_flag + w.write_bit(false); // vcl_hrd_parameters_present_flag + w.write_bit(false); // pic_struct_present_flag + w.write_bit(false); // bitstream_restriction_flag + } + w.rbsp_trailing(); + let mut nal = vec![0x67]; + nal.extend(escape_rbsp(&w.finish())); + nal +} + +/// A Main 10 HEVC SPS NAL unit (ITU-T H.265 §7.3.2.2, VUI per Annex E.2.1) of `width`×`height` +/// (multiples of 8), with a VUI carrying `vui` (no VUI when `None`). +fn hevc_sps(width: u32, height: u32, vui: Option) -> Vec { + let mut w = BitWriter::new(); + w.write_bits(0, 4); // sps_video_parameter_set_id + w.write_bits(0, 3); // sps_max_sub_layers_minus1 + w.write_bit(true); // sps_temporal_id_nesting_flag + // profile_tier_level: Main 10, level 4.1 + w.write_bits(0, 2); + w.write_bit(false); + w.write_bits(2, 5); + w.write_bits(0x2000_0000, 32); + w.write_bits(0b1001, 4); + w.write_bits(0, 32); + w.write_bits(0, 12); + w.write_bits(123, 8); + w.write_ue(0); // sps_seq_parameter_set_id + w.write_ue(1); // chroma_format_idc 4:2:0 + w.write_ue(width); + w.write_ue(height); + w.write_bit(false); // conformance_window_flag + w.write_ue(2); // bit_depth_luma_minus8 + w.write_ue(2); // bit_depth_chroma_minus8 + w.write_ue(4); // log2_max_pic_order_cnt_lsb_minus4 + w.write_bit(true); // sps_sub_layer_ordering_info_present_flag + w.write_ue(4); // sps_max_dec_pic_buffering_minus1 + w.write_ue(0); // sps_max_num_reorder_pics + w.write_ue(0); // sps_max_latency_increase_plus1 + w.write_ue(0); // log2_min_luma_coding_block_size_minus3 (8) + w.write_ue(1); // log2_diff_max_min_luma_coding_block_size (CTB 16) + w.write_ue(0); // log2_min_luma_transform_block_size_minus2 (4) + w.write_ue(2); // log2_diff_max_min_luma_transform_block_size (16) + w.write_ue(0); // max_transform_hierarchy_depth_inter + w.write_ue(0); // max_transform_hierarchy_depth_intra + w.write_bit(false); // scaling_list_enabled_flag + w.write_bit(false); // amp_enabled_flag + w.write_bit(false); // sample_adaptive_offset_enabled_flag + w.write_bit(false); // pcm_enabled_flag + w.write_ue(0); // num_short_term_ref_pic_sets + w.write_bit(false); // long_term_ref_pics_present_flag + w.write_bit(false); // sps_temporal_mvp_enabled_flag + w.write_bit(false); // strong_intra_smoothing_enabled_flag + w.write_bit(vui.is_some()); // vui_parameters_present_flag + if let Some(s) = vui { + w.write_bit(false); // aspect_ratio_info_present_flag + w.write_bit(false); // overscan_info_present_flag + signal(&mut w, s); + w.write_bit(false); // chroma_loc_info_present_flag + w.write_bit(false); // neutral_chroma_indication_flag + w.write_bit(false); // field_seq_flag + w.write_bit(false); // frame_field_info_present_flag + w.write_bit(false); // default_display_window_flag + w.write_bit(false); // vui_timing_info_present_flag + w.write_bit(false); // bitstream_restriction_flag + } + w.write_bit(false); // sps_extension_present_flag + w.rbsp_trailing(); + let mut nal = vec![33 << 1, 1]; + nal.extend(escape_rbsp(&w.finish())); + nal +} + +/// An AV1 sequence header OBU (AV1 bitstream spec §5.5; reduced still-picture header, profile 0, +/// 10-bit 4:2:0) of `width`×`height` with `color_config` signalling `codes` and `full_range`. +fn av1_sequence_header(width: u32, height: u32, codes: Option<(u8, u8, u8)>, full_range: bool) -> Vec { + let mut w = BitWriter::new(); + w.write_bits(0, 3); // seq_profile + w.write_bit(true); // still_picture + w.write_bit(true); // reduced_still_picture_header + w.write_bits(0, 5); // seq_level_idx[0] + w.write_bits(15, 4); // frame_width_bits_minus_1 + w.write_bits(15, 4); // frame_height_bits_minus_1 + w.write_bits(width - 1, 16); + w.write_bits(height - 1, 16); + w.write_bits(0, 3); // use_128x128_superblock, enable_filter_intra, enable_intra_edge_filter + w.write_bits(0, 3); // enable_superres, enable_cdef, enable_restoration + w.write_bit(true); // high_bitdepth + w.write_bit(false); // mono_chrome + w.write_bit(codes.is_some()); // color_description_present_flag + if let Some((p, t, m)) = codes { + w.write_bits(p.into(), 8); + w.write_bits(t.into(), 8); + w.write_bits(m.into(), 8); + } + w.write_bit(full_range); // color_range + w.write_bits(0, 2); // chroma_sample_position + w.write_bit(false); // separate_uv_delta_q + w.write_bit(false); // film_grain_params_present + w.rbsp_trailing(); + let payload = w.finish(); + // OBU header: type 1 (OBU_SEQUENCE_HEADER), obu_has_size_field; one-byte leb128 size + let mut obu = vec![(1 << 3) | 2, u8::try_from(payload.len()).expect("short payload")]; + obu.extend(payload); + obu +} + +fn avc(sps: Vec) -> CodecConfig { + CodecConfig::Avc(AvcConfig::new(vec![sps], vec![vec![0x68, 0xCE, 0x38, 0x80]], 4)) +} + +fn hevc(sps: Vec) -> CodecConfig { + CodecConfig::Hevc(HevcConfig { + general_profile_idc: 2, + chroma_format_idc: 1, + bit_depth_luma: 10, + bit_depth_chroma: 10, + length_size: 4, + arrays: vec![HevcNalArray { completeness: true, nal_type: 33, nalus: vec![sps] }], + ..Default::default() + }) +} + +fn av1(obus: Vec) -> CodecConfig { + CodecConfig::Av1(Av1Config { high_bitdepth: true, chroma_subsampling_x: true, chroma_subsampling_y: true, config_obus: obus, ..Default::default() }) +} + +/// A one-sample MP4 with `codec` (the sample is never decoded) and, when given, a `colr` box. +fn mp4(codec: CodecConfig, w: u16, h: u16, colr: Option) -> Arc<[u8]> { + let mut entry = match codec { + CodecConfig::Avc(c) => SampleEntry::avc(c, w, h), + CodecConfig::Hevc(c) => SampleEntry::hevc(c, w, h), + other => SampleEntry::video(filmcraft_isobmff::FourCc(*b"av01"), other, w, h), + }; + if let Some(v) = entry.video.as_mut() { + v.color = colr; + } + let mut mux = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4)).expect("writer"); + let t = mux.add_track(TrackConfig::new(entry, 30)).expect("track"); + mux.write_sample(t, WriteSample { data: &[0, 0, 0, 1, 0x65], duration: 1, composition_offset: 0, is_sync: true }).expect("sample"); + mux.finish().expect("finish").into_inner().into() +} + +fn mp4_color(codec: CodecConfig, w: u16, h: u16, colr: Option) -> ColorInfo { + let src = crate::Mp4Source::open("clip.mp4", mp4(codec, w, h, colr)).expect("open"); + src.info().video.as_ref().expect("video").color +} + +#[test] +fn hevc_pq_without_colr_is_rec2100_pq() { + let c = mp4_color(hevc(hevc_sps(1920, 1080, Some(PQ_2020))), 1920, 1080, None); + assert_eq!(c, ColorInfo { matrix: Matrix::Bt2020Ncl, transfer: Transfer::Pq, primaries: Primaries::Bt2020, range: Range::Limited }); + assert_eq!(ColorSpace::from_info(&c), ColorSpace::Rec2100Pq); + assert!(ColorSpace::from_info(&c).is_hdr()); +} + +#[test] +fn full_range_h264_without_colr_is_full_range() { + // video_full_range_flag with no colour description: full range, the rest by size + let c = mp4_color(avc(h264_sps(80, 45, Some(FULL_ONLY))), 1280, 720, None); + assert_eq!(c, ColorInfo { range: Range::Full, ..ColorInfo::REC709 }); +} + +#[test] +fn bt601_sd_h264_without_colr_has_bt601_primaries() { + let c = mp4_color(avc(h264_sps(45, 30, Some(SMPTE_170M))), 720, 480, None); + assert_eq!(c, ColorInfo { matrix: Matrix::Bt601, transfer: Transfer::Bt709, primaries: Primaries::Bt601_525, range: Range::Limited }); + // PAL (BT.470 BG primaries, BT.601 matrix) + let pal = Signal { codes: Some((5, 6, 5)), full_range: false }; + let c = mp4_color(avc(h264_sps(45, 36, Some(pal))), 720, 576, None); + assert_eq!((c.primaries, c.matrix), (Primaries::Bt601_625, Matrix::Bt601)); +} + +#[test] +fn unspecified_or_missing_vui_keeps_the_size_defaults() { + let unspecified = Signal { codes: Some((2, 2, 2)), full_range: false }; + for vui in [None, Some(unspecified)] { + assert_eq!(mp4_color(avc(h264_sps(80, 45, vui)), 1280, 720, None), ColorInfo::REC709); + assert_eq!(mp4_color(hevc(hevc_sps(1920, 1080, vui)), 1920, 1080, None), ColorInfo::REC709); + // SD: the BT.601 matrix, as before + assert_eq!(mp4_color(avc(h264_sps(45, 30, vui)), 720, 480, None), ColorInfo { matrix: Matrix::Bt601, ..ColorInfo::REC709 }); + } +} + +#[test] +fn colr_wins_over_the_bitstream() { + use filmcraft_isobmff::ColorInfo::{Nclc, Nclx}; + let sps = || hevc(hevc_sps(1920, 1080, Some(PQ_2020))); + // a BT.709 nclx box beats a PQ VUI, and is what the decoder's frames are tagged with + let colr = Nclx { primaries: 1, transfer: 1, matrix: 1, full_range: false }; + assert_eq!(mp4_color(sps(), 1920, 1080, Some(colr.clone())), ColorInfo::REC709); + // HLG in colr, PQ in the VUI: HLG + let hlg = Nclx { primaries: 9, transfer: 18, matrix: 9, full_range: false }; + assert_eq!(mp4_color(sps(), 1920, 1080, Some(hlg)).transfer, Transfer::Hlg); + // limited range in colr beats a full-range VUI + assert_eq!(mp4_color(avc(h264_sps(80, 45, Some(FULL_ONLY))), 1280, 720, Some(colr)).range, Range::Limited); + // what colr leaves unspecified comes from the VUI + let partial = Nclx { primaries: 2, transfer: 16, matrix: 2, full_range: false }; + assert_eq!(mp4_color(sps(), 1920, 1080, Some(partial)), mp4_color(sps(), 1920, 1080, None)); + // QuickTime nclc has no range flag: the VUI's range stands + let nclc = Nclc { primaries: 1, transfer: 1, matrix: 1 }; + assert_eq!(mp4_color(avc(h264_sps(80, 45, Some(FULL_ONLY))), 1280, 720, Some(nclc)).range, Range::Full); + // an ICC profile has no code points: the bitstream decides + let icc = filmcraft_isobmff::ColorInfo::Icc { kind: filmcraft_isobmff::FourCc(*b"prof"), profile: vec![0; 8] }; + assert_eq!(mp4_color(sps(), 1920, 1080, Some(icc)).transfer, Transfer::Pq); +} + +#[test] +fn av1_sequence_header_colour() { + let obu = av1_sequence_header(1920, 1080, Some((9, 16, 9)), false); + assert_eq!(from_codec_config(&av1(obu.clone())), Some(ColorCodes { primaries: 9, transfer: 16, matrix: 9, full_range: Some(false) })); + let c = mp4_color(av1(obu), 1920, 1080, None); + assert_eq!(ColorSpace::from_info(&c), ColorSpace::Rec2100Pq); + // no colour description: unspecified code points, the range flag still counts + let obu = av1_sequence_header(640, 360, None, true); + assert_eq!(from_codec_config(&av1(obu)), Some(ColorCodes { primaries: 2, transfer: 2, matrix: 2, full_range: Some(true) })); +} + +#[test] +fn matroska_without_colour_uses_the_bitstream() { + let CodecConfig::Hevc(hvcc) = hevc(hevc_sps(1920, 1080, Some(PQ_2020))) else { panic!("an hvcC") }; + let mut spec = TrackSpec::new(TrackKind::Video, "V_MPEGH/ISO/HEVC"); + spec.codec_private = hvcc.to_bytes(); + spec.video_size = Some((1920, 1080)); + spec.default_duration_ns = Some(33_333_333); + let mut w = MkvWriter::new(Cursor::new(Vec::new()), vec![spec], MuxOptions::default()).expect("writer"); + w.write_frame(0, 0, true, &[0, 0, 0, 1, 0x26], None).expect("frame"); + let b: Arc<[u8]> = w.finish().expect("finish").into_inner().into(); + let src = crate::MkvSource::open("clip.mkv", b).expect("open"); + let c = src.info().video.as_ref().expect("video").color; + assert_eq!(ColorSpace::from_info(&c), ColorSpace::Rec2100Pq); + assert_eq!((c.primaries, c.matrix), (Primaries::Bt2020, Matrix::Bt2020Ncl)); +} + +#[test] +fn resolve_takes_each_field_from_the_first_source_that_knows_it() { + let colr = ColorCodes { primaries: 1, transfer: 2, matrix: 0, full_range: None }; + let vui = ColorCodes { primaries: 9, transfer: 18, matrix: 9, full_range: Some(true) }; + // matrix 0 (identity) is not one we know: the VUI's BT.2020 is used + let c = resolve(1920, 1080, &[colr, vui]); + assert_eq!(c, ColorInfo { matrix: Matrix::Bt2020Ncl, transfer: Transfer::Hlg, primaries: Primaries::Bt709, range: Range::Full }); + assert_eq!(resolve(720, 480, &[]), ColorInfo { matrix: Matrix::Bt601, ..ColorInfo::REC709 }); + // code points that don't fit a byte are unspecified, not truncated (0x109 is not 9) + assert_eq!(ColorCodes::from_wide(0x109, 0x110, 0x109, None), ColorCodes { primaries: 2, transfer: 2, matrix: 2, full_range: None }); +} + +/// Every truncation and many bit flips of each configuration: never a panic, and the result is +/// either some colour or none (the size defaults). +#[test] +fn hostile_parameter_sets_never_panic() { + let configs: Vec<(Vec, fn(Vec) -> CodecConfig)> = vec![ + (h264_sps(45, 30, Some(SMPTE_170M)), avc), + (hevc_sps(1920, 1080, Some(PQ_2020)), hevc), + (av1_sequence_header(1920, 1080, Some((9, 16, 9)), false), av1), + ]; + for (good, make) in configs { + assert!(from_codec_config(&make(good.clone())).is_some()); + let mut variants: Vec> = (0..good.len()).map(|n| good[..n].to_vec()).collect(); + for bit in 0..good.len() * 8 { + let mut b = good.clone(); + b[bit / 8] ^= 0x80 >> (bit % 8); + variants.push(b); + } + variants.push(vec![0xff; 64]); + variants.push(Vec::new()); + for v in variants { + let r = std::panic::catch_unwind(|| from_codec_config(&make(v.clone()))); + assert!(r.is_ok(), "panicked on {v:02x?}"); + } + } + // configurations with no parameter sets at all + assert_eq!(from_codec_config(&CodecConfig::Avc(AvcConfig::default())), None); + assert_eq!(from_codec_config(&CodecConfig::Hevc(HevcConfig::default())), None); + assert_eq!(from_codec_config(&CodecConfig::Av1(Av1Config::default())), None); + // a garbage SPS imports with the size defaults + assert_eq!(mp4_color(avc(vec![0x67, 0xff, 0xff]), 1280, 720, None), ColorInfo::REC709); + assert_eq!(mp4_color(hevc(vec![33 << 1, 1, 0xff]), 1920, 1080, None), ColorInfo::REC709); +} diff --git a/crates/codecs/src/tests.rs b/crates/codecs/src/tests.rs index 819a41f9..be3e30a4 100644 --- a/crates/codecs/src/tests.rs +++ b/crates/codecs/src/tests.rs @@ -615,3 +615,32 @@ fn apv_mp4_and_raw_bitstream_decode() { let decoded_mp4 = mp4_src.video_frame(FrameRequest::full(Tick::ZERO)).expect("decode mp4 frame"); assert_eq!(decoded_mp4.to_rgba8(), decoded_raw.to_rgba8()); } + +/// ffmpeg writes MP4 without a `colr` box unless asked: the colour comes from the x265 / x264 +/// SPS VUI, and matches the same stream remuxed with `colr`. +#[test] +fn mp4_colour_comes_from_the_sps_without_colr() { + use filmcraft_color::{ColorSpace, Primaries, Range}; + let src = |name: &str, args: &[&str]| fixture(name, args).map(|b| crate::open_bytes(name, b).expect("open")); + let color = |s: &filmcraft_media::SharedSource| s.info().video.as_ref().expect("video").color; + let lavfi = |size: &'static str| ["-f", "lavfi", "-i", size, "-t", "0.2"]; + let x265 = "colorprim=bt2020:transfer=smpte2084:colormatrix=bt2020nc:log-level=error"; + let mut pq = lavfi("testsrc2=s=640x360:r=25").to_vec(); + pq.extend(["-c:v", "libx265", "-tag:v", "hvc1", "-pix_fmt", "yuv420p10le", "-x265-params", x265]); + let Some(plain) = src("hevc_pq_no_colr.mp4", &pq) else { return }; + assert_eq!(ColorSpace::from_info(&color(&plain)), ColorSpace::Rec2100Pq); + let colr_path = fixture_path("hevc_pq_no_colr.mp4", &pq).expect("fixture"); + if let Some(with_colr) = src("hevc_pq_colr.mp4", &["-i", colr_path.to_str().expect("path"), "-c", "copy", "-movflags", "+write_colr"]) { + assert_eq!(color(&with_colr), color(&plain)); + } + let mut full = lavfi("testsrc2=s=640x360:r=25").to_vec(); + full.extend(["-c:v", "libx264", "-pix_fmt", "yuvj420p", "-color_range", "pc"]); + if let Some(s) = src("h264_full_no_colr.mp4", &full) { + assert_eq!(color(&s).range, Range::Full); + } + let mut sd = lavfi("testsrc2=s=720x480:r=30000/1001").to_vec(); + sd.extend(["-c:v", "libx264", "-pix_fmt", "yuv420p", "-x264-params", "colorprim=smpte170m:transfer=smpte170m:colormatrix=smpte170m"]); + if let Some(s) = src("h264_sd601_no_colr.mp4", &sd) { + assert_eq!(color(&s).primaries, Primaries::Bt601_525); + } +} From 67ed539fbee717e473f1a4e0f7a0f14384a2e14c Mon Sep 17 00:00:00 2001 From: MrCloudy2 Date: Fri, 9 Oct 2026 18:12:58 +0200 Subject: [PATCH 07/64] #219: translate the edit point menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The interface became translatable since this branch started. The edit point menu now draws its labels (and automation labels) in the interface language like the clip menu: the trim types through i18n::t, Apply Default Transitions and Join Through Edits with tl! (both already in the Spanish catalog). Spanish entries for the five trim types use the catalog's existing vocabulary (recorte de rizo, edición de rodillo, recortar), and the trim types are listed with the clip menu's labels in the test that requires Spanish for every such name. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/es.tsv | 5 +++++ crates/ui-egui/src/i18n/mod.rs | 1 + crates/ui-egui/src/panels/timeline.rs | 13 ++++++++----- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index a39dc82e..f5ee9c88 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1891,6 +1891,11 @@ Japanese Japonés JetBrains Mono JetBrains Mono Join Through Edits Unir ediciones pasantes + Ripple Trim In Recorte de rizo de entrada + Ripple Trim Out Recorte de rizo de salida + Roll Edit Edición de rodillo + Trim In Recortar entrada + Trim Out Recortar salida Kaleidoscope Caleidoscopio Keep a recovery copy of unsaved changes Mantener una copia de recuperación de los cambios sin guardar Key Color Color clave diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index c04416c6..2000c1a1 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -474,6 +474,7 @@ mod tests { } } crate::panels::timeline::CLIP_MENU.iter().flat_map(|g| g.iter()).for_each(|(l, _)| push(&mut out, l)); + crate::panels::timeline::EDIT_POINT_TYPES.iter().for_each(|(l, ..)| push(&mut out, l)); crate::panels::project::NEW_ITEMS.iter().for_each(|(l, _)| push(&mut out, l)); // section headers keyed by their English name (collapsed state), translated when drawn let sections = [ diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 67f125c2..8ba37b18 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1599,7 +1599,7 @@ impl EditPointTarget { /// The edit point menu's trim types: label, id (`timeline.editPointMenu.`), trim kind, and /// which side of the cut it selects (the incoming clip's In or the outgoing clip's Out). -const EDIT_POINT_TYPES: &[(&str, &str, &str, filmcraft_edit::Edge)] = &[ +pub(crate) const EDIT_POINT_TYPES: &[(&str, &str, &str, filmcraft_edit::Edge)] = &[ ("Ripple Trim In", "rippleIn", "ripple", filmcraft_edit::Edge::In), ("Ripple Trim Out", "rippleOut", "ripple", filmcraft_edit::Edge::Out), ("Roll Edit", "roll", "roll", filmcraft_edit::Edge::Out), @@ -1619,6 +1619,7 @@ fn edit_point_menu(app: &mut FilmcraftApp, ctx: &egui::Context, ui: &mut egui::U let clip = clip.filter(|_| kind != "roll" || (target.left.is_some() && target.right.is_some())); let out = edge == filmcraft_edit::Edge::Out; let current = selected.is_some_and(|ep| Some(ep.clip) == clip && ep.out == out && serde_json::to_value(ep.kind).ok() == Some(json!(kind))); + let label = crate::i18n::t(label); let text = if current { format!("✓ {label}") } else { label.to_string() }; let r = ui.add_enabled(clip.is_some(), egui::Button::new(text)); app.auto.add(&format!("timeline.editPointMenu.{id}"), r.rect, label); @@ -1633,8 +1634,9 @@ fn edit_point_menu(app: &mut FilmcraftApp, ctx: &egui::Context, ui: &mut egui::U } } ui.separator(); - let r = ui.add_enabled(app.session.is_enabled("trim.applyDefaultTransition"), egui::Button::new("Apply Default Transitions")); - app.auto.add("timeline.editPointMenu.applyDefaultTransitions", r.rect, "Apply Default Transitions"); + let label = tl!("Apply Default Transitions"); + let r = ui.add_enabled(app.session.is_enabled("trim.applyDefaultTransition"), egui::Button::new(label)); + app.auto.add("timeline.editPointMenu.applyDefaultTransitions", r.rect, label); if r.clicked() { if let Err(e) = crate::menus::invoke(app, ctx, "trim.applyDefaultTransition", json!({})) { app.ui.status = e; @@ -1643,8 +1645,9 @@ fn edit_point_menu(app: &mut FilmcraftApp, ctx: &egui::Context, ui: &mut egui::U } // only this cut (and its linked partners), and only when it is a through edit let through = filmcraft_edit::through::through_edits(seq).iter().any(|e| Some(e.left) == target.left && Some(e.right) == target.right); - let r = ui.add_enabled(through, egui::Button::new("Join Through Edits")); - app.auto.add("timeline.editPointMenu.joinThroughEdits", r.rect, "Join Through Edits"); + let label = tl!("Join Through Edits"); + let r = ui.add_enabled(through, egui::Button::new(label)); + app.auto.add("timeline.editPointMenu.joinThroughEdits", r.rect, label); if r.clicked() && let (Some(left), Some(right)) = (target.left, target.right) { From cf614c1fa3a722fc169d1d958d6f462dfc64529b Mon Sep 17 00:00:00 2001 From: Ikki Nishihara Date: Sat, 10 Oct 2026 02:14:19 +0900 Subject: [PATCH 08/64] i18n: Japanese for the whole interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ja.tsv now has the same 3,466 entries as es.tsv (menus, panels, dialogs, the Settings pages, the Effects registry, Keyboard Shortcuts, tooltips and status messages) instead of 89 menu-only entries. The 46 old rows that came from PhotoCraft/PDFCraft and match no FilmCraft string are dropped. Five rows that were wrong for a video editor are fixed: Horizontal/Vertical are 水平/垂直 (every use is geometry, not writing direction), Orientation is 向き, Type is 種類 and Effect is エフェクト. The other 38 rows that were already in use are unchanged. ROADMAP.md and crates/ui-egui/README.md now list Japanese next to Spanish as covering the whole interface. Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 2 +- crates/ui-egui/README.md | 4 +- crates/ui-egui/src/i18n/ja.tsv | 3557 +++++++++++++++++++++++++++++++- 3 files changed, 3476 insertions(+), 87 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0f0708be..50b95eea 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -211,4 +211,4 @@ Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining ### Interface localization -Edit > Language switches English/Japanese/Spanish/Portuguese (Brazilian) and persists across restarts through `general.interfaceLanguage` (also available in Settings > General). Until a language is chosen, System Language (the default) follows the operating system's or browser's preferred languages and falls back to English. Commands `app.language.english`, `app.language.japanese`, `app.language.spanish` and `app.language.portuguese` are reachable through the control channel. Translations live in one catalog per language (`crates/ui-egui/src/i18n/.tsv`); UI code wraps its strings in `tl!("…")` / `tlf!("…{name}", name)`, and names that come from registries (effects and their parameters, settings, commands, panels, workspaces, presets) are translated where they are drawn. **Spanish covers the whole interface**: menus, panels, dialogs, the Settings pages, the Effects/Effect Controls registry, the Keyboard Shortcuts dialog and status messages; tests fail when a `tl!` literal, a menu label or a registry name has no Spanish entry. Searches match the displayed labels as well as English sources. Japanese and Brazilian Portuguese cover the core menus; untranslated labels use English. Engine error messages, the control channel, the CLI and MCP stay in English. Japanese text uses the Japanese fonts from [craft-fonts](https://github.com/storytold/craft-fonts) when FilmCraft is built with them (`CRAFT_FONTS_DIR`; all official releases, including the web build), otherwise a Japanese font already installed on the system; with neither, switching to Japanese is refused with a message. Existing vertical text support is preserved. +Edit > Language switches English/Japanese/Spanish/Portuguese (Brazilian) and persists across restarts through `general.interfaceLanguage` (also available in Settings > General). Until a language is chosen, System Language (the default) follows the operating system's or browser's preferred languages and falls back to English. Commands `app.language.english`, `app.language.japanese`, `app.language.spanish` and `app.language.portuguese` are reachable through the control channel. Translations live in one catalog per language (`crates/ui-egui/src/i18n/.tsv`); UI code wraps its strings in `tl!("…")` / `tlf!("…{name}", name)`, and names that come from registries (effects and their parameters, settings, commands, panels, workspaces, presets) are translated where they are drawn. **Spanish and Japanese cover the whole interface**: menus, panels, dialogs, the Settings pages, the Effects/Effect Controls registry, the Keyboard Shortcuts dialog and status messages; tests fail when a `tl!` literal, a menu label or a registry name has no Spanish entry (`ja.tsv` has the same entries, but the tests do not require them yet). Searches match the displayed labels as well as English sources. Brazilian Portuguese covers the core menus; untranslated labels use English. Engine error messages, the control channel, the CLI and MCP stay in English. Japanese text uses the Japanese fonts from [craft-fonts](https://github.com/storytold/craft-fonts) when FilmCraft is built with them (`CRAFT_FONTS_DIR`; all official releases, including the web build), otherwise a Japanese font already installed on the system; with neither, switching to Japanese is refused with a message. Existing vertical text support is preserved. diff --git a/crates/ui-egui/README.md b/crates/ui-egui/README.md index 21ff94e6..2bb3394a 100644 --- a/crates/ui-egui/README.md +++ b/crates/ui-egui/README.md @@ -19,10 +19,10 @@ fills translated templates, and `i18n::t` translates names from registries. Plac (including user filenames containing braces) are inserted literally. Catalog translations are original work using ordinary language, without proprietary localisation resources. -Spanish covers menus, panels, dialogs, settings and registry labels. Searches accept both the +Spanish and Japanese cover menus, panels, dialogs, settings and registry labels. Searches accept both the translated label and its English source, including Unicode capitals. Project content, command ids and preference values retain their original values. Engine errors, CLI and MCP messages remain -English; Japanese currently covers core menus and falls back to English elsewhere. +English; Brazilian Portuguese currently covers core menus and falls back to English elsewhere. Verification: `cargo test -p filmcraft-ui-egui` checks catalog syntax, duplicate keys, placeholders, literal/menu/registry coverage and UI behaviour; `cargo xtask ci` runs the workspace gates. Visual diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 12a3ea2a..14a5c02d 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1,95 +1,3484 @@ -# Japanese (ja) catalog. Same format as `es.tsv`: context source translation. +# Japanese (ja) catalog. Same format as `es.tsv`: context source translation. It covers the +# whole interface (the same entries as `es.tsv`); a string without an entry is shown in English. # -# Clean-room: every entry was written from the plain meaning of the English label using ordinary -# Japanese computing vocabulary. No proprietary localisation resource, string table or product UI -# was consulted or copied. This catalog covers the menus only; anything else is shown in English. +# Clean-room: every entry was written from the plain meaning of the English text using ordinary +# Japanese computing and video-editing vocabulary (クリップ, シーケンス, トラック, 再生ヘッド, ぼかし ...). No +# proprietary localisation resource, string table or product UI was consulted or copied; add entries +# the same way. Product and technology names (FilmCraft, ArtCraft, Lumetri, ProRes, LUFS ...) keep +# their spelling. +# +# House style, so new entries read like the rest: +# - Menu commands and buttons are short noun phrases or "〜を + verb" with no する/します ending +# (別名で保存…, 編集点を追加). Tooltips and messages are です・ます sentences ending in 。 +# - One half-width space between Japanese text and an adjacent Latin word, number or {placeholder} +# (Lumetri カラー, {n} 個). Half-width ":" and "( )", full-width "?". Always "…", never "...". +# - The long vowel mark is kept (フォルダー, モニター). Terms: 既定 (not デフォルト), リセット, エフェクト +# (not 効果), ぼかし, 音量, ビデオ, 再生ヘッド, イン点/アウト点. +# - "Horizontal"/"Vertical" are 水平/垂直 (guides, blur, axes); vertical text is "Vertical Text". - Clip クリップ - Sequence シーケンス - Markers マーカー + About FilmCraft FilmCraft について + Add Audio Submix Track オーディオサブミックストラックを追加 + Add Caption at Playhead 再生ヘッドの位置にキャプションを追加 + Add Chapter Marker… チャプターマーカーを追加… + Add Edit 編集点を追加 + Add Edit to All Tracks すべてのトラックに編集点を追加 + Add Flash Cue Marker… フラッシュキューマーカーを追加… + Add Frame Hold フレームホールドを追加 + Add Guide… ガイドを追加… + Add Marker マーカーを追加 + Add New Caption Track… 新規キャプショントラックを追加… + Add Range Marker 範囲マーカーを追加 + Add Range Marker to In and Out イン点からアウト点までの範囲マーカーを追加 + Add Tracks… トラックを追加… + Adjustment Layer… 調整レイヤー… + Align to Selection 選択範囲に整列 + Align to Video Frame ビデオフレームに整列 + Align to Video Frame as Group グループとしてビデオフレームに整列 + All Panels すべてのパネル + Alpha アルファ + Appearance 外観 + Appearance… 外観… + Apply Audio Transition オーディオトランジションを適用 + Apply Default Transitions to Selection 選択範囲に既定のトランジションを適用 + Apply Video Transition ビデオトランジションを適用 + Arrange 重ね順 + ArtCraft Website ArtCraft の Web サイト + Assembly アセンブリ + Attach Proxies… プロキシを関連付け… + Audio Channels… オーディオチャンネル… + Audio Clip Mixer オーディオクリップミキサー + Audio Gain… オーディオゲイン… + Audio Hardware… オーディオハードウェア… + Audio In オーディオのイン点 + Audio Meters オーディオメーター + Audio Options オーディオオプション + Audio Out オーディオのアウト点 + Audio Track Mixer オーディオトラックミキサー + Audio Waveform オーディオ波形 + Auto Save… 自動保存… + Automate to Sequence… シーケンスに自動配置… + Bars and Tone… カラーバーとトーン… + Bin ビン + Bin From Selection 選択項目からビンを作成 + Black Video… ブラックビデオ… + Blue ブルー + Bottom 下 + Breakout to Mono モノラルに分割 + Bring Forward 前面へ + Bring to Front 最前面へ + Brown ブラウン + Captions キャプション + Captions and Graphics キャプションとグラフィックス + Captions… キャプション… + Caribbean カリビアン + Center Horizontally 水平方向の中央に整列 + Center Vertically 垂直方向の中央に整列 + Cerulean セルリアン + Clear 消去 + Clear Guides ガイドをクリア + Clear In イン点をクリア + Clear In and Out イン点とアウト点をクリア + Clear Markers マーカーをクリア + Clear Out アウト点をクリア + Clear Selected Marker 選択したマーカーをクリア + Close 閉じる + Close All Other Projects 他のプロジェクトをすべて閉じる + Close All Projects すべてのプロジェクトを閉じる + Close Gap ギャップを詰める + Close Project プロジェクトを閉じる + Color Management… カラーマネジメント… + Color Matte… カラーマット… + Comparison View 比較表示 + Composite Video コンポジットビデオ + Consolidate Duplicates 重複を統合 + Copy コピー + Copy Paste Includes Sequence Markers コピーと貼り付けにシーケンスマーカーを含める + Create Captions from Transcript… 文字起こしからキャプションを作成… + Create Multi-Camera Source Sequence… マルチカメラソースシーケンスを作成… + Create Proxies… プロキシを作成… + Cut 切り取り + Darkest 最も暗い + Delete Render Files レンダリングファイルを削除 + Delete Render Files In to Out イン点からアウト点までのレンダリングファイルを削除 + Delete Tracks… トラックを削除… + Delete Transcript 文字起こしを削除 + Demo Footage デモ素材 + Demo Project デモプロジェクト + Deselect All すべての選択を解除 + Detach Proxies プロキシの関連付けを解除 + Display Mode 表示モード + Distribute 均等配置 + Distribute Horizontally 水平方向に均等配置 + Distribute Space Horizontally 水平方向の間隔を均等化 + Distribute Space Vertically 垂直方向の間隔を均等化 + Distribute Vertically 垂直方向に均等配置 + Duplicate 複製 + Dynamic Audio Waveforms 動的なオーディオ波形 + Edit 編集 + Edit Offline… オフラインファイルを編集… + Edit Original オリジナルを編集 + Edit Subclip… サブクリップを編集… + Editing 編集 + Effect Controls エフェクトコントロール + Effects エフェクト + Ellipse 楕円 + Enable 有効 + Enable Remix リミックスを有効にする + Essential Graphics 基本グラフィックス + Essential Sound 基本サウンド + Events イベント + Export 書き出し + Export As Motion Graphics Template… モーショングラフィックステンプレートとして書き出し… + Extract 抽出 + Extract Audio オーディオを抽出 + Field Options… フィールドオプション… + File ファイル + File… ファイル… + Fill frame フレームを埋める + FilmCraft Help… FilmCraft ヘルプ… + FilmCraft on GitHub GitHub の FilmCraft + FilmCraft on getartcraft.com getartcraft.com の FilmCraft + Find Next 次を検索 + Find… 検索… + Fit 合わせる + Fit to frame フレームに合わせる + Flatten フラット化 + Forest フォレスト + Frame Blending フレームブレンド + Frame Hold Options… フレームホールドオプション… + Frame Sampling フレームサンプリング + From Bin ビンから + From Source Monitor ソースモニターから + From Source Monitor, Match Frame ソースモニターから (マッチフレーム) + From file… ファイルから… + Full フル + Generate Audio Waveform オーディオ波形を生成 + Get Media File Properties for メディアファイルのプロパティを取得 + Go to Gap ギャップへ移動 + Go to In イン点へ移動 + Go to Next Caption Segment 次のキャプションセグメントへ移動 + Go to Next Marker 次のマーカーへ移動 + Go to Out アウト点へ移動 + Go to Previous Caption Segment 前のキャプションセグメントへ移動 + Go to Previous Marker 前のマーカーへ移動 + Go to Split スプリットへ移動 Graphics and Titles グラフィックスとタイトル - Settings 環境設定 - Object オブジェクト - Effect 効果 - Settings… 環境設定… - Image 画像 - Layer レイヤー - Type 書式 - Select 選択 - Filter フィルター - Window ウィンドウ - Language 表示言語 - Save As… 別名で保存… - Exit 終了 - New… 新規… - New 新規 - Horizontal 横書き - Vertical 縦書き - Orientation 組み方向 - Layers レイヤー + Graphics… グラフィックス… + Green グリーン + Group グループ + Guide Templates ガイドテンプレート + Help ヘルプ + Hide All Caption Tracks すべてのキャプショントラックを非表示 + High Quality Playback 高画質再生 History 履歴 + Import From 読み込み元 + Import Image Sequence… 連番画像を読み込み… + Import from Media Browser メディアブラウザーから読み込み + Import… 読み込み… + Info 情報 + Ingest Settings… 取り込み設定… + Insert 挿入 + Insert Frame Hold Segment フレームホールドセグメントを挿入 + Install Motion Graphics Template… モーショングラフィックステンプレートをインストール… + Interpret Footage… 素材を解釈… + Join the ArtCraft Discord… ArtCraft の Discord に参加… + Keyboard Shortcuts… キーボードショートカット… + Label ラベル + Labels… ラベル… + Language 表示言語 + Lavender ラベンダー + Learning 学習 + Left 左 + Libraries ライブラリ + Lift リフト + Light ライト + Link リンク + Link Media… メディアをリンク… + Linked Selection リンクされた選択 + Lock Guides ガイドをロック + Lumetri Color Lumetri カラー + Lumetri Scopes Lumetri スコープ + Magnification 倍率 + Make Offline… オフラインに設定… + Make Subclip… サブクリップを作成… + Make Subsequence サブシーケンスを作成 + Manage Guides… ガイドを管理… + Mark Clip クリップをマーク + Mark In イン点をマーク + Mark Out アウト点をマーク + Mark Selection 選択範囲をマーク + Mark Split スプリットをマーク + Markers マーカー + Match Frame マッチフレーム + Media Analysis & Transcription… メディア分析と文字起こし… + Media Browser メディアブラウザー + Media Cache… メディアキャッシュ… + Media… メディア… + Medium 中 + Memory… メモリー… + Merge Clips… クリップを結合… + Metadata メタデータ + Modify 変更 + Motion Graphics Template… モーショングラフィックステンプレート… + Multi-Camera マルチカメラ + Multi-Camera View マルチカメラ表示 + Nest… ネスト… + New 新規 + New Layer 新規レイヤー + New Project Panel 新規プロジェクトパネル + Next in Sequence シーケンス内の次 + Next in Track トラック内の次 + Normalize Mix Track… ミックストラックをノーマライズ… + Offline File… オフラインファイル… + Open Project… プロジェクトを開く… + Optical Flow オプティカルフロー + Overwrite 上書き + Paste 貼り付け + Paste Attributes… 属性を貼り付け… + Paste Insert 挿入して貼り付け + Paused Resolution 一時停止時の解像度 + Playback Resolution 再生時の解像度 + Playback… 再生… + Plugins… プラグイン… + Polygon 多角形 + Preferences 環境設定 + Previous in Sequence シーケンス内の前 + Previous in Track トラック内の前 + Program プログラム + Progress 進行状況 + Project プロジェクト + Project Manager… プロジェクトマネージャー… + Project Settings プロジェクト設定 + Project… プロジェクト… Properties プロパティ - Color カラー - Brush Settings ブラシ設定 + Purple パープル + Reconnect Full Resolution Media… フル解像度メディアに再接続… + Recover Unsaved Changes… 未保存の変更を復元… + Rectangle 長方形 + Red レッド + Redo やり直し + Reference Monitor リファレンスモニター + Remix リミックス + Remix Properties… リミックスのプロパティ… + Remove Attributes… 属性を削除… + Remove Filler Words フィラーワードを削除 + Remove Pauses 無音部分を削除 + Remove Unused 未使用の項目を削除 + Rename… 名前を変更… + Render Audio オーディオをレンダリング + Render Effects In to Out イン点からアウト点までのエフェクトをレンダリング + Render In to Out イン点からアウト点までをレンダリング + Render Selection 選択範囲をレンダリング + Replace Fonts in Projects… プロジェクト内のフォントを置換… + Replace With Clip クリップで置換 + Report an Issue… 問題を報告… + Reset All Parameters すべてのパラメーターをリセット + Reset Duration 長さをリセット + Reset to Saved Layout 保存したレイアウトにリセット + Restore Captions from Source Clip ソースクリップからキャプションを復元 + Reveal Log Files… ログファイルを表示… + Reverse Match Frame 逆マッチフレーム + Revert 保存済みの状態に戻す + Revert Remix リミックスを元に戻す + Review レビュー + Right 右 + Ripple Delete リップル削除 + Ripple Sequence Markers シーケンスマーカーをリップル + Rose ローズ + Safe Margins セーフマージン + Save 保存 + Save All すべて保存 + Save As… 別名で保存… + Save Guides as Template… ガイドをテンプレートとして保存… + Save a Copy… コピーを保存… + Save as Template… テンプレートとして保存… + Scale to Frame Size フレームサイズに合わせてスケール + Scene Edit Detection… シーンの編集点を検出… + Scratch Disks… スクラッチディスク… + Search Bin 検索ビン + Select 選択 + Select All すべて選択 + Select All Matching 一致するクリップをすべて選択 + Select Label Group ラベルグループを選択 + Select Next Graphic 次のグラフィックを選択 + Select Next Layer 次のレイヤーを選択 + Select Previous Graphic 前のグラフィックを選択 + Select Previous Layer 前のレイヤーを選択 + Selection Follows Playhead 選択範囲が再生ヘッドに追従 + Selection as FilmCraft Project… 選択項目を FilmCraft プロジェクトとして書き出し… + Selection… 選択項目… + Send Backward 背面へ + Send to Back 最背面へ + Sequence シーケンス + Sequence From Clip クリップからシーケンスを作成 + Sequence Settings… シーケンス設定… + Sequence… シーケンス… + Show Active Caption Tracks Only アクティブなキャプショントラックのみ表示 + Show All Caption Tracks すべてのキャプショントラックを表示 + Show All Marker Colors すべての色のマーカーを表示 + Show Guides ガイドを表示 + Show Rulers ルーラーを表示 + Show Through Edits スルー編集点を表示 + Simplify Sequence… シーケンスを簡略化… + Snap in Program Monitor プログラムモニターでスナップ + Snap in Timeline タイムラインでスナップ + Source ソース + Source Settings… ソース設定… + Speed/Duration… 速度・長さ… + Synchronize… 同期… + System Compatibility Report… システム互換性レポート… + Tan タン + Teal ティール + Text テキスト + Time Interpolation 時間補間 + Timecode タイムコード + Timecode… タイムコード… + Timeline タイムライン + Timeline… タイムライン… + Toggle Proxies プロキシを切り替え Tools ツール - Options オプション + Top 上 + Transcribe Sequence… シーケンスを文字起こし… + Transcribe… 文字起こし… + Transcript 文字起こし + Transparent Video… 透明ビデオ… + Trim Edit トリム編集 + Trim… トリム… + Undo 取り消し + Ungroup グループ解除 + Universal Counting Leader… ユニバーサルカウントリーダー… + Update Metadata… メタデータを更新… + Upgrade Caption to Graphic キャプションをグラフィックに変換 + Upgrade to Source Graphic ソースグラフィックに変換 + Vertical Text 縦書き + Video In ビデオのイン点 + Video Options ビデオオプション + Video Out ビデオのアウト点 + Video and Audio Waveform Split ビデオとオーディオ波形の分割表示 + View 表示 + Violet バイオレット + Window ウィンドウ + Workspaces ワークスペース + Yellow イエロー Zoom In ズームイン Zoom Out ズームアウト - Fit on Screen 画面に合わせる - Copy コピー - Cut 切り取り - Paste 貼り付け - Select All すべて選択 - Deselect 選択を解除 - Export 書き出し - Export As… 形式を指定して書き出し… - Search… 検索… - Theme テーマ - Menu メニュー - File ファイル - Edit 編集 - Pages ページ - View 表示 - Help ヘルプ - Preferences 環境設定 - Preferences… 環境設定… - Interface language 表示言語 - Open… 開く… - New blank PDF 空白の PDF を作成 - Create PDF from file… ファイルから PDF を作成… - Create PDF from images… 画像から PDF を作成… - Create PDF from clipboard クリップボードから PDF を作成 - Combine files… ファイルを結合… - Save 保存 - Save as… 別名で保存… - Close file ファイルを閉じる - Close all すべて閉じる - Revert 保存済みの状態に戻す - Print… 印刷… - Document properties… 文書のプロパティ… - Undo 取り消し - Redo やり直し - Find… 検索… - Advanced search… 高度な検索… - Copy pages ページをコピー - Cut pages ページを切り取り - Paste pages ページを貼り付け - Fit visible 表示範囲に合わせる - Marquee zoom 範囲指定ズーム - Take a snapshot スナップショットを作成 - Full screen mode 全画面表示 - Read mode 閲覧モード - Switch light / dark theme 明るい/暗いテーマを切り替え - Comments panel コメントパネル - Form fields panel フォームフィールドパネル - Clear form フォームをクリア - Find tools and commands… ツールとコマンドを検索… - Zoom ズーム - Actual size 実際のサイズ - Zoom to page level ページ全体を表示 - Fit to width 幅に合わせる - Display theme 表示テーマ - Side panels サイドパネル + Zoom to Sequence シーケンス全体にズーム + Home ホーム + Import 読み込み + {name} - Edited {name} - 編集済み + Full screen 全画面表示 + Volume 音量 + Master volume: use the Audio Track Mixer マスター音量はオーディオトラックミキサーで調整してください + Search 検索 + Quick Export クイック書き出し + Join the ArtCraft Discord (discord.gg/artcraft) ArtCraft の Discord に参加 (discord.gg/artcraft) + Cancel キャンセル OK OK + Interpret Footage 素材を解釈 + Color Management · {n} item カラーマネジメント · {n} 項目 + Color Management · {n} items カラーマネジメント · {n} 項目 + Media colour space (from file metadata): {detected} メディアのカラースペース (ファイルのメタデータから): {detected} + Override media colour space: メディアのカラースペースを上書き: + Use file metadata ファイルのメタデータを使用 + Sequence Color Management シーケンスのカラーマネジメント + Working Color Space 作業用カラースペース + Wide gamut color (composite in BT.2020) 広色域カラー (BT.2020 で合成) + Auto tone map media (HDR and log into SDR) メディアを自動的にトーンマッピングする (HDR と Log を SDR に) + Audio Gain オーディオゲイン + Set Gain to: ゲインを設定: + Adjust Gain by: ゲインを調整: + Normalize Max Peak to: 最大ピークをノーマライズ: + Normalize All Peaks to: すべてのピークをノーマライズ: + Peak Amplitude: {peak} ピーク振幅: {peak} + Standard 標準 + Adaptive アダプティブ + Mono モノラル + Stereo ステレオ + Add Tracks トラックを追加 + Amount 量 + Placement 配置 + Track type トラックの種類 + Add video tracks ビデオトラックを追加 + Add audio tracks オーディオトラックを追加 + Add audio submix tracks オーディオサブミックストラックを追加 + After {name} {name} の後 + Before First Track 最初のトラックの前 + Delete Tracks トラックを削除 + Video Tracks ビデオトラック + Audio Tracks オーディオトラック + Delete Video Tracks ビデオトラックを削除する + Delete Audio Tracks オーディオトラックを削除する + All Empty Tracks すべての空のトラック + Version {version} バージョン {version} + A clean-room, pure-Rust non-linear video editor. Part of the ArtCraft family. Rust だけで書かれた、クリーンルーム設計のノンリニアビデオ編集ソフトです。ArtCraft ファミリーの一員です。 + Join the ArtCraft Discord ArtCraft の Discord に参加 + Source code on GitHub GitHub のソースコード + Report an issue 問題を報告 + MIT OR Apache-2.0. Fonts: Inter, Noto Serif and JetBrains Mono (SIL OFL 1.1). MIT OR Apache-2.0。フォント: Inter、Noto Serif、JetBrains Mono (SIL OFL 1.1)。 + just now たった今 + {n} s ago {n} 秒前 + {n} min ago {n} 分前 + {n} h ago {n} 時間前 + All Events すべてのイベント + Warnings and Errors 警告とエラー + Errors Only エラーのみ + Clear All すべてクリア + No events. イベントはありません。 + Show finished jobs 終了したジョブを表示 + Cancelled キャンセル済み + Failed: {e} 失敗しました: {e} + Done 完了 + No background jobs. バックグラウンドジョブはありません。 + Recover Unsaved Changes 未保存の変更を復元 + Untitled 名称未設定 + FilmCraft was closed while “{name}” had unsaved changes. 「{name}」に未保存の変更がある状態で FilmCraft が終了しました。 + FilmCraft quit unexpectedly while “{name}” had unsaved changes. 「{name}」に未保存の変更がある状態で FilmCraft が予期せず終了しました。 + Recover unsaved changes from {when}? {when} の未保存の変更を復元しますか? + The project had not been saved yet. このプロジェクトはまだ保存されていませんでした。 + {n} sessions have unsaved changes: {n} 件のセッションに未保存の変更があります: + Discard 破棄 + Recover 復元 + Not Now 後で + Are you sure you want to discard your changes to '{file}'? 「{file}」への変更を破棄してもよろしいですか? + Yes はい + No いいえ + Add media to your project. Drop files anywhere in the window, or choose a source below. プロジェクトにメディアを追加します。ウィンドウ内の任意の場所にファイルをドロップするか、下のソースを選択してください。 + Browse files… ファイルを参照… + Movies, audio and stills from disk ディスク上のムービー、オーディオ、静止画 + Demo footage デモ素材 + Six procedural 1080p clips with sound サウンド付きの 1080p クリップ 6 本 (プロシージャル生成) + Demo project デモプロジェクト + A cut sequence with transitions, effects and music トランジション、エフェクト、音楽を含む編集済みのシーケンス + Bars and Tone カラーバーとトーン + SMPTE HD bars with 1 kHz reference tone 1 kHz 基準トーン付きの SMPTE HD カラーバー + Community コミュニティ + Join us on Discord Discord に参加 + OMF Export Settings OMF 書き出し設定 + AAF Export Settings AAF 書き出し設定 + OMF Title: OMF タイトル: + Mixdown video ビデオをミックスダウンする + Audio: オーディオ: + Encapsulate カプセル化 + Separate Audio オーディオを分離 + Embed 埋め込み + Separate Files 個別のファイル + Link to Media メディアにリンク + Audio File Format: オーディオファイル形式: + Sample Rate: サンプルレート: + Bits per Sample: サンプルあたりのビット数: + Rendering: レンダリング: + Trim audio files オーディオファイルをトリムする + Handle Frames: ハンドルフレーム: + Render audio clip effects オーディオクリップのエフェクトをレンダリングする + Breakout to mono モノラルに分割する + Exported {path} {path} を書き出しました + Select a clip to view its metadata. クリップを選択すると、メタデータを表示します。 + Clip: {name} クリップ: {name} + Open 開く + (no sequence) (シーケンスなし) + Show or hide {color} markers {color} のマーカーの表示/非表示 + Marker マーカー + {tc} Chapter {tc} チャプター + No markers. Press M to add one. マーカーはありません。M キーを押すと追加できます。 + All markers are hidden by the colour filter. 色フィルターにより、すべてのマーカーが非表示になっています。 + Type: 種類: + Video: ビデオ: + Codec: コーデック: + Duration: 長さ: + Settings: 設定: + Playhead: 再生ヘッド: + Video {n}: ビデオ {n}: + Audio {n}: オーディオ {n}: + UI: UI: + {fps} fps · {n} frames queued {fps} fps · {n} フレーム待機中 + {panel} — coming in a later milestone {panel} — 今後のマイルストーンで実装予定 + Close Panel パネルを閉じる + Close Other Timeline Panels 他のタイムラインパネルを閉じる + Maximize Frame パネルを最大化 + Restore Workspace ワークスペースを復元 + Reveal Sequence in Project プロジェクトでシーケンスを表示 + Video Thumbnails ビデオサムネイル + Audio Waveforms オーディオ波形 + Delete Preset プリセットを削除 + Export Preset… プリセットを書き出し… + Import Presets… プリセットを読み込み… + Effect presets エフェクトプリセット + Save Preset プリセットを保存 + Name: 名前: + Type 種類 + Scale スケール + Anchor to In Point イン点に固定 + Anchor to Out Point アウト点に固定 + Without keyframes キーフレームなし + Description: 説明: + Saved preset “{name}” プリセット「{name}」を保存しました + Current duration {seconds} s 現在の長さ {seconds} s + Remix Properties リミックスのプロパティ + Target Duration: 目標の長さ: + Segments: セグメント: + Fewer · More 少なく · 多く + Variations: バリエーション: + Remixed to {seconds} s {seconds} s にリミックスしました + {mode} · no clip {mode} · クリップなし + {mode} · no sequence {mode} · シーケンスなし + Remove Row 行を削除 + Add a timecode row タイムコードの行を追加 + Panner パンナー + Mute ミュート + Send {n} Level センド {n} のレベル + Show Keyframes キーフレームを表示 + Clip Keyframes クリップのキーフレーム + Track Keyframes トラックのキーフレーム + Delete 削除 + Untitled Workspace 名称未設定のワークスペース + New Workspace 新規ワークスペース + Edit Workspaces ワークスペースを編集 + Rename 名前を変更 + Restore Original 元の状態に復元 + Search effects エフェクトを検索 + Apply to Selected Clips 選択したクリップに適用 + Metadata Display メタデータ表示 + Save As New View Preset 新規表示プリセットとして保存 + Manage Saved View Presets 保存した表示プリセットを管理 + Freeform View Options フリーフォーム表示のオプション + Save Arrangement 配置を保存 + Saves the view, columns, sort, thumbnail and font size. 表示モード、列、並べ替え、サムネイルサイズ、フォントサイズを保存します。 + Grid spacing: グリッド間隔: + Default clip size: 既定のクリップサイズ: + Snap to grid グリッドにスナップ + Show clip names クリップ名を表示 + Show durations 長さを表示 + Show: 表示: + Search fields フィールドを検索 + Project Metadata プロジェクトのメタデータ + Add Property… プロパティを追加… + Column order 列の順序 + {n}. (empty) {n}. (空き) + Restore 復元 + Overwrite with Current View 現在の表示で上書き + Graphics text search arrives with M10.1–M10.2 グラフィックスのテキスト検索は M10.1–M10.2 で対応予定です + Open a sequence to work with captions キャプションを扱うにはシーケンスを開いてください + Add captions キャプションを追加 + Create a caption track or import a caption file. キャプショントラックを作成するか、キャプションファイルを読み込んでください。 + Create new caption track 新規キャプショントラックを作成 + Import captions file… キャプションファイルを読み込み… + New {format} track 新規 {format} トラック + Add caption at playhead 再生ヘッドの位置にキャプションを追加 + Split caption at playhead 再生ヘッドの位置でキャプションを分割 + Merge selected captions 選択したキャプションを結合 + Delete selected captions 選択したキャプションを削除 + No captions on this track. Press + to add one at the playhead. このトラックにはキャプションがありません。+ を押すと、再生ヘッドの位置に追加できます。 + No matching captions. 一致するキャプションはありません。 + Open a sequence to see its transcript 文字起こしを表示するにはシーケンスを開いてください + Transcribe sequence シーケンスを文字起こし + Speech-to-text turns the dialogue into editable text. 音声認識で、セリフを編集できるテキストに変換します。 + This build has no speech-to-text; import a transcript with transcript.set. このビルドには音声認識機能がありません。transcript.set で文字起こしを読み込んでください。 + Transcribe 文字起こし + Extract selected text 選択したテキストを抽出 + Lift selected text 選択したテキストをリフト + Remove filler words フィラーワードを削除 + Create captions キャプションを作成 + Speaker 話者 + Box ボックス + Align 整列 + Align left 左揃え + Align center 中央揃え + Align right 右揃え + Position 位置 + Position: top 位置: 上 + Position: middle 位置: 中央 + Position: bottom 位置: 下 + (gap) (ギャップ) + Out: {name} アウト: {name} + In: {name} イン: {name} + Play Around Edit (Space loops, Shift+K once) 編集点の前後を再生 (Space でループ、Shift+K で 1 回) + Trim Backward Many 後方へ複数フレームトリム + Trim Backward 後方へトリム + Apply Default Transitions 既定のトランジションを適用 + Apply Default Transitions to Selection (Shift+D) 選択項目に既定のトランジションを適用 (Shift+D) + Trim Forward 前方へトリム + Trim Forward Many 前方へ複数フレームトリム + Exit Trim Mode トリムモードを終了 + Voice-over record (right-click: Voice-Over Record Settings…) ナレーションを録音 (右クリック: ナレーション録音設定…) + Voice-Over Record Settings… ナレーション録音設定… + Recording voice-over… ナレーションを録音中… + Voice-over recorded: {p} ナレーションを録音しました: {p} + Voice-over recording stopped (nothing recorded) ナレーションの録音を停止しました (何も録音されていません) + Post-roll ポストロール + ● Recording ● 録音中 + Voice-Over Record Settings ナレーション録音設定 + Source: ソース: + Default Input 既定の入力 + Input: 入力: + Channel {n} チャンネル {n} + Countdown Sound Cues カウントダウン音 + Pre-roll: プリロール: + seconds 秒 + Post-roll: ポストロール: + Playback starts the pre-roll before the playhead (or the In point); with In/Out marked, recording punches in and out there. 再生は、再生ヘッド (またはイン点) のプリロール分手前から始まります。イン点とアウト点を設定している場合は、その位置でパンチイン・パンチアウトして録音します。 + Effect エフェクト + Clip Fx Editor クリップ Fx エディター + Track Fx Editor トラック Fx エディター + Master Gain マスターゲイン + Reset リセット + Band {n} バンド {n} + Solo ソロ + Bypass バイパス + Thr しきい値 + Ratio レシオ + Att アタック + Rel リリース + Gain ゲイン + Output Gain 出力ゲイン + Limiter リミッター + Link Channels チャンネルをリンク + Limiter Threshold リミッターのしきい値 + Limiter Release リミッターのリリース + Auto Gate 自動ゲート + Threshold しきい値 + Attack アタック + Release リリース + Hold ホールド + Compressor コンプレッサー + Auto Makeup 自動メイクアップ + Makeup メイクアップ + Expander エキスパンダー + Soft Clip ソフトクリップ + (no sequences) (シーケンスなし) + (no clips) (クリップなし) + Show Transport Controls トランスポートコントロールを表示 + Loop ループ + Multi-Camera Audio Follows Video マルチカメラでオーディオをビデオに追従させる + Multi-Camera Record マルチカメラ記録 + Multi-Camera Selection Top Down マルチカメラの選択を上から順に + Show Multi-Camera Preview Monitor マルチカメラプレビューモニターを表示 + Auto-Adjust Multi-Camera Playback Quality マルチカメラの再生画質を自動調整 + Transmit Multi-Camera View マルチカメラ表示を送出 + Multi-Camera Layout マルチカメラのレイアウト + Automatic 自動 + Edit Cameras… カメラを編集… + Add Marker (M) マーカーを追加 (M) + Mark In (I) イン点をマーク (I) + Mark Out (O) アウト点をマーク (O) + Go to In (Shift+I) イン点へ移動 (Shift+I) + Step Back 1 Frame (Left) 1 フレーム戻る (Left) + Play-Stop Toggle (Space) 再生/停止を切り替え (Space) + Step Forward 1 Frame (Right) 1 フレーム進む (Right) + Go to Out (Shift+O) アウト点へ移動 (Shift+O) + Insert (,) 挿入 (,) + Overwrite (.) 上書き (.) + Export Frame (Shift+E) フレームを書き出し (Shift+E) + Lift (;) リフト (;) + Extract (') 抽出 (') + Export Frame: use Export mode (M6) フレームの書き出し: 書き出しモードを使用してください (M6) + Button Editor ボタンエディター + Multi-Camera Record on マルチカメラ記録: オン + Multi-Camera Record off マルチカメラ記録: オフ + {first} - Merged {first} - 結合済み + {first} Multicam {first} マルチカメラ + In Points イン点 + Out Points アウト点 + Clip Marker クリップマーカー + Audio オーディオ + Merge Clips クリップを結合 + Create Multi-Camera Source Sequence マルチカメラソースシーケンスを作成 + Synchronize Clips クリップを同期 + Clip Name: クリップ名: + Sequence Name: シーケンス名: + {n} clip(s): {names} {n} 個のクリップ: {names} + {n} clip(s) selected; the clip on the reference track stays put. {n} 個のクリップを選択中。基準トラックのクリップは移動しません。 + Synchronize Point 同期ポイント + Ignore Hours 時間の桁を無視 + first marker 最初のマーカー + Offset Audio by: オーディオのオフセット: + Offset: オフセット: + frames フレーム + Reference track: 基準トラック: + lowest (V1…) 最下段 (V1…) + Remove Audio from AV Clip AV クリップからオーディオを削除 + Camera 1 カメラ 1 + All Cameras すべてのカメラ + Switch Audio オーディオを切り替え + Camera Names カメラ名 + Clip Names クリップ名 + Enumerate Cameras カメラの連番 + Camera Angle Metadata カメラアングルのメタデータ + Move source clips to Processed Clips bin ソースクリップを「Processed Clips」ビンに移動 + Synchronized {n} clip group(s) {n} 個のクリップグループを同期しました + Merged clip created 結合クリップを作成しました + Multi-camera source sequence with {n} camera(s) マルチカメラソースシーケンス (カメラ {n} 台) + No multi-camera clip at the playhead 再生ヘッドの位置にマルチカメラクリップがありません + Camera {n} — {name} カメラ {n} — {name} + Page {page} of {pages} {page} / {pages} ページ + Previous page 前のページ + Next page 次のページ + Multi-Camera Record On/Off (0) マルチカメラ記録のオン/オフ (0) + Record 記録 + Edit Cameras カメラを編集 + Cameras shown in the Multi-Camera view (uncheck to hide). マルチカメラ表示に含めるカメラです (チェックを外すと非表示になります)。 + Recorded {n} multi-camera cut(s) {n} 個のマルチカメラカットを記録しました + Clamp Signal 信号をクランプ + Presets プリセット + Parade ({kind}) パレード ({kind}) + Waveform ({kind}) 波形 ({kind}) + Parade Type パレードの種類 + Waveform Type 波形の種類 + Vectorscope Targets ベクトルスコープのターゲット + Colour Space カラースペース + Brightness 明るさ + Signal Scale 信号スケール + Histogram ヒストグラム + Paste Attributes 属性を貼り付け + Remove Attributes 属性を削除 + New Offline File 新規オフラインファイル + Nested Sequence Name ネストされたシーケンス名 + Make Subclip サブクリップを作成 + Edit Subclip サブクリップを編集 + Modify Clip: Audio Channels クリップの変更: オーディオチャンネル + Modify Clip: Timecode クリップの変更: タイムコード + Frame Hold Options フレームホールドのオプション + Field Options フィールドのオプション + Save Project プロジェクトを保存 + Motion モーション + Opacity 不透明度 + Time Remapping タイムリマップ + Channel Volume チャンネル音量 + In Point イン点 + Out Point アウト点 + Playhead 再生ヘッド + Source Timecode ソースタイムコード + Sequence Time シーケンス時間 + Video Attributes ビデオ属性 + Audio Attributes オーディオ属性 + No effects エフェクトはありません + Scale Attribute Times 属性の時間を伸縮する + File Name: ファイル名: + Tape Name: テープ名: + Contains: 内容: + Video ビデオ + Width: 幅: + Height: 高さ: + Frame Rate: フレームレート: + Media Start: メディアの開始: + Subclip: {name} サブクリップ: {name} + Start: 開始: + End: 終了: + Restrict Trims To Subclip Boundaries トリムをサブクリップの範囲内に制限する + Convert to Master Clip マスタークリップに変換 + Clip Channel Format: クリップのチャンネル形式: + Number of Audio Clips: オーディオクリップ数: + Media Source Channels メディアソースのチャンネル + Ch. {n} Ch {n} + Clip {n} クリップ {n} + Source channels the selected audio clips play: 選択したオーディオクリップで再生するソースチャンネル: + Set Timecode: タイムコードを設定: + Use the file's timecode ファイルのタイムコードを使用する + Hold On ホールド位置 + Timecode: タイムコード: + Hold Filters フィルターをホールドする + Reverse Field Dominance フィールドオーダーを反転する + Processing Options 処理オプション + None なし + Always Deinterlace 常にインターレース解除 + Flicker Removal フリッカー除去 + Save changes to “{project_name}” before closing? 閉じる前に「{project_name}」への変更を保存しますか? + Don't Save 保存しない + All media is online. すべてのメディアがオンラインです。 + {name} (copy) {name} (コピー) + Offline オフライン + Missing 見つかりません + Link Media メディアをリンク + {n} clip(s) can't find their media. Locate them, search a folder, or leave them offline. {n} 個のクリップのメディアが見つかりません。場所を指定するか、フォルダーを検索するか、オフラインのままにしてください。 + Clip Name クリップ名 + File Name ファイル名 + File Path ファイルパス + Status 状態 + Match file properties: 照合するファイルのプロパティ: + File Extension ファイル拡張子 + Clip ID (fingerprint) クリップ ID (フィンガープリント) + Duration 長さ + Media Start メディア開始 + Frame Size / Rate フレームサイズ / レート + Align Timecode タイムコードを揃える + Relink others automatically 他のクリップを自動的に再リンク + Search in: 検索場所: + Browse… 参照… + Exact name matches only 名前の完全一致のみ + No candidates yet — Search a folder or Locate the file. 候補はまだありません。フォルダーを検索するか、ファイルの場所を指定してください。 + ✔ same file ✔ 同じファイル + ✔ matches ✔ 一致 + ✖ different file ✖ 別のファイル + Preview プレビュー + Offline All すべてオフライン + Locate… 場所を指定… + Linked {n} clip(s) {n} 個のクリップをリンクしました + Nothing found with that name. その名前に一致するものは見つかりませんでした。 + Make Offline オフラインに設定 + Make {n} selected clip(s) offline. Their clips show the offline slate until you link them again. 選択した {n} 個のクリップをオフラインにします。再びリンクするまで、クリップにはオフライン画面が表示されます。 + Media files remain on disk メディアファイルをディスクに残す + Media files are deleted メディアファイルを削除する + Create Proxies プロキシを作成 + {n} clip(s) selected. {n} 個のクリップが選択されています。 + Format and size 形式とサイズ + Destination 出力先 + Next to the original media, in a Proxies folder 元のメディアと同じ場所の Proxies フォルダー + Proxies are made in the background and attached when done. Export always uses full-resolution media. プロキシはバックグラウンドで作成され、完了すると関連付けられます。書き出しには常にフル解像度のメディアが使われます。 + Creating {n} proxy file(s)… {n} 個のプロキシファイルを作成中… + Project Manager プロジェクトマネージャー + Sequences シーケンス + Resulting Project 処理後のプロジェクト + Collect Files and Copy to New Location ファイルを収集して新しい場所にコピー + Consolidate and Transcode 統合してトランスコード + Options オプション + Exclude Unused Clips 未使用のクリップを除外 + Include Handles: ハンドルを含める: + Include Proxies プロキシを含める + Include Preview Files プレビューファイルを含める + Destination Path 出力先のパス + Disk space: original {original} · resulting {result} ({n} files) ディスク容量: 処理前 {original} · 処理後 {result} ({n} ファイル) + Disk space: — ディスク容量: — + Calculate 計算 + Project Manager: writing {path} プロジェクトマネージャー: {path} を書き込み中 + Timeline: (no sequences) タイムライン: (シーケンスなし) + Browse 参照 + Select audio clips, then click a preset to apply it. オーディオクリップを選択し、プリセットをクリックして適用します。 + {name} (user) {name} (ユーザー) + Select audio clips to edit them here オーディオクリップを選択すると、ここで編集できます + Multiple Clips Selected 複数のクリップを選択中 + The selected clips have different audio types. Assign one type to edit them together. 選択したクリップはオーディオの種類が異なります。1 つの種類を割り当てると、まとめて編集できます。 + Assign an audio type to the selection: 選択したクリップにオーディオの種類を割り当て: + Clear Audio Type オーディオの種類をクリア + Preset: プリセット: + (Custom) (カスタム) + Preset name プリセット名 + Reduce Noise ノイズを軽減 + Reduce Rumble ランブルを軽減 + DeHum ハム除去 + DeEss 歯擦音除去 + Reduce Reverb リバーブを軽減 + Dynamics ダイナミクス + EQ EQ + Enhance Speech 音声を強調 + Low Tone 低い声 + High Tone 高い声 + Mix ミックス + Reverb リバーブ + Stereo Width ステレオ幅 + Remixing music to a target duration is not available yet. Trim the clip and add a crossfade instead. 音楽を目標の長さにリミックスする機能は、まだ利用できません。代わりにクリップをトリムして、クロスフェードを追加してください。 + Pan パン + Auto-Match 自動マッチ + Not matched yet まだマッチしていません + Matches each of the {n} clips on its own. {n} 個のクリップをそれぞれ個別にマッチさせます。 + Duck against: ダッキングのトリガー: + Untagged タグなし + Sensitivity 感度 + Reduce By 減衰量 + Fades フェード + Generate Keyframes キーフレームを生成 + Clip Volume クリップの音量 + (no clip selected) (クリップが選択されていません) + Master · {name} マスター · {name} + Add Lumetri Color to clip クリップに Lumetri カラーを追加 + Input LUT 入力 LUT + Color カラー + Temperature 色温度 + Tint 色合い + Saturation 彩度 + Exposure 露出 + Contrast コントラスト + Highlights ハイライト + Shadows シャドウ + Whites ホワイト + Blacks ブラック + HDR HDR + HDR White HDR ホワイト + HDR Specular HDR スペキュラー + Look ルック + Look LUT ルック LUT + Intensity 強さ + Adjustments 調整 + Faded Film 色あせたフィルム + Sharpen シャープ + Vibrance 自然な彩度 + RGB Curves RGB カーブ + HDR Range HDR 範囲 + Hue Saturation Curves 色相/彩度カーブ + Hue vs Sat 色相 vs 彩度 + Hue vs Hue 色相 vs 色相 + Hue vs Luma 色相 vs 輝度 + Luma vs Sat 輝度 vs 彩度 + Sat vs Sat 彩度 vs 彩度 + Midtones 中間調 + Key キー + Hue 色相 + Hue Range 色相の範囲 + Saturation Min 最小彩度 + Luma Min 最小輝度 + Luma Max 最大輝度 + Soften ソフト化 + Show Mask マスク表示 + Off オフ + Color/Gray カラー/グレー + Color/Black カラー/ブラック + White/Black ホワイト/ブラック + Refine リファイン + Denoise ノイズ除去 + Blur ぼかし + Correction 補正 + Hue Shift 色相シフト + Midpoint 中間点 + Roundness 丸み + Feather ぼかし + LUT LUT + Color Match カラーマッチ + Reference 参照 + Face Detection (protect skin tones) 顔検出 (肌の色を保護) + Apply Match マッチを適用 + Create ellipse mask 楕円マスクを作成 + Create 4-point polygon mask 4 点の多角形マスクを作成 + Free draw bezier ベジェで自由に描画 + Click in the Program monitor to place mask points (drag for curves); click the first point or press Enter to close プログラムモニターをクリックしてマスクのポイントを配置します (ドラッグで曲線)。最初のポイントをクリックするか Enter キーを押すと、マスクが閉じます + Delete Mask マスクを削除 + Inverted 反転 + Drag: move · Alt-click: smooth/corner · Cmd-click: delete ドラッグ: 移動 · Alt+クリック: スムーズ/コーナー · Cmd+クリック: 削除 + Track selected mask backward 選択したマスクを逆方向にトラッキング + Track selected mask backward 1 frame 選択したマスクを 1 フレーム逆方向にトラッキング + Track selected mask forward 1 frame 選択したマスクを 1 フレーム順方向にトラッキング + Track selected mask forward 選択したマスクを順方向にトラッキング + Stop tracking トラッキングを停止 + Tracking method: {method} トラッキング方法: {method} + Composite コンポジット + Comparison 比較 + Video and Waveform ビデオと波形 + System Default システムの既定 + Default location 既定の場所 + Font family フォントファミリー + Label Colors ラベルカラー + Decoded frames held for the monitors and thumbnails: {used} of {budget}. モニターとサムネイル用に保持するデコード済みフレームに、{budget} のうち {used} を使用しています。 + FilmCraft keeps the rest of the RAM free for other applications. FilmCraft は残りの RAM をほかのアプリケーション用に空けておきます。 + not available in this session このセッションでは利用できません + Currently: {loc} — {files} file(s), {size} 現在: {loc} — {files} 個のファイル、{size} + ({ch} output channels) (出力 {ch} チャンネル) + Programme left → device output {l}, right → device output {r}{dev}. Outputs beyond the device's channels fall back to 1 and 2. プログラムの左 → デバイス出力 {l}、右 → デバイス出力 {r}{dev}。デバイスのチャンネル数を超える出力は、代わりに 1 と 2 に送られます。 + Auto-saves go to: {dir} 自動保存先: {dir} + Recovery copy: {dir} 復旧用コピー: {dir} + Last auto-save {a} · last recovery copy {j} 前回の自動保存 {a} · 前回の復旧用コピー {j} + Last auto-save {a} 前回の自動保存 {a} + Last recovery copy {j} 前回の復旧用コピー {j} + Remove Media Cache Files メディアキャッシュファイルの削除 + Deleted {n} file(s), {size}. {n} 個のファイル、{size} を削除しました。 + Reset… リセット… + Source · {name} ソース · {name} + Reset Effect エフェクトをリセット + Save Preset… プリセットを保存… + Custom Setup カスタム設定 + Edit… 編集… + Show graphs グラフを表示 + Toggle animation アニメーションのオン / オフを切り替え + Add/Remove Keyframe キーフレームを追加/削除 + Temporal Interpolation 時間補間 + Linear リニア + Bezier ベジェ + Auto Bezier 自動ベジェ + Continuous Bezier 連続ベジェ + Ease In イーズイン + Ease Out イーズアウト + Select a clip to grade クリップを選択すると、グレーディングできます + Select a clip to see its properties クリップを選択すると、プロパティを表示します + Anchor point アンカーポイント + Rotation 回転 + Level レベル + Speed {n}% 速度 {n}% + Value 値 + Velocity 速度 + Default 既定 + Custom ({n} points) カスタム ({n} ポイント) + Select a graphic clip, or use the Type tool (T) on the Program monitor グラフィッククリップを選択するか、プログラムモニターでテキストツール (T) を使用してください + Create new graphic 新規グラフィックを作成 + New Text Layer 新規テキストレイヤー + New Text 新規テキスト + New Rectangle 新規長方形 + New Ellipse 新規楕円 + Delete Layer レイヤーを削除 + Select a layer to edit its properties. レイヤーを選択すると、プロパティを編集できます。 + Align Left 左揃え + Align Center Horizontally 水平方向の中央揃え + Align Right 右揃え + Align Top 上揃え + Align Center Vertically 垂直方向の中央揃え + Align Bottom 下揃え + Font フォント + Font Style フォントスタイル + Font Size フォントサイズ + Alignment 整列 + Left Align Text テキストを左揃え + Center Align Text テキストを中央揃え + Right Align Text テキストを右揃え + Justify 両端揃え + Tracking トラッキング + Leading 行送り + Baseline Shift ベースラインシフト + Text Box Width テキストボックスの幅 + Style スタイル + Faux Bold 疑似ボールド + Faux Italic 疑似イタリック + All Caps すべて大文字 + Small Caps スモールキャップ + Underline 下線 + OpenType OpenType + Kerning カーニング + Ligatures 合字 + Shape シェイプ + Size サイズ + Corner Radius 角丸の半径 + Polygon Sides 多角形の辺の数 + Fill 塗り + Stroke 線 + Stroke 2 線 2 + Background 背景 + Opacity 不透明度 + Size サイズ + Corner Radius 角丸の半径 + Shadow シャドウ + Angle 角度 + Distance 距離 + Blur ぼかし + Search templates テンプレートを検索 + All Categories すべてのカテゴリ + Install… インストール… + Install Motion Graphics Template (.fcgt) モーショングラフィックステンプレートをインストール (.fcgt) + No templates match. 一致するテンプレートはありません。 + {category} · My Template {category} · マイテンプレート + Apply 適用 + Remove 削除 + {n} editable properties {n} 個の編集可能なプロパティ + Template: {name} テンプレート: {name} + Intro Duration イントロの長さ + Outro Duration アウトロの長さ + Roll ロール + Start Off Screen 画面外から開始 + End Off Screen 画面外で終了 + Preroll プリロール + Postroll ポストロール + Video Frame ビデオフレーム + Pin To 固定先 + Pinned Edges 固定する辺 + FilmCraft Graphics Template FilmCraft グラフィックステンプレート + {n} Font {n} のフォント + {n} Size {n} のサイズ + {n} Color {n} の色 + {n} Position {n} の位置 + Show {n} {n} を表示 + My Templates マイテンプレート + Export As Motion Graphics Template モーショングラフィックステンプレートとして書き出し + Category: カテゴリ: + Editable properties: 編集可能なプロパティ: + Saved as a FilmCraft graphics template (.fcgt) in your templates folder. テンプレートフォルダーに FilmCraft グラフィックステンプレート (.fcgt) として保存されます。 + Exported graphics template to {path} グラフィックステンプレートを {path} に書き出しました + Replace Fonts in Projects プロジェクト内のフォントを置換 + Fonts used in the project: プロジェクトで使用されているフォント: + {f} (missing) {f} (見つかりません) + Replace: 置換前: + Replaced {n} font uses {n} 箇所のフォントを置換しました + Show/Hide Effects and Sends エフェクトとセンドの表示/非表示 + Audio Track Mixer menu オーディオトラックミキサーのメニュー + Show/Hide Tracks… トラックの表示/非表示… + Meter Input(s) Only 入力のみをメーター表示 + Show/Hide Tracks トラックの表示/非表示 + Show All すべてを表示 + Post-Fader ポストフェーダー + Remove Effect エフェクトを削除 + Fill the slots above first 先に上のスロットを埋めてください + pre プリ + Pre-Fader プリフェーダー + Mute Send センドをミュート + Remove Send センドを削除 + New Submix 新規サブミックス + Pan / balance (drag; double-click: centre) パン / バランス (ドラッグで調整、ダブルクリックで中央) + Solo safe ソロセーフ + Enable track for recording トラックの録音を有効にする + Volume (drag; double-click: 0 dB) 音量 (ドラッグで調整、ダブルクリックで 0 dB) + 5.1 panner (drag the puck; double-click: front centre) 5.1 パンナー (ポイントをドラッグ、ダブルクリックで前方中央) + Center 中央 + Go to In Point イン点へ移動 + Go to Out Point アウト点へ移動 + Play In to Out イン点からアウト点まで再生 + Record (voice-over on the record-enabled track) 録音 (録音が有効なトラックにナレーションを収録) + Write keyframes キーフレームを書き込み + Something went wrong while drawing the window. Your project is still open; save it to be safe. ウィンドウの描画中に問題が発生しました。プロジェクトは開いたままです。念のため保存してください。 + Continue 続行 + Go Back 戻る + Go Forward 進む + Up One Level 上の階層へ移動 + File Types Displayed 表示するファイルの種類 + List View リスト表示 + Thumbnail View サムネイル表示 + Ingest 取り込み + Open Ingest Settings 取り込み設定を開く + Import as Image Sequence 連番画像として読み込む + {sel} of {total} items selected {total} 項目中 {sel} 項目を選択中 + {total} items {total} 項目 + Cannot open {dir}: {e} {dir} を開けません: {e} + Favorites お気に入り + Local Drives ローカルドライブ + Network ネットワーク + Recent Directories 最近使用したディレクトリ + (none) (なし) + Remove from Favorites お気に入りから削除 + Add to Favorites お気に入りに追加 + Clear Recent Directories 最近使用したディレクトリをクリア + Open In Source Monitor ソースモニターで開く + Reveal in Finder Finder で表示 + Edit Columns… 列を編集… + Hover Scrub ホバースクラブ + New Media Browser Panel 新規メディアブラウザーパネル + Create Folder For Imported Projects 読み込んだプロジェクト用のフォルダーを作成 + Allow Duplicate Media During Project Import プロジェクトの読み込み時にメディアの重複を許可 + Refresh 更新 + Edit Columns 列を編集 + Guides ガイド + (no audio) (オーディオなし) + Generating waveform… 波形を生成中… + Reference {tc} 参照 {tc} + Set 設定 + Set the reference to the playhead 参照位置を再生ヘッドに設定 + Reference: next frame 参照: 次のフレーム + Reference: previous frame 参照: 前のフレーム + RGBA Channels RGBA チャンネル + Add Guide ガイドを追加 + Save Guides as Template ガイドをテンプレートとして保存 + Manage Guides ガイドを管理 + Orientation: 向き: + Vertical 垂直 + Horizontal 水平 + Position: 位置: + No saved guide templates. 保存されたガイドテンプレートはありません。 + Project: {name} プロジェクト: {name} + Bin: {name} ビン: {name} + Up one level 上の階層へ移動 + Video Usage: {vu} Audio Usage: {au} ビデオ使用: {vu} オーディオ使用: {au} + Icon View アイコン表示 + Freeform View フリーフォーム表示 + Sort Icons アイコンを並べ替え + Clear (Delete) 消去 (Delete) + New Item 新規項目 + New Bin 新規ビン + Find 検索 + Automate to Sequence シーケンスに自動配置 + Select items to clear 消去する項目を選択してください + User Order ユーザー定義の順序 + Descending 降順 + Refresh Project プロジェクトを更新 + Reveal Project in Finder… Finder でプロジェクトを表示… + New Bin From Selection 選択項目から新規ビン + New Search Bin 新規検索ビン + New Project Shortcut 新規プロジェクトショートカット + List リスト + Icon アイコン + Freeform フリーフォーム + Preview Area プレビューエリア + Thumbnails サムネイル + Thumbnails show effects applied サムネイルに適用済みのエフェクトを表示 + Thumbnail controls for all pointing devices すべてのポインティングデバイスでサムネイルを操作 + Refresh Sort Order 並べ替え順を更新 + Metadata Display… メタデータ表示… + Save Current View Preset 現在の表示をプリセットとして保存 + Restore View Preset 表示プリセットを復元 + Project View Preset {n} プロジェクト表示プリセット {n} + Freeform View Options… フリーフォーム表示のオプション… + Media missing メディアが見つかりません + Unreadable 読み取れません + Open in Source Monitor ソースモニターで開く + Open in Timeline タイムラインで開く + New Sequence From Clip クリップから新規シーケンス + Set Poster Frame ポスターフレームを設定 + Proxy プロキシ + Open in Place その場で開く + Open in New Tab 新しいタブで開く + Open in New Window 新しいウィンドウで開く + {n} items {n} 項目 + Stack スタック + Unstack スタックを解除 + Clip Size クリップサイズ + Larger 大きく + Smaller 小さく + Align to Grid グリッドに整列 + Reset to Grid グリッドにリセット + Save Arrangement… 配置を保存… + Restore Arrangement 配置を復元 + Delete Arrangement 配置を削除 + Drop media here to create sequence. ここにメディアをドロップすると、シーケンスを作成します。 + Double-click a sequence in the Project panel to open it here. プロジェクトパネルでシーケンスをダブルクリックすると、ここで開きます。 + Open Demo Project デモプロジェクトを開く + Insert and overwrite sequences as nests or individual clips シーケンスをネストまたは個別のクリップとして挿入・上書き + Snap in Timeline (S) タイムラインでスナップ (S) + Caption track options キャプショントラックのオプション + Timeline Display Settings タイムライン表示設定 + Show Video Thumbnails ビデオのサムネイルを表示 + Show Audio Waveform オーディオ波形を表示 + Expand All Tracks すべてのトラックを展開 + Minimize All Tracks すべてのトラックを最小化 + Unrendered: should play back in real time 未レンダリング: リアルタイムで再生できるはずです + Unrendered: render to play back in real time 未レンダリング: リアルタイム再生にはレンダリングが必要です + Rendered preview レンダリング済みプレビュー + ✓ Enable ✓ 有効 + Unlink リンク解除 + (Default) (既定) + (RGB) Gamma (RGB) ガンマ + (RGB) Input Black Level (RGB) 入力黒レベル + (RGB) Input White Level (RGB) 入力白レベル + (RGB) Output Black Level (RGB) 出力黒レベル + (RGB) Output White Level (RGB) 出力白レベル + + Cmd + Cmd + + Opt + Opt + 10 Bit Black Point 10 ビットのブラックポイント + 10 Bit White Point 10 ビットのホワイトポイント + 105mm Prime 105mm 単焦点 + 29.97 fps Drop-Frame 29.97 fps ドロップフレーム + 29.97 fps Non Drop-Frame 29.97 fps ノンドロップフレーム + 35mm Prime 35mm 単焦点 + 3D Roll 3D ロール + 3D Rotate 3D 回転 + 3D Spin 3D スピン + 3D Spinback 3D スピンバック + 4-Color Gradient 4 色グラデーション + 5.1 Media 5.1 メディア + 5.1 Mixdown Type 5.1 ミックスダウンの種類 + 50-300mm Zoom 50-300mm ズーム + 59.94 fps Drop-Frame 59.94 fps ドロップフレーム + 59.94 fps Non Drop-Frame 59.94 fps ノンドロップフレーム + ASC CDL ASC CDL + Accessible color contrast アクセシビリティに配慮したカラーコントラスト + Action-Safe Margin アクションセーフの余白 + Active Monitor アクティブなモニター + Add 加算 + Add tracks automatically when editing source clips onto the Timeline ソースクリップをタイムラインに配置するとき、トラックを自動的に追加する + Additional Scale 追加スケール + Additive Dissolve 加算ディゾルブ + Additive Dissolve (Legacy) 加算ディゾルブ (従来版) + Adjust 調整 + Advanced 詳細設定 + Aggressive 強め + Align and Transform 整列と変形 + All lines すべての行 + Allow Selection tool to choose Roll and Ripple trims without modifier key 選択ツールで修飾キーなしにロールトリムとリップルトリムを選べるようにする + Allow current tool to change trim type of previously selected edit point 前に選択した編集点のトリムの種類を現在のツールで変更できるようにする + Allow duplicate media during project import プロジェクトの読み込み時にメディアの重複を許可する + Alpha Adjust アルファ調整 + Alpha Blurriness アルファのぼかし + Alpha Channel アルファチャンネル + Alpha Glow アルファグロー + Always import embedded CEA-708 closed captions with wide aspect. 埋め込まれた CEA-708 クローズドキャプションを常にワイドアスペクトで読み込む + Always override audio tags オーディオタグを常に上書きする + Ambience 環境音 + Ambience Intensity 環境光の強さ + Ambience Light Color 環境光の色 + Amount of Noise ノイズの量 + Amount to Decolor 脱色の量 + Amount to Tint 色合いの適用量 + Amplifier アンプ + Amplify 増幅 + Amplitude 振幅 + Amplitude and Compression 振幅とコンプレッション + Analog アナログ + Analog Delay アナログディレイ + Analyze all imported media to search for visuals or audio 読み込んだすべてのメディアを解析して、ビジュアルやオーディオを検索できるようにする + Anamorphic アナモルフィック + Anchor アンカー + Anchor Point アンカーポイント + Angle 角度 + Animation アニメーション + Anti-aliasing Quality アンチエイリアスの品質 + Anti-flicker Filter ちらつき防止フィルター + Antialiasing for Best Quality 最高画質時のアンチエイリアス + Asymmetry 非対称 + At Startup 起動時 + At playback end, return to beginning when restarting playback 最後まで再生した後に再生を再開すると、先頭に戻る + Attempt to force hardware to document sample rate ハードウェアをシーケンスのサンプルレートで動作させるよう試みる + Audio Codec オーディオコーデック + Audio Duration オーディオの長さ + Audio Effects オーディオエフェクト + Audio Hardware オーディオハードウェア + Audio In Point オーディオのイン点 + Audio Info オーディオ情報 + Audio Out Point オーディオのアウト点 + Audio Samples オーディオサンプル + Audio Time Units オーディオ時間単位 + Audio Transition Default Duration オーディオトランジションの既定の長さ + Audio Transitions オーディオトランジション + Audio Usage オーディオの使用状況 + Auto 自動 + Auto Align 自動位置合わせ + Auto Reframe 自動リフレーム + Auto Save 自動保存 + Auto Save also saves the current project(s) 自動保存時に現在のプロジェクトも保存する + Auto-tag audio types in the timeline タイムラインでオーディオの種類を自動でタグ付けする + Auto-transcribe all imported clips 読み込んだすべてのクリップを自動で文字起こし + Auto-transcribe only clips in sequences シーケンス内のクリップのみ自動で文字起こし + Automatch Time オートマッチ時間 + Automatic Click Remover クリックノイズ自動除去 + Automatically Hide Dependent Clips 依存クリップを自動的に非表示にする + Automatically Save Every 自動保存の間隔 + Automatically delete cache files older than 一定期間を過ぎたキャッシュファイルを自動的に削除 + Automatically delete oldest cache files when cache exceeds キャッシュが上限を超えたら古いファイルから自動的に削除 + Automatically refresh growing files 書き込み中のファイルを自動的に更新する + Automatically resume playback for growing files in Source Monitor ソースモニターで書き込み中のファイルの再生を自動的に再開する + Automatically save projects プロジェクトを自動保存する + Automatically transcribe clips クリップを自動的に文字起こしする + Automation Keyframe Optimization オートメーションキーフレームの最適化 + Background Color 背景色 + Background Corner Radius 背景の角丸の半径 + Background Opacity 背景の不透明度 + Background Size 背景のサイズ + Background fill 背景の塗り + Balance バランス + Balanced Voice バランスの良い音声 + Band 1 バンド 1 + Band 2 バンド 2 + Band 3 バンド 3 + Band 4 バンド 4 + Band 5 バンド 5 + Band Pass バンドパス + Band Slide バンドスライド + Band Stop バンドストップ + Bandpass バンドパス + Barn Doors 観音開き + Basic 基本 + Basic 3D 基本 3D + Basic Correction 基本補正 + Bass 低音 + Behind 背面 + Bessel ベッセル + Bevel Alpha アルファベベル + Bevel join 面取りした角 + Binauralizer - Ambisonics バイノーラライザー - アンビソニックス + Bins ビン + Black 黒 + Black & White 白黒 + Black Input Level 入力黒レベル + Black and white 白黒 + Black and white with crushed blacks and strong contrast 黒をつぶし、コントラストを強めた白黒 + Bleach Bypass 銀残し + Bleach-bypass style: desaturated with hard contrast 銀残し風: 彩度を抑え、コントラストを強めた色調 + Blend ブレンド + Blend Mode 描画モード + Blend With Original 元の画像とのブレンド + Blending Mode 描画モード + Block Dissolve ブロックディゾルブ + Block Height ブロックの高さ + Block Motion ブロックモーション + Block Size ブロックサイズ + Block Width ブロックの幅 + Blocks ブロック数 + Blue Blurriness 青のぼかし + Blue Curve ブルーカーブ + Blue Offset 青のオフセット + Blue Power 青のパワー + Blue Slope 青のスロープ + Blue-Blue 青-青 + Blue-Const 青-定数 + Blue-Green 青-緑 + Blue-Red 青-赤 + Blur & Sharpen ぼかし・シャープ + Blur Amount ぼかし量 + Blur Angle ぼかしの角度 + Blur Dimensions ぼかしの方向 + Blur Dissolve ぼかしディゾルブ + Blur Layer ぼかしレイヤー + Blur Length ぼかしの長さ + Blurriness ぼかしの量 + Bokeh Blur ボケ風ぼかし + Boost ブースト + Border 境界 + Border Color 境界線の色 + Border Width 境界線の幅 + Borders 境界 + Bottom Center 下中央 + Bottom Left 左下 + Bottom Right 右下 + Bounce バウンス + Branching 分岐 + Bright 明るめ + Bright Air 明るい空気感 + Bright Studio 明るいスタジオ + Brightness & Contrast 明るさ・コントラスト + British Stack ブリティッシュスタック + Brush Hardness ブラシの硬さ + Brush Opacity ブラシの不透明度 + Brush Position ブラシの位置 + Brush Size ブラシサイズ + Brush Spacing (secs) ブラシの間隔 (秒) + Brush Strokes ブラシストローク + Bubbles 泡 + Bulge 膨らみ + Bulge Smoother 膨らみ (より滑らか) + Bump Height バンプの高さ + Burn Alpha バーンアルファ + Burn Chroma バーンクロマ + Burn Color バーンの色 + Butt cap 平らな端 + Butterworth バターワース + CEA-608 CEA-608 + CEA-708 CEA-708 + Cache analysis results for re-use 再利用する解析結果のキャッシュ先 + Camera Angle カメラアングル + Camera Blur カメラぼかし + Camera Shake カメラの揺れ + Capitalisation 大文字化 + Cathedral 大聖堂 + Cell Pattern セルパターン + Center Channel Pan センターチャンネルのパン + Center Gradient 中央に配置 + Center Input センター入力 + Center Out 中央から外へ + Center Point (Latitude) 中心点 (緯度) + Center Point (Longitude) 中心点 (経度) + Center Split 中央分割 + Center of Sphere 球の中心 + Cents セント + Change to Color 指定色に変更 + Changes are copied in the background a moment after each edit. If FilmCraft quits unexpectedly, they are offered the next time it starts. 変更内容は、編集のたびに少し遅れてバックグラウンドでコピーされます。FilmCraft が予期せず終了した場合は、次回の起動時に復元できます。 + Channel チャンネル + Channel Blur チャンネルぼかし + Channel Mix チャンネルミックス + Channel Mixer チャンネルミキサー + Chaos カオス + Chebyshev チェビシェフ + Checker Wipe チェッカーワイプ + Checkerboard チェッカーボード + Chinese 中国語 + Choke チョーク + Chorus コーラス + Chorus/Flanger コーラス/フランジャー + Chroma クロマ + Chroma Leak クロマリーク + Chromatic Aberration (Blue) 色収差 (青) + Chromatic Aberration (Green) 色収差 (緑) + Chromatic Aberration (Red) 色収差 (赤) + Cineon Converter Cineon コンバーター + Circle 円形 + Clarity 明瞭度 + Clean Combo クリーンコンボ + Clean Up Noisy Dialogue ノイズの多いセリフをクリアに + Clear Muddiness こもりを解消 + Client クライアント + Clip クリップ + Clip Level クリッピングレベル + Clipping クリッピング + Clock Wipe クロックワイプ + Clock Wipe (Legacy) クロックワイプ (従来版) + Clockwise 時計回り + Clone クローン + Closed Captions クローズドキャプション + Color & Alpha カラーとアルファ + Color 1 カラー 1 + Color 2 カラー 2 + Color 3 カラー 3 + Color 4 カラー 4 + Color Balance カラーバランス + Color Burn 焼き込みカラー + Color Channel カラーチャンネル + Color Correction カラー補正 + Color Distortion 色のゆがみ + Color Dodge 覆い焼きカラー + Color Emboss カラーエンボス + Color Key カラーキー + Color Pass カラーパス + Color Replace 色の置換 + Color Space カラースペース + Color Theme カラーテーマ + Color To Leave 残す色 + Color Tolerance 色の許容値 + Color Wheels & Match カラーホイールとマッチ + Colorize カラー化 + Columns 列 + Comment コメント + Complexity 複雑さ + Composite In Back 背面に合成 + Composite In Front 前面に合成 + Composite Using 合成に使用 + Composite on Original 元の画像に合成 + Compound Blur 複合ぼかし + Compression Before Clipping クリッピング前の圧縮 + Constant Gain コンスタントゲイン + Constant Power コンスタントパワー + Conversion Type 変換の種類 + Convolution Reverb コンボリューションリバーブ + Cool Blue クールブルー + Cool, desaturated and punchy 寒色寄りで彩度を抑えた、メリハリのある色調 + Core Width コアの幅 + Corner コーナー + Corner Pin コーナーピン + Count 個数 + Counter-clockwise 反時計回り + Crash Recovery クラッシュからの復旧 + Crawl Left 左へクロール + Crawl Right 右へクロール + Create folder for imported projects 読み込んだプロジェクト用のフォルダーを作成する + Creative クリエイティブ + Crop クロップ + Crop Less <-> Smooth More クロップを減らす <-> 滑らかさを増す + Cross Displacement クロスディスプレイスメント + Cross Dissolve クロスディゾルブ + Cross Dissolve (Legacy) クロスディゾルブ (従来版) + Cross Zoom クロスズーム + Cross Zoom (Legacy) クロスズーム (従来版) + Crossfade クロスフェード + Crossovers クロスオーバー + Crystallize 結晶化 + Crystals 結晶 + Cube Spin キューブスピン + Curl Radius カールの半径 + Current Time 現在の時間 + Cursor カーソル + Curvature 曲率 + Curve カーブ + Curves カーブ + Custom カスタム + Custom Color カスタムカラー + Cutoff カットオフ + Cyan-teal shadows and warm highlights with a little more contrast シアン寄りのティールのシャドウと暖色のハイライトに、少し強めのコントラスト + Cycle (in Revolutions) 周期 (回転数) + Cycle Evolution 展開をループ + Damping ダンピング + Damping HF 高域ダンピング + Damping LF 低域ダンピング + Dark ダーク + Darken 比較 (暗) + Darker Color カラー比較 (暗) + Date Created 作成日 + Date Modified 変更日 + Day for night: darker, cooler and less saturated 疑似夜景: 暗めで寒色寄り、彩度を抑えた色調 + DeEsser ディエッサー + DeHummer ハムノイズ除去 + DeNoise ノイズ除去 + DeReverb リバーブ除去 + Decay 減衰 + Decay Time (RT60) 残響時間 (RT60) + Default Audio Tracks 既定のオーディオトラック + Default Emoji font 既定の絵文字フォント + Default Media Scaling 既定のメディアスケーリング + Default Output 既定の出力 + Default closed caption font 既定のクローズドキャプションフォント + Default language 既定の言語 + Default paragraph direction 既定の段落の方向 + Default subtitle and text font 既定の字幕・テキストフォント + Delay ディレイ + Delay 1 ディレイ 1 + Delay 2 ディレイ 2 + Delay 3 ディレイ 3 + Delay 4 ディレイ 4 + Delay and Echo ディレイとエコー + Delete files older than 次より古いファイルを削除 + Delete oldest files when the cache exceeds キャッシュが次のサイズを超えたら古いファイルから削除 + Delete… 削除… + Depth 深さ + Desaturate 彩度低減 + Description 説明 + Detail Amplitude ディテールの振幅 + Detail Level ディテールレベル + Detailed Analysis 詳細な分析 + Device Class デバイスクラス + Dialogue セリフ + Diamond ひし形 + Difference 差の絶対値 + Dimensions 方向 + Dimmed 暗め + Dip to Black 黒にディップ + Dip to Black (Legacy) 黒にディップ (従来版) + Dip to Color カラーにディップ + Dip to White 白にディップ + Dip to White (Legacy) 白にディップ (従来版) + Direction 方向 + Directional 平行光源 + Directional Blur 方向ぼかし + Directional Blur (Legacy) 方向ぼかし (従来版) + Disable video output when in the background バックグラウンド時はビデオ出力を無効にする + Disperse 分散 + Displacement ディスプレイスメント + Display Color Management ディスプレイのカラーマネジメント + Display Color Settings ディスプレイのカラー設定 + Display out of sync indicators for unlinked clips リンク解除したクリップの同期ずれインジケーターを表示 + Dissolve ディゾルブ + Dissolve Blocks ブロックをディゾルブ + Distance 距離 + Distance to Image 画像までの距離 + Distort ゆがみ + Distortion ディストーション + Distortion Complexity ゆがみの複雑さ + Distortion Rate ゆがみの速度 + Distortion Type ディストーションの種類 + Divide 除算 + Do not delete cache files automatically キャッシュファイルを自動的に削除しない + Don't label speakers 話者にラベルを付けない + Double-click ダブルクリック + Draft decoding at reduced playback resolution (H.264: faster, some frames less filtered) 再生解像度を下げたときのドラフトデコード (H.264: 高速化、一部のフレームはフィルター処理を軽減) + Drive ドライブ + Drop Shadow ドロップシャドウ + Dry ドライ + Dry Out ドライ出力 + Dry Up a Roomy Voice 響きの多い音声をドライに + Duck Deep Under Dialogue セリフに合わせて深くダッキング + Duck Under Dialogue セリフに合わせてダッキング + Duck Under Voice and Effects 音声と効果音に合わせてダッキング + Ducking ダッキング + Dutch オランダ語 + Dynamic Link ダイナミックリンク + Dynamics Processing ダイナミクス処理 + Early Reflections 初期反射 + Earthquake 地震 + Ease In and Out イーズイン・アウト + Easing イージング + Echo エコー + Echo Glow エコーグロー + Echo Operator エコーの合成方法 + Echo Time (seconds) エコー時間 (秒) + Echoes エコー数 + Edge Color エッジの色 + Edge Feather エッジのぼかし + Edge Glow エッジグロー + Edge Sharpness エッジのシャープさ + Edge Softness エッジの柔らかさ + Edge Thickness エッジの太さ + Edge Thin エッジの細さ + Edge Threshold エッジのしきい値 + Edge Type エッジの種類 + Edge Width エッジの幅 + Elliptical 楕円 + Emboss エンボス + Enable HSL Secondary HSL セカンダリを有効にする + Enable Mercury Transmit Mercury Transmit を有効にする + Enable ProRes hardware accelerated encoding, if available 可能な場合は、ハードウェアアクセラレーションによる ProRes エンコードを有効にする + Enable clip and XMP metadata linking クリップと XMP メタデータのリンクを有効にする + Enable developer mode 開発者モードを有効にする + Enable hardware accelerated decoding (requires restart) ハードウェアアクセラレーションによるデコードを有効にする (再起動が必要) + Enable language auto-detection 言語の自動検出を有効にする + Enable proxies プロキシを有効にする + End Color 終了色 + End Point 終点 + End of Ramp ランプの終了点 + Error エラー + Evolution 展開 + Evolution Options 展開オプション + Exciter Amount エキサイター量 + Exciter Mode エキサイターモード + Exclusion 除外 + Exponential Fade 指数フェード + Extended Dynamic Range Monitoring 拡張ダイナミックレンジのモニタリング + FFT Filter FFT フィルター + Fade フェード + Fade Out フェードアウト + Faded Matte フェードマット + Falloff 減衰 + Far Away 遠くで + Fast 高速 + Faster Motion より速い動き + Feedback フィードバック + Feedback 1 フィードバック 1 + Feedback 2 フィードバック 2 + Feedback 3 フィードバック 3 + Feedback 4 フィードバック 4 + Feet + Frames 16mm フィート + フレーム 16mm + Feet + Frames 35mm フィート + フレーム 35mm + Female 女性 + File Size ファイルサイズ + Fill Background 背景を塗りつぶし + Fill Color 塗りの色 + Fill Left with Right 右チャンネルを左にコピー + Fill Point 塗りつぶしの基点 + Fill Right with Left 左チャンネルを右にコピー + Fill Selector 塗りつぶしの基準 + Film Dissolve フィルムディゾルブ + Film Dissolve (Legacy) フィルムディゾルブ (従来版) + Film Roll フィルムロール + Filter フィルター + Filter Frequency フィルター周波数 + Filter Resonance フィルターレゾナンス + Filter and EQ フィルターと EQ + Final Delay 最終ディレイ + Find Edges 輪郭検出 + Flanger フランジャー + Flare フレア + Flare Brightness フレアの明るさ + Flare Center フレアの中心 + Flare Color フレアの色 + Flash フラッシュ + Flicker ちらつき + Flickers ちらつき回数 + Flip Motion フリップモーション + Flip Over フリップオーバー + Float 浮動小数点 + Focus フォーカス + Focus Blur フォーカスぼかし + Focus Point フォーカスポイント + Focus Shape フォーカスの形状 + Focus Size フォーカスのサイズ + Fold Motion フォールドモーション + Foldback フォールドバック + For color management parameters, go to the Settings tab in the Lumetri Color panel. カラーマネジメントのパラメーターは、Lumetri カラーパネルの「設定」タブにあります。 + Format 形式 + Four Scopes (RGB, 8 bit) 4 つのスコープ (RGB、8 bit) + Four Scopes (YUV, float, no clamp) 4 つのスコープ (YUV、浮動小数点、クランプなし) + Fractal Influence フラクタルの影響 + Fractal Type フラクタルの種類 + Frame フレーム + Frame Color フレームの色 + Frame Count フレームカウント + Frame Layout フレームレイアウト + Frame Rate フレームレート + Frame Width フレームの幅 + Frame cache for monitors and thumbnails モニターとサムネイル用のフレームキャッシュ + Frames フレーム + Framing フレーミング + French フランス語 + Frequency 周波数 + From 変更元 + From East 右から + From North 上から + From South 下から + From West 左から + From the Left 左から + From the Right 右から + Front + LFE フロント + LFE + Front + Rear フロント + リア + Front + Rear + LFE フロント + リア + LFE + Front Only フロントのみ + Fuzz ファズ + GB GB + Gamma ガンマ + Gamma Correction ガンマ補正 + Gamut Warning 色域外警告 + Gamut Warning Color 色域外警告の色 + Gap ギャップ + Gap Color ギャップの色 + Gaussian Blur ガウスぼかし + Gaussian Blur (Legacy) ガウスぼかし (従来版) + General 一般 + Generate 生成 + Generate Timecode タイムコードを生成 + Generate waveforms and conform audio using multithreading マルチスレッドを使用して波形の生成とオーディオのコンフォームを行う + Generate waveforms automatically during import 読み込み時に波形を自動的に生成する + German ドイツ語 + Ghosts ゴースト数 + Glass ガラス + Glint きらめき + Glitch グリッチ + Glow グロー + Glow Brightness グローの明るさ + Glow Color グローの色 + Glow Only グローのみ + Glow Radius グローの半径 + Glow Saturation グローの彩度 + Glow Width グローの幅 + Golden Hour ゴールデンアワー + Good 良好 + Gradient グラデーション + Gradient End グラデーションの終点 + Gradient Layer グラデーションレイヤー + Gradient Placement グラデーションの配置 + Gradient Scatter グラデーションの拡散 + Gradient Shape グラデーションの形状 + Gradient Start グラデーションの始点 + Gradient Wipe グラデーションワイプ + Gradient Wipe (Legacy) グラデーションワイプ (従来版) + Grain Size グレインのサイズ + Graphic Equalizer (10 Bands) グラフィックイコライザー (10 バンド) + Graphic Equalizer (20 Bands) グラフィックイコライザー (20 バンド) + Graphic Equalizer (30 Bands) グラフィックイコライザー (30 バンド) + Graphics グラフィックス + Green Blurriness 緑のぼかし + Green Curve グリーンカーブ + Green Offset 緑のオフセット + Green Power 緑のパワー + Green Slope 緑のスロープ + Green-Blue 緑-青 + Green-Const 緑-定数 + Green-Green 緑-緑 + Green-Red 緑-赤 + Grid グリッド + Grow 拡大 + Growing Files 書き込み中のファイル + Grunge グランジ + Grunge & Distort グランジとゆがみ + GuitarSuite ギタースイート + HDR Graphics White (Nits) HDR グラフィックスのホワイトレベル (Nits) + HLS Vectorscope + Histogram HLS ベクトルスコープ + ヒストグラム + HSL Secondary HSL セカンダリ + Hard ハード + Hard Clip ハードクリップ + Hard Light ハードライト + Hard Limiter ハードリミッター + Hard Mix ハードミックス + Hardware decoding ハードウェアデコード + Hardware decoding: Auto uses the system's video decoder (VideoToolbox on macOS, Media Foundation on Windows, VA-API for H.264 and HEVC on Linux) for the streams it supports, and FilmCraft's own decoder for everything else or if the hardware fails. Media that is already open keeps its decoder until it is reopened. ハードウェアデコード: 「自動」では、対応しているストリームにはシステムのビデオデコーダー (macOS では VideoToolbox、Windows では Media Foundation、Linux では H.264・HEVC 用の VA-API) を使用し、それ以外のストリームや、ハードウェアで失敗した場合は FilmCraft 独自のデコーダーを使用します。すでに開いているメディアは、開き直すまで現在のデコーダーを使い続けます。 + Head Size 頭部サイズ + Heart ハート + Height 高さ + Hexagon 六角形 + Hide Back Face 裏面を非表示 + High 高 + High Cutoff 高域カットオフ + High Frequency Cut 高域カット + High Frequency Damping 高域ダンピング + High Pass ハイパス + High Shelf ハイシェルフ + High Shelf Gain ハイシェルフゲイン + High contrast with held highlights and deep shadows ハイライトを保ちつつシャドウを深く沈めた、高コントラストの色調 + Highlight ハイライト + Highlight Blue Balance ハイライトのブルーバランス + Highlight Brightness ハイライトの明るさ + Highlight Color ハイライトカラー + Highlight Green Balance ハイライトのグリーンバランス + Highlight Red Balance ハイライトのレッドバランス + Highlight Rolloff ハイライトのロールオフ + Highlight Threshold ハイライトのしきい値 + Highlight Tint ハイライトの色合い + Highlights Lightness ハイライトの明度 + Highpass ハイパス + Hindi ヒンディー語 + Hindi digits ヒンディー数字 + Horizontal 16:9 横長 16:9 + Horizontal Alignment 水平方向の整列 + Horizontal Blocks 水平ブロック数 + Horizontal Decentering 水平方向の偏心 + Horizontal Displacement 水平ディスプレイスメント + Horizontal Flip 左右反転 + Horizontal Margin 水平方向の余白 + Horizontal and Vertical 水平と垂直 + Hue Center 色相の中心 + Hue Tolerance 色相の許容値 + I/O Buffer Size I/O バッファーサイズ + Ignore Alpha アルファを無視 + Image Control 画像コントロール + Immersive Video イマーシブビデオ + Import numbered stills as image sequences 連番の静止画を画像シーケンスとして読み込む + Impulse インパルス + In a Room 部屋の中で + In the Media Cache メディアキャッシュ内 + In the project プロジェクト内 + In/Out Duration イン/アウト間の長さ + Include captions on import 読み込み時にキャプションを含める + Incoming 次のクリップ + Indeterminate Media Timebase 不定メディアのタイムベース + Initial Delay 初期ディレイ + Inner 内側 + Inner Radius 内側の半径 + Input Boost 入力ブースト + Inset インセット + Inside 内側 + Inside Color 内側の色 + Inter Inter + Interior Room Tone 屋内のルームトーン + Internal Black Point 内部ブラックポイント + Internal White Point 内部ホワイトポイント + Interpolation 補間 + Intersect 交差 + Inverse Falloff 減衰を反転 + Invert 反転 + Invert Alpha アルファを反転 + Invert Blur ぼかしを反転 + Invert Fill 塗りつぶしを反転 + Invert Gradient グラデーションを反転 + Invert Left 左の位相を反転 + Invert Right 右の位相を反転 + Iris アイリス + Iris Box 四角形アイリス + Iris Cross 十字アイリス + Iris Diamond ひし形アイリス + Iris Radius アイリスの半径 + Iris Rotation アイリスの回転 + Iris Round 円形アイリス + Iris Shape アイリスの形状 + Italian イタリア語 + Japanese 日本語 + JetBrains Mono JetBrains Mono + Join Through Edits スルー編集点を結合 + Kaleidoscope 万華鏡 + Keep a recovery copy of unsaved changes 未保存の変更の復旧用コピーを保持する + Key Color キーカラー + Keying キーイング + Korean 韓国語 + Label Defaults 既定のラベル + Label speakers 話者にラベルを付ける + Labels ラベル + Large Hall 大きなホール + Large Trim Offset 大幅トリムのオフセット + Large Volume Adjustment 大幅な音量調整 + Latch ラッチ + Layer Name レイヤー名 + Layers レイヤー + Leave Color 色を残す + Left - Left 左 - 左 + Left - Right 左 - 右 + Left channel to device output 左チャンネルを割り当てるデバイス出力 + Left to Right 左から右へ + Left to right 左から右 + Legacy 従来版 + Length 長さ + Lens Blur レンズぼかし + Lens Distortion レンズゆがみ + Lens Flare レンズフレア + Lens Type レンズの種類 + Level 1 レベル 1 + Level 2 レベル 2 + Level 3 レベル 3 + Level 4 レベル 4 + Levels レベル + Lifted blacks, softer contrast, muted colour 黒を浮かせ、コントラストを弱めた、くすんだ色調 + Light 1 光源 1 + Light 2 光源 2 + Light 3 光源 3 + Light 4 光源 4 + Light 5 光源 5 + Light Angle 光の角度 + Light Color 光の色 + Light Intensity 光の強さ + Light Leak 光漏れ + Light Leaks 光漏れ + Light Sweep ライトスイープ + Light Type 光源の種類 + Lighten 比較 (明) + Lighter Color カラー比較 (明) + Lighting Effects 照明エフェクト + Lightning 稲妻 + Lights & Blurs 光とぼかし + Lights & Glows 光とグロー + Line Thickness 線の太さ + Linear Burn 焼き込み (リニア) + Linear Dodge (Add) 覆い焼き (リニア) - 加算 + Linear Light リニアライト + Linear Ramp リニアランプ + Linear Wipe リニアワイプ + Linear Wipe (Legacy) リニアワイプ (従来版) + Linear keyframe thinning リニアキーフレームの間引き + Linear to Log リニアからログ + Liquid Distortion 液状ゆがみ + Local Projects ローカルプロジェクト + Location 場所 + Log Note ログメモ + Log to Linear ログからリニア + Log to Log ログからログ + Logo Cutout ロゴの切り抜き + Long Shadow ロングシャドウ + Look-Ahead Time 先読み時間 + Loudness ラウドネス + Loudness Maximizer ラウドネスマキシマイザー + Loudness Meter ラウドネスメーター + Louver ルーバー + Low 低 + Low Frequency Cut 低域カット + Low Pass ローパス + Low Shelf ローシェルフ + Low Shelf Gain ローシェルフゲイン + Lower Left 左下 + Lower Right 右下 + Lowpass ローパス + Luma 輝度 + Luma Curve 輝度カーブ + Luma Fade 輝度フェード + Luma Key 輝度キー + Luma Source 輝度のソース + Luma and Chroma 輝度とクロマ + Lumetri Presets Lumetri プリセット + Luminance 輝度 + Luminance Threshold 輝度のしきい値 + Luminosity 輝度 + MB MB + Magenta マゼンタ + Magnify 拡大鏡 + Magnify (Legacy) 拡大鏡 (従来版) + Maintain pitch while shuttling シャトル再生時にピッチを維持する + Major Radius 長半径 + Male 男性 + Mango マンゴー + Map Black To 黒に割り当てる色 + Map White To 白に割り当てる色 + Maps full-range levels into 16–235 (video range) with a luma curve 輝度カーブで、フルレンジのレベルを 16–235 (ビデオレンジ) に収めます + Mask Only マスクのみ + Master Amplitude マスター振幅 + Mastering マスタリング + Match frame sets in point マッチフレームでイン点を設定する + Matte マット + Matte Alpha マットのアルファ + Matte Cleanup マットのクリーンアップ + Matte Generation マットの生成 + Matte Luma マットの輝度 + Maximum 最大値 + Maximum Amplitude 最大振幅 + Maximum Block Size 最大ブロックサイズ + Maximum Blur 最大ぼかし + Maximum Project Versions プロジェクトの最大バージョン数 + Maximum Reduction 最大低減量 + Maximum Scale 最大スケール + Media メディア + Media Analysis メディア解析 + Media Analysis & Transcription メディア解析と文字起こし + Media Cache メディアキャッシュ + Media Cache Database メディアキャッシュデータベース + Media Cache Files メディアキャッシュファイル + Media Cache Management メディアキャッシュの管理 + Media Duration メディアの長さ + Media End メディア終了 + Media Time メディア時間 + Media Timecode メディアのタイムコード + Media Type メディアの種類 + Media analysis and transcriptions will be cached in the Media Cache folder. Reanalysis won't be required if the media is imported into another project on this computer. メディアの解析結果と文字起こしは、メディアキャッシュフォルダーにキャッシュされます。このコンピューター上の別のプロジェクトにメディアを読み込んだ場合でも、再解析は必要ありません。 + Median 中央値 + Medium Room 中くらいの部屋 + Memory メモリー + Metadata & Timecode Burn-in メタデータとタイムコードの焼き込み + Method 方法 + Metrics Kerning メトリクスカーニング + Mid 中 + Mid Point 中間点 + Midtone Blue Balance 中間調のブルーバランス + Midtone Green Balance 中間調のグリーンバランス + Midtone Red Balance 中間調のレッドバランス + Midtones Lightness 中間調の明度 + Minimum 最小値 + Minimum time 最小時間 + Minimum time interval thinning 最小時間間隔での間引き + Minor Radius 短半径 + Mirror ミラー + Mirror Alternate 交互にミラー + Missing font replacement 見つからないフォントの代替 + Miter join とがった角 + Miter limit マイター制限 + Mixed Crystals 混合結晶 + Mode モード + Modern High Gain モダンハイゲイン + Modulation モジュレーション + Modulation Rate モジュレーションレート + Mono Media モノラルメディア + Monochrome モノクロ + Monochrome toned cyan-blue シアンブルーで調色したモノクロ + Monochrome toned warm brown 温かみのあるブラウンで調色したモノクロ + Monoscopic モノスコピック + Monospace only 等幅フォントのみ + More colour in the less saturated areas 彩度の低い部分の色を強調 + Morph Cut モーフカット + Mosaic モザイク + Mosaic (Legacy) モザイク (従来版) + Motion Blur モーションぼかし + Motion Camera モーションカメラ + Motion Preset モーションプリセット + Motion Tween モーショントゥイーン + Move 移動 + Movie ムービー + Movie (audio and video) ムービー (オーディオとビデオ) + Multiband Compressor マルチバンドコンプレッサー + Multichannel Mono Media マルチチャンネルのモノラルメディア + Multiply 乗算 + Multitap Delay マルチタップディレイ + Music 音楽 + Mute input during timeline recording タイムラインでの録音中は入力をミュートする + Name 名前 + Narrow 狭い + Neon Color ネオンの色 + Neon Wipe ネオンワイプ + Next to original media files 元のメディアファイルと同じ場所 + Night 夜 + No Motion 動きなし + No Scroll スクロールなし + No plugins are installed. Changes will take effect the next time you start FilmCraft. プラグインはインストールされていません。変更は次回 FilmCraft の起動時に反映されます。 + Noise ノイズ + Noise & Grain ノイズ・グレイン + Noise (Legacy) ノイズ (従来版) + Noise Level ノイズレベル + Noise Reduction/Restoration ノイズ除去/修復 + Noise Type ノイズの種類 + Non-Additive Dissolve 非加算ディゾルブ + Normal 通常 + Notch 1 ノッチ 1 + Notch 2 ノッチ 2 + Notch 3 ノッチ 3 + Notch 4 ノッチ 4 + Notch 5 ノッチ 5 + Notch 6 ノッチ 6 + Notch Filter ノッチフィルター + Notch Width ノッチ幅 + Number Of Echoes エコーの数 + Number of Harmonics 倍音の数 + Obsolete 旧式 + Octagon 八角形 + Offset オフセット + Offset (Turbulence) オフセット (乱流) + Offset From 開始オフセット + Offset To 終了オフセット + Omni 全方向光源 + On オン + On Original Image 元の画像上 + On Transparent 透明背景上 + Open Air 屋外 + Open Most Recent 前回のプロジェクトを開く + Open in new window 新しいウィンドウで開く + Open in place その場で開く + Open new tab 新しいタブで開く + Opens up the shadows シャドウを明るくします + Order オーダー + Orientation 向き + Outer 外側 + Outgoing 前のクリップ + Output 出力 + Output Mapping 出力マッピング + Outside 外側 + Outside Color 外側の色 + Overflow オーバーフロー + Overlay オーバーレイ + Overshoot オーバーシュート + Page Peel ページピール + Page Scroll ページスクロール + Page Turn ページめくり + Paint Bucket ペイントバケツ + Paint On Black 黒に描画 + Paint On Original Image 元の画像に描画 + Paint On Transparent 透明背景に描画 + Paint On White 白に描画 + Paint Style 描画スタイル + Paint Surface 描画面 + Pan (Y Axis) パン (Y 軸) + Panel Wipe パネルワイプ + Panels パネル数 + Panner - Ambisonics パンナー - アンビソニックス + Parade パレード + Parade (RGB) パレード (RGB) + Parametric Equalizer パラメトリックイコライザー + Passband Ripple 通過帯域リップル + Path パス + Path Points パスのポイント + Pause Media Encoder queue during playback 再生中は Media Encoder のキューを一時停止する + Peak Frequency ピーク周波数 + Peak Gain ピークゲイン + Pedestal ペデスタル + Pentagon 五角形 + Per line 行ごと + Percent Blur ぼかし率 + Perspective 遠近 + Phase Difference 位相差 + Phaser フェイザー + Phone Call 電話越しの声 + Phosphor 蛍光体 + Phosphor Color 蛍光体の色 + Photocopy コピー機風 + Photocopy Color コピー機風 (カラー) + Pillow ピロー + Pin All すべて固定 + Pin Horizontal 水平方向を固定 + Pin Light ピンライト + Pin Vertical 垂直方向を固定 + Pinning 固定 + Pitch Shifter ピッチシフター + Plate プレート + Plateau Wipe プラトーワイプ + Plateaus プラトー数 + Plates プレート + Play after rendering previews プレビューのレンダリング後に再生する + Play audio while scrubbing in Source and Program Monitors ソースモニターとプログラムモニターでのスクラブ中にオーディオを再生する + Playback 再生 + Playhead position determines trim monitor loop playback トリムモニターのループ再生で再生ヘッドの位置を基準にする + Plugins プラグイン + Podcast Voice ポッドキャスト向けの声 + Point 1 ポイント 1 + Point 1 Color ポイント 1 の色 + Point 1 Latitude ポイント 1 の緯度 + Point 1 Longitude ポイント 1 の経度 + Point 2 ポイント 2 + Point 2 Color ポイント 2 の色 + Point 2 Latitude ポイント 2 の緯度 + Point 2 Longitude ポイント 2 の経度 + Point 3 ポイント 3 + Point 3 Color ポイント 3 の色 + Point 3 Latitude ポイント 3 の緯度 + Point 3 Longitude ポイント 3 の経度 + Point 4 ポイント 4 + Point 5 ポイント 5 + Point 6 ポイント 6 + Point 7 ポイント 7 + Point 8 ポイント 8 + Points 頂点数 + Policy ポリシー + Pop Motion ポップモーション + Portuguese ポルトガル語 + Position & Rotation 位置・回転 + Position Amount 移動量 + Position, Scale & Rotation 位置・スケール・回転 + Position, Scale, Rotation 位置・スケール・回転 + Posterize ポスタライズ + Posterize Time 時間のポスタライズ + Pre-Delay プリディレイ + Prefix 接頭辞 + Presence Lift プレゼンスを強調 + Preserve Luminosity 輝度を保持 + Preserve Scale スケールを保持 + ProcAmp プロセスアンプ + Projecting cap 突き出た端 + Projects プロジェクト + Pull Motion プルモーション + Pulls highlights and whites down ハイライトとホワイトを抑えます + Push プッシュ + Push (Legacy) プッシュ (従来版) + Q Q + RAM reserved for other applications 他のアプリケーション用に確保する RAM + RGB RGB + RGB Split RGB 分割 + RGB-White RGB (白) + Radial 円形 + Radial Blur 放射状ぼかし + Radial Ramp 放射状ランプ + Radial Wipe 放射状ワイプ + Radial Wipe (Legacy) 放射状ワイプ (従来版) + Radio Announcer ラジオアナウンサー + Radio Voice ラジオ風の声 + Radius 半径 + Ramp ランプ + Ramp Shape ランプの形状 + Random ランダム + Random Seed ランダムシード + Range 範囲 + Ray 光線 + Ray Color 光線の色 + Ray Length 光線の長さ + Rays 光線数 + Rays Only 光線のみ + Read 読み取り + Rec. 2100 Rec. 2100 + Rec. 601 Rec. 601 + Rec. 709 Rec. 709 + Red Blurriness 赤のぼかし + Red Curve レッドカーブ + Red Offset 赤のオフセット + Red Power 赤のパワー + Red Slope 赤のスロープ + Red-Blue 赤-青 + Red-Const 赤-定数 + Red-Green 赤-緑 + Red-Red 赤-赤 + Reduce Axis 低減の対象 + Reduce Interlace Flicker インターレースのちらつきを軽減 + Reflected 反射形 + Reflection Angle 反射の角度 + Reflection Center 反射の中心 + Refraction 屈折 + Reframe Offset リフレームのオフセット + Reframe Zoom リフレームのズーム + Refresh growing Files Every 書き込み中のファイルの更新間隔 + Relaxed 控えめ + Release Time リリース時間 + Relief レリーフ + Remaining 残り時間 + Remove Fringe フリンジを除去 + Render Edit in Audition files to: Audition で編集するファイルのレンダリング先: + Render audio when rendering video ビデオのレンダリング時にオーディオもレンダリングする + Repair 修復 + Repeat Edge Pixels エッジピクセルを繰り返す + Replace Color 置換後の色 + Replace With Clip From Source Monitor ソースモニターのクリップで置換 + Replicate 複製 + Responsive Design - Position レスポンシブデザイン - 位置 + Responsive Design - Time レスポンシブデザイン - 時間 + Restore open sequences when opening projects プロジェクトを開くときに、開いていたシーケンスを復元する + Result 結果 + Retro レトロ + Reveal Nested Sequence ネストされたシーケンスを表示 + Reveal Original Image 元の画像を表示 + Reveal in Project プロジェクトで表示 + Reverb Amount リバーブ量 + Reverse 反転 + Right - Left 右 - 左 + Right - Right 右 - 右 + Right channel to device output 右チャンネルを割り当てるデバイス出力 + Right to left 右から左 + Ripple trim adds edits to keep both sides of trim in sync リップルトリムで編集点を追加し、トリムの両側の同期を保つ + Roll (Z Axis) ロール (Z 軸) + Room Size ルームサイズ + Rotation Amount 回転量 + Roughen 粗い + Roughen Color 粗い (カラー) + Roughen Edges エッジを粗く + Round cap 丸い端 + Round join 丸い角 + Rounded Crop 角丸クロップ + Rows 行 + Russian ロシア語 + Rusty さびた + Rusty Color さびた (カラー) + SFX 効果音 + Sample Rate サンプルレート + Saturation at 50 % 彩度 50 % + Save .cfa and .pek media cache files next to original media files when possible 可能な場合は、.cfa と .pek のメディアキャッシュファイルを元のメディアファイルと同じ場所に保存する + Scale (Degrees) スケール (度) + Scale Amount スケール量 + Scale From 開始スケール + Scale Height 高さのスケール + Scale Match スケール合わせ + Scale To 終了スケール + Scale Width 幅のスケール + Scale to frame size フレームサイズに合わせてスケール + Scan Lines 走査線 + Scene シーン + Scientific Filter サイエンティフィックフィルター + Scratch disk location for Captured Audio キャプチャしたオーディオのスクラッチディスクの場所 + Screen スクリーン + Seconds 秒 + Segments セグメント数 + Semi-tones 半音 + Sequence Name シーケンス名 + Sequence Timecode シーケンスのタイムコード + Set focus on the Timeline when performing Insert/Overwrite edits 挿入/上書き編集の実行時にタイムラインにフォーカスを移す + Set to frame size フレームサイズに設定 + Setting 設定 + Shadow Angle シャドウの角度 + Shadow Blue Balance シャドウのブルーバランス + Shadow Blur シャドウのぼかし + Shadow Color シャドウの色 + Shadow Distance シャドウの距離 + Shadow Green Balance シャドウのグリーンバランス + Shadow Only シャドウのみ + Shadow Opacity シャドウの不透明度 + Shadow Red Balance シャドウのレッドバランス + Shadow Size シャドウのサイズ + Shadow Tint シャドウの色合い + Shadows Lightness シャドウの明度 + Shake 揺れ + Shape Dissolve シェイプディゾルブ + Shape Flow シェイプフロー + Shape Size シェイプのサイズ + Shapes シェイプ + Shard Size 破片のサイズ + Sharp Colors シャープな色 + Sharpen Amount シャープの量 + Shift Center To 中心の移動先 + Shift clips that overlap trim point during ripple trimming リップルトリム時に、トリムポイントに重なるクリップをずらす + Shot ショット + Show "Project:" and "Bin:" in Project panel tab プロジェクトパネルのタブに「プロジェクト:」と「ビン:」を表示 + Show Clip Mismatch Warning dialog クリップの不一致警告ダイアログを表示 + Show Event Indicator イベントインジケーターを表示 + Show Focus Area フォーカス範囲を表示 + Show Home ホームを表示 + Show Mask Tracker Preview マスクトラッカーのプレビューを表示 + Show Only Noise ノイズのみ表示 + Show Open Dialog 「開く」ダイアログを表示 + Show Project Load Error Dialog プロジェクトの読み込みエラーダイアログを表示 + Show Rich Tool Tips 詳細なツールヒントを表示 + Show Tool Tips ツールヒントを表示 + Show Workspace Reset Warning dialog on double click ダブルクリック時にワークスペースのリセット警告ダイアログを表示 + Show system compatibility issues at startup 起動時にシステムの互換性の問題を表示 + Shrink 縮小 + Shutter Angle シャッター角度 + Similarity 類似度 + Simple Notch Filter 簡易ノッチフィルター + Simple Parametric EQ 簡易パラメトリック EQ + Simple Text シンプルテキスト + Single-band Compressor シングルバンドコンプレッサー + Sinusoidal 正弦波 + Skew 傾斜 + Skew Axis 傾斜の軸 + Slats 羽根の数 + Slice スライス + Slices スライス数 + Slide スライド + Slide (Legacy) スライド (従来版) + Slope スロープ + Slow 低速 + Slower Motion より遅い動き + Small Room 小さな部屋 + Smart Limit スマート制限 + Smart Tools スマートツール + Smart quotes スマート引用符 + Smooth スムーズ + Smooth Motion 滑らかな動き + Smooth Scroll スムーズスクロール + Smoothness 滑らかさ + Snap playhead in Timeline when Snap is enabled スナップが有効なときにタイムラインで再生ヘッドをスナップする + Soft ソフト + Soft Clamp ソフトクランプ + Soft Edges (Best Quality) ソフトエッジ (最高画質) + Soft Light ソフトライト + Soft Wipe ソフトワイプ + Soften Harshness きつさを和らげる + Softness 柔らかさ + Solarize ソラリゼーション + Solid Colors 単色 + Source Point ソースポイント + Source Text ソーステキスト + Spacer スペーサー + Spanish スペイン語 + Speaker Angle スピーカー角度 + Speaker Labeling 話者のラベル付け + Special 特殊 + Special Effects 特殊エフェクト + Speech model 音声認識モデル + Speed 速度 + Spherize 球面 + Spiky とげ状 + Spill スピル + Spill Suppression スピル抑制 + Spin スピン + Spin Direction 回転方向 + Spin Motion スピンモーション + Split 分割 + Split (Legacy) 分割 (従来版) + Spotlight スポットライト + Spread スプレッド + Spring Motion スプリングモーション + Square 正方形 + Square (Width Only) 正方形 (幅のみ) + Square 1:1 正方形 1:1 + Stabilization 手ぶれ補正 + Stabilize Only 手ぶれ補正のみ + Stabilize, Crop 手ぶれ補正、クロップ + Stabilize, Crop, Auto-scale 手ぶれ補正、クロップ、自動スケール + Stabilize, Synthesize Edges 手ぶれ補正、エッジを合成 + Stages ステージ数 + Star 星形 + Star Wipe 星形ワイプ + Start Angle 開始角度 + Start Color 開始色 + Start Point 始点 + Start at 0 0 から開始 + Start at 1 1 から開始 + Start of Ramp ランプの開始点 + Start with an Empty Project 空のプロジェクトで開始 + Starting Intensity 開始時の強さ + Static Plates 静止プレート + Step forward/back many 複数フレーム単位で前後にステップ + Stereo Expand ステレオ拡張 + Stereo Expander ステレオエキスパンダー + Stereo Imagery ステレオイメージ + Stereo Media ステレオメディア + Stereo Phasing ステレオ位相 + Stereoscopic - Over/Under ステレオスコピック - 上下 + Stereoscopic - Side by Side ステレオスコピック - 左右 + Still 静止画 + Still Image Default Duration 静止画の既定の長さ + Stone Church 石造りの教会 + Stopband Attenuation 阻止帯域減衰量 + Straight Color カラーのみ + Strength 強さ + Stretch 引き伸ばし + Stretch Gradient to Fit 全体に合わせて伸縮 + Stretch Map to Fit マップを伸縮して合わせる + Stretch Width or Height 幅または高さの伸縮 + Stretch Wipe 引き伸ばしワイプ + Stretch to Fit Frame フレームに合わせて伸縮 + Stripe ストライプ + Stripes ストライプ数 + Strobe Color ストロボの色 + Strobe Duration (secs) ストロボの長さ (秒) + Strobe Light ストロボライト + Strobe Period (secs) ストロボの周期 (秒) + Stroke 2 Color 線 2 の色 + Stroke 2 Type 線 2 の位置 + Stroke 2 Width 線 2 の幅 + Stroke Angle ストロークの角度 + Stroke Color 線の色 + Stroke Density ストロークの密度 + Stroke Length ストロークの長さ + Stroke Length (secs) ストロークの長さ (秒) + Stroke Only ストロークのみ + Stroke Randomness ストロークのランダムさ + Stroke Type 線の位置 + Stroke Width 線の幅 + Stroke line cap 線の端の形状 + Stroke line join 線の角の形状 + Studio Reverb スタジオリバーブ + Stylize スタイライズ + Subclip End サブクリップの終了 + Subclip Start サブクリップの開始 + Subspace Warp サブスペースワープ + Subtitle 字幕 + Subtract 減算 + Sum multichannel outputs to mono in Source Monitor ソースモニターでマルチチャンネル出力をモノラルにミックスダウンする + Super Narrow 極めて狭い + Surface Gloss 表面の光沢 + Surface Material 表面の材質 + Surround Reverb サラウンドリバーブ + Swap 左右入れ替え + Swap Channels チャンネルを入れ替え + Swivel スイベル + Synthetic 生成メディア + TV Power TV 電源 + Tape テープ + Tape Name テープ名 + Tape/Tube テープ/真空管 + Target Aspect 目標のアスペクト比 + Target Color 対象の色 + Target Field of View 目標の視野角 + Target Loudness 目標ラウドネス + Teal & Orange ティール&オレンジ + Telephone 電話風 + Teletext テレテキスト + Template Properties テンプレートのプロパティ + Text Animator テキストアニメーター + Text Color テキストの色 + The media cache holds render previews of unsaved projects and other files FilmCraft can recreate. Deleting them is always safe; FilmCraft rebuilds them as needed. メディアキャッシュには、未保存のプロジェクトのレンダリングプレビューなど、FilmCraft が再作成できるファイルが保存されています。これらはいつ削除しても問題ありません。FilmCraft が必要に応じて再構築します。 + Thickness 太さ + Tile Gradient タイル状に配置 + Tilt チルト + Tilt (X Axis) チルト (X 軸) + Time 時間 + Time and Pitch 時間とピッチ + Timecode Conversion タイムコード変換 + Timeline Mouse Scrolling タイムラインのマウススクロール + Timeline Playback Auto-Scrolling タイムライン再生時の自動スクロール + Tint Color 色合い + To 変更先 + Tolerance 許容値 + Tone トーン + Top Center 上中央 + Top Left 左上 + Top Right 右上 + Touch タッチ + Track Matte Key トラックマットキー + Transcription 文字起こし + Transcription preferences 文字起こしの設定 + Transform 変形 + Transformers 変形 + Transience トランジェント + Transition Completion トランジションの完了率 + Transition Softness トランジションの柔らかさ + Transparency 透明度 + Trash 汚し + Travel Motion トラベルモーション + Treble 高音 + Triangle 三角形 + Trim トリム + Tube 真空管 + Tube-modeled Compressor 真空管モデリングコンプレッサー + Tubular 管状 + Turbulent 乱流 + Turbulent Displace 乱流ディスプレイス + Turbulent Sharp 乱流 (シャープ) + Turbulent Smooth 乱流 (滑らか) + Turbulent Smoother 乱流 (より滑らか) + Tweed ツイード + Twirl 渦巻き + Twirl (Legacy) 渦巻き (従来版) + Twirl Center 渦巻きの中心 + Twirl Radius 渦巻きの半径 + Twist ねじり + Twist Smoother ねじり (より滑らか) + Typewriter タイプライター + Ultra Key ウルトラキー + Uniform Scale 縦横比を固定 + Uniform Spacing 等間隔 + Unsharp Mask アンシャープマスク + Update it at least every 更新間隔の上限 + Update timeline dynamically during ripple edits. リップル編集中にタイムラインを動的に更新する + Upper Frequency 上限周波数 + Upper Left 左上 + Upper Right 右上 + Use Color Noise カラーノイズを使用 + Use End Color 終了色を使用 + Use File ファイルに従う + Use Glow Color グローの色を使用 + Use Media Source メディアソースを使用 + Use Tint Color 色合いを使用 + Utility ユーティリティ + VHS Damage VHS ダメージ + VR Blur VR ぼかし + VR Chroma Leaks VR クロマリーク + VR Chromatic Aberrations VR 色収差 + VR Color Gradients VR カラーグラデーション + VR De-Noise VR ノイズ除去 + VR Digital Glitch VR デジタルグリッチ + VR Fractal Noise VR フラクタルノイズ + VR Glow VR グロー + VR Gradient Wipe VR グラデーションワイプ + VR Iris Wipe VR アイリスワイプ + VR Light Leaks VR ライトリーク + VR Light Rays VR 光線 + VR Mobius Zoom VR メビウスズーム + VR Plane to Sphere VR 平面から球面へ + VR Projection VR 投影 + VR Random Blocks VR ランダムブロック + VR Rotate Sphere VR 球面の回転 + VR Sharpen VR シャープ + VR Spherical Blur VR 球面ぼかし + Validate Content Credentials on import 読み込み時にコンテンツクレデンシャルを検証する + Vectorscope (YUV) ベクトルスコープ (YUV) + Vectorscope + Waveform YC ベクトルスコープ + YC 波形 + Vectorscope HLS ベクトルスコープ HLS + Vectorscope YUV ベクトルスコープ YUV + Venetian Blinds ベネチアンブラインド + Vertical 4:5 縦長 4:5 + Vertical 9:16 縦長 9:16 + Vertical Alignment 垂直方向の整列 + Vertical Blocks 垂直ブロック数 + Vertical Decentering 垂直方向の偏心 + Vertical Displacement 垂直ディスプレイスメント + Vertical Flip 上下反転 + Vertical Margin 垂直方向の余白 + Very Narrow とても狭い + Video 1 ビデオ 1 + Video 2 ビデオ 2 + Video 3 ビデオ 3 + Video 4 ビデオ 4 + Video 5 ビデオ 5 + Video 6 ビデオ 6 + Video 7 ビデオ 7 + Video 8 ビデオ 8 + Video Codec ビデオコーデック + Video Duration ビデオの長さ + Video Effects ビデオエフェクト + Video In Point ビデオのイン点 + Video Info ビデオ情報 + Video Limiter ビデオリミッター + Video Out Point ビデオのアウト点 + Video Transition Default Duration ビデオトランジションの既定の長さ + Video Transitions ビデオトランジション + Video Usage ビデオの使用状況 + Vignette ビネット + Vivid Light ビビッドライト + Vocal Booth ボーカルブース + Vocal Enhancer ボーカルエンハンサー + Voice 音声 + Voice in a Hall ホールに響く声 + Volumetric Rays ボリューメトリック光線 + Warm Film ウォームフィルム + Warm Room 温かみのある部屋 + Warm Voice 温かみのある声 + Warm print stock with gentle roll-off なだらかなロールオフを持つ、暖かみのあるプリントフィルム調 + Warm, soft late-afternoon light 夕方の暖かく柔らかな光 + Warning 警告 + Warp Stabilizer ワープスタビライザー + Wave ウェーブ + Wave Height 波の高さ + Wave Speed 波の速度 + Wave Warp 波状ワープ + Wave Width 波の幅 + Waveform 波形 + Waveform (Luma) 波形 (輝度) + Waveform (RGB) 波形 (RGB) + Wavelength 波長 + Wet ウェット + Wet Out ウェット出力 + When Opening a Project プロジェクトを開くとき + Whip ウィップ + Whip (Legacy) ウィップ (従来版) + Whisper base (balanced) Whisper base (バランス重視) + Whisper small (most accurate) Whisper small (最高精度) + Whisper tiny (fastest) Whisper tiny (最速) + White 白 + White Input Level 入力白レベル + White Is High 白を高く + Wide Outdoor 広い屋外 + Widener ワイドナー + Width 幅 + Wiggle ウィグル + Wipe ワイプ + Wipe Angle ワイプの角度 + Wonder Glow ワンダーグロー + Wrap Back 折り返し + Write 書き込み + Write XMP ID to files on import 読み込み時に XMP ID をファイルに書き込む + Write clip markers to XMP クリップマーカーを XMP に書き込む + Write-on 手書き + X Rotation X 軸回転 + Y Rotation Y 軸回転 + YC YC + YC no Chroma YC (クロマなし) + YUV YUV + Yellow-green highlights and blue-lifted shadows from crossed channel curves チャンネルカーブを交差させた、黄緑のハイライトと青く浮いたシャドウ + Z Position Z 位置 + Z Rotation Z 軸回転 + Zoom ズーム + Zoom Amount ズーム量 + Zoom Blur ズームぼかし + Zoom Strength ズームの強さ + days 日 + frames フレーム + milliseconds ミリ秒 + minute(s) 分 + second(s) 秒 + seconds 秒 + AAF… AAF… + Audio… オーディオ… + Avid Log Exchange… Avid Log Exchange… + Color… カラー… + EDL… EDL… + Edit Workspaces… ワークスペースを編集… + FCPXML… FCPXML… + Final Cut Pro XML… Final Cut Pro XML… + General… 一般… + OMF… OMF… + OpenTimelineIO… OpenTimelineIO… + Save Changes to this Workspace このワークスペースへの変更を保存 + Save as New Workspace… 新規ワークスペースとして保存… + Create Search Bin 検索ビンを作成 + Edit Offline File オフラインファイルを編集 + Source Settings ソース設定 + Update Metadata メタデータを更新 + Automate To Sequence シーケンスに自動配置 + Scene Edit Detection シーン編集の検出 + Analyze 分析 + Normalize Mix Track ミックストラックをノーマライズ + Simplify Sequence シーケンスを単純化 + Simplify 単純化 + Transcribe Sequence シーケンスを文字起こし + Save as Template テンプレートとして保存 + Flash Cue Marker フラッシュキューマーカー + System Compatibility Report システム互換性レポート + no GPU adapter: the CPU compositor is used GPU アダプターがありません: CPU コンポジターを使用します + Avid Log Exchange Avid Log Exchange + FilmCraft Project FilmCraft プロジェクト + {name} Selection.fcproj {name} 選択項目.fcproj + {name} (Simplified) {name} (単純化済み) + Find in: 検索場所: + Find: 検索文字列: + Search: 検索対象: + Clip Names and Markers クリップ名とマーカー + Match: 一致条件: + All すべて + Any いずれか + Case Sensitive 大文字と小文字を区別 + Scratch Disks スクラッチディスク + Ingest Settings 取り込み設定 + Captured and Generated: キャプチャ・生成メディア: + Video Previews: ビデオプレビュー: + Audio Previews: オーディオプレビュー: + Project Auto Save: プロジェクトの自動保存: + Same as Project プロジェクトと同じ + Path: {path} パス: {path} + Transcode トランスコード + Copy and Create Proxies コピーしてプロキシを作成 + Action: 処理: + Destination: 出力先: + Next to the media メディアと同じ場所 + Video Rendering and Playback ビデオのレンダリングと再生 + Renderer: レンダラー: + Display Format: 表示形式: + Milliseconds ミリ秒 + Capture キャプチャ + Capture Format: キャプチャ形式: + Action and Title Safe Areas アクションセーフ・タイトルセーフエリア + Title Safe Area タイトルセーフエリア + Action Safe Area アクションセーフエリア + Total Duration 全体の長さ + Average Data Rate 平均データレート + Colour カラー + {codec} · {rate} Hz · {n} channel(s) {codec} · {rate} Hz · {n} チャンネル + Media Name: メディア名: + Scene: シーン: + Shot/Take: ショット/テイク: + Log Note: ログメモ: + Codec: {codec} コーデック: {codec} + Write the metadata of the selected clip(s) ({n}) to XMP files next to their media? 選択したクリップ ({n} 個) のメタデータを、メディアと同じ場所にある XMP ファイルに書き込みますか? + Existing XMP files written by other applications are left unchanged. 他のアプリケーションが書き込んだ既存の XMP ファイルは変更されません。 + To {name} 配置先: {name} + Ordering: 並び順: + Sort Order 並べ替え順 + Selection Order 選択順 + Placement: 配置: + Sequentially 順番に + At Unnumbered Markers 番号なしマーカーの位置に + Method: 方法: + Insert Edit 挿入編集 + Overwrite Edit 上書き編集 + Clip Overlap: クリップの重なり: + Frames per Still: 静止画あたりのフレーム数: + Transitions トランジション + Apply Default Audio Transition 既定のオーディオトランジションを適用 + Apply Default Video Transition 既定のビデオトランジションを適用 + Ignore Options 無視オプション + Ignore Audio オーディオを無視 + Ignore Video ビデオを無視 + Apply a cut at each detected cut point 検出したカット点ごとにカットを適用 + Create a subclip for each detected cut point 検出したカット点ごとにサブクリップを作成 + Generate clip markers at each detected cut point 検出したカット点ごとにクリップマーカーを生成 + Sensitivity: 感度: + Normalize Mix Track to: ミックストラックのノーマライズ値: + Disabled clips 無効なクリップ + Empty tracks 空のトラック + Gaps ギャップ + Video effects ビデオエフェクト + Audio effects オーディオエフェクト + Text (graphics and captions) テキスト (グラフィックスとキャプション) + Clips クリップ + Move clips to the lowest track possible クリップをできるだけ下のトラックへ移動 + Keep: 保持: + Video and Audio ビデオとオーディオ + Video Only ビデオのみ + Audio Only オーディオのみ + Language: 言語: + Audio on track {t} トラック {t} のオーディオ + Audio analysis: 分析するオーディオ: + Recognize when different speakers are talking 話者の切り替わりを認識 + Template Name: テンプレート名: + Comments: コメント: + (unavailable) (使用不可) + Decoders デコーダー + Containers コンテナー + Export formats 書き出し形式 + Match {n} of {total} {total} 件中 {n} 件目 + Saved template {path} テンプレートを保存しました: {path} + Scene Edit Detection: analysing in the background (see Progress) シーン編集の検出: バックグラウンドで分析中 (進行状況を参照) + Delete Search Bin 検索ビンを削除 + (removed from {list}) ({list} から削除) + {key} can't be used as a shortcut {key} はショートカットに使用できません + Keyboard Shortcuts キーボードショートカット + {active} (modified) {active} (変更あり) + Loaded “{name}” 「{name}」をロードしました + My Shortcuts マイショートカット + Deleted preset “{active}” プリセット「{active}」を削除しました + Export… 書き出し… + Exported to {path} {path} に書き出しました + Export: no file chosen (agents: shortcuts.export {path}) 書き出し: ファイルが選択されていません (エージェント: shortcuts.export {path}) + Imported “{name}” 「{name}」を読み込みました + Import: no file chosen (agents: shortcuts.import {path}) 読み込み: ファイルが選択されていません (エージェント: shortcuts.import {path}) + Keyboard: US (macOS) キーボード: US 配列 (macOS) + Keyboard: US キーボード: US 配列 + Save preset as: 保存するプリセット名: + Commands: コマンド: + {panel} Panel {panel}パネル + Application shortcuts work whatever panel has focus アプリケーションのショートカットは、どのパネルにフォーカスがあっても機能します + Panel shortcuts override application shortcuts while that panel has focus パネルのショートカットは、そのパネルにフォーカスがある間、アプリケーションのショートカットより優先されます + Modifier held / selected 押下中 / 選択中の修飾キー + Search commands or keys コマンドまたはキーを検索 + Selected: {command} 選択中: {command} + Command コマンド + Shortcut ショートカット + Type a shortcut… (Esc cancels) ショートカットを入力… (Esc でキャンセル) + Add another shortcut 別のショートカットを追加 + No matching commands 一致するコマンドがありません + Key: キー: + Modifiers 修飾キー + Nothing is assigned to this key このキーには何も割り当てられていません + Click a key on the keyboard to see its shortcuts キーボードのキーをクリックすると、割り当てられたショートカットを表示します + Assign {key} to {command} {key} を「{command}」に割り当て + Conflict: {key} is assigned to {commands} ({ctx}) 競合: {key} は「{commands}」に割り当てられています ({ctx}) + and と + Click a command's Shortcut cell and press keys to assign it; + adds another shortcut. コマンドの「ショートカット」セルをクリックしてキーを押すと割り当てられます。+ で別のショートカットを追加できます。 + Cleared {n} shortcut(s) of {command} 「{command}」のショートカットを {n} 個クリアしました + No export preset named `{name}` 「{name}」という名前の書き出しプリセットはありません + Open a sequence to export 書き出すシーケンスを開いてください + Destinations 出力先 + Media File メディアファイル + Queue ({n}) キュー ({n}) + Send exports here with Send to Queue, then start the queue. 「キューに送信」で書き出しをここに追加してから、キューを開始してください。 + Ready 準備完了 + Encoding {n}% エンコード中 {n}% + Start Queue キューを開始 + Stop 停止 + Clear Finished 終了した項目をクリア + Not supported by FilmCraft's encoders yet FilmCraft のエンコーダーはまだ対応していません + Sequence name シーケンス名 + Folder フォルダー + Preset プリセット + More presets… その他のプリセット… + Multiplexer マルチプレクサー + The sequence has no caption track. このシーケンスにはキャプショントラックがありません。 + Export Options 書き出しオプション + Burn Captions Into Video キャプションをビデオに焼き込み + Create Sidecar File サイドカーファイルを作成 + File Format ファイル形式 + SubRip Subtitle (.srt) SubRip 字幕 (.srt) + WebVTT (.vtt) WebVTT (.vtt) + Title タイトル + Creator 作成者 + Copyright 著作権 + Image 画像 + Frame Size フレームサイズ + Match Source ソースに合わせる + Field Order フィールドオーダー + Aspect アスペクト比 + Profile プロファイル + Bitrate Encoding ビットレート方式 + Target Bitrate ターゲットビットレート + Adaptive ({bpp} bits per pixel) アダプティブ (1 ピクセルあたり {bpp} ビット) + Maximum Bitrate 最大ビットレート + Key Frame Distance キーフレーム間隔 + Quality 画質 + Render at Maximum Depth 最大深度でレンダリングする + Use Maximum Render Quality 最高レンダリング画質を使用する + Export Audio オーディオを書き出す + Audio Format オーディオ形式 + Uncompressed (PCM) 非圧縮 (PCM) + Match Source ({seq_sr} Hz) ソースに合わせる ({seq_sr} Hz) + Channels チャンネル + Audio Bitrate オーディオビットレート + Sample Size サンプルサイズ + 16 bit 16 ビット + 24 bit 24 ビット + Size / Opacity サイズ / 不透明度 + Image Overlay 画像のオーバーレイ + PNG or JPEG file PNG または JPEG ファイル + Name Overlay 名前のオーバーレイ + Timecode Overlay タイムコードのオーバーレイ + Luma Min / Max 輝度の最小 / 最大 + Loudness Normalization ラウドネスのノーマライズ + True Peak Limit トゥルーピークの上限 + Audio only オーディオのみ + Scaling 拡大・縮小 + Scale To Fit 収まるように拡大・縮小 + Scale To Fill 埋まるように拡大・縮小 + Stretch To Fill 引き伸ばして埋める + Summary 概要 + Sequence, {w}x{h}, {fps} fps, {rate} Hz シーケンス、{w}x{h}、{fps} fps、{rate} Hz + Estimated file size: {size} 推定ファイルサイズ: {size} + Send to Queue キューに送信 + Exporting {path} {path} を書き出し中 + Added to the export queue 書き出しキューに追加しました + Preset Manager プリセットマネージャー + Search presets プリセットを検索 + Favorites only お気に入りのみ + Show in the Preset menu プリセットメニューに表示 + User ユーザー + Save current settings as: 現在の設定を次の名前で保存: + Export presets 書き出しプリセット + Import: no file chosen (agents: export.presets.import {path}) 読み込み: ファイルが選択されていません (エージェント: export.presets.import {path}) + Export: no file chosen (agents: export.presets.export {path, names}) 書き出し: ファイルが選択されていません (エージェント: export.presets.export {path, names}) + Quick Export: open a sequence first クイック書き出し: 先にシーケンスを開いてください + File Name & Location ファイル名と場所 + Quick Export: {path} クイック書き出し: {path} + Graphics problem: switched to software compositing グラフィックスに問題が発生したため、ソフトウェア合成に切り替えました + Exporting 書き出し中 + Click to select, or click in empty space and drag to marquee select. Use Shift, Opt, and Cmd for other options. クリックで選択します。何もない場所からドラッグすると範囲選択になります。Shift、Opt、Cmd キーを併用すると、ほかのオプションも使えます。 + Click to select all clips to the right in all tracks. Shift-click for a single track. クリックすると、全トラックでクリック位置より右にあるクリップをすべて選択します。Shift+クリックでは 1 つのトラックだけが対象になります。 + Click to select all clips to the left in all tracks. Shift-click for a single track. クリックすると、全トラックでクリック位置より左にあるクリップをすべて選択します。Shift+クリックでは 1 つのトラックだけが対象になります。 + Drag an edit point to ripple trim; later clips move to keep the gap closed. 編集点をドラッグしてリップルトリムします。後続のクリップも移動するため、ギャップは生じません。 + Drag an edit point to roll it: the out of one clip and the in of the next move together. 編集点をドラッグしてロールトリムします。前のクリップのアウト点と次のクリップのイン点が一緒に動きます。 + Drag an edge to change the clip's speed so it fills the new duration. 端をドラッグすると、新しい長さに収まるようにクリップの速度を変更します。 + Drag the edge of a music clip to remix it to the new duration at musically matching beats. 音楽クリップの端をドラッグすると、曲のビートが合う位置で新しい長さにリミックスします。 + Click to split a clip. Shift-click to split all tracks. クリックでクリップを分割します。Shift+クリックではすべてのトラックを分割します。 + Drag a clip to slip its source in/out without moving it. クリップをドラッグすると、位置はそのままでソースのイン点とアウト点をずらします。 + Drag a clip to slide it between its neighbours. クリップをドラッグすると、前後のクリップの間でスライドします。 + Drag to scroll the timeline. ドラッグしてタイムラインをスクロールします。 + Click to zoom in; Opt-click to zoom out. クリックでズームイン、Opt+クリックでズームアウトします。 + FilmCraft hit an error FilmCraft でエラーが発生しました + Details: {path} 詳細: {path} + Opened {url} {url} を開きました + (empty) (空) + Step Back 1 Frame 1 フレーム戻る + Step Forward 1 Frame 1 フレーム進む + Match Playhead 再生ヘッドに合わせる + Gang to Program プログラムと連動 + 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 + APV APV + APV 422-10 APV 422-10 + APV 422-12 APV 422-12 + Anamorphic 2:1 (2.0) アナモルフィック 2:1 (2.0) + Animated GIF アニメーション GIF + Animated GIF 640×360 アニメーション GIF 640×360 + Apple ProRes Apple ProRes + Apple ProRes 422 Apple ProRes 422 + Apple ProRes 422 HQ Apple ProRes 422 HQ + Apple ProRes 422 LT Apple ProRes 422 LT + Apple ProRes 422 Proxy Apple ProRes 422 Proxy + Application アプリケーション + Apply Guide Template ガイドテンプレートを適用 + Auto Save Now 今すぐ自動保存 + Auto-Match Loudness ラウドネスを自動マッチ + Avid DNxHR Avid DNxHR + Avid DNxHR HQ Avid DNxHR HQ + Avid DNxHR LB Avid DNxHR LB + Avid DNxHR SQ Avid DNxHR SQ + Avid-style OP-Atom: DNxHR HQ picture file plus one 24-bit 48 kHz PCM file per channel (_A1, _A2…) Avid 方式の OP-Atom: DNxHR HQ のビデオファイルと、チャンネルごとに 1 つの 24-bit 48 kHz PCM ファイル (_A1、_A2…) + BMP BMP + BMP Sequence BMP 連番画像 + Baseline ベースライン + Begin Text Editing for a Graphic Layer グラフィックレイヤーのテキスト編集を開始 + Begins With 前方一致 + Blur In ぼかしイン + Blur Out ぼかしアウト + Brightens inside a feathered circular mask 境界をぼかした円形マスクの内側を明るくします + Broadcast 放送 + CBR CBR + Cancel Dynamic Trim ダイナミックトリムをキャンセル + Cancel Queued Export キュー内の書き出しをキャンセル + Center Left 左中央 + Center Right 右中央 + Center align text テキストを中央揃え + Change Mask マスクを変更 + Clear All Events すべてのイベントをクリア + Clear Edit Point Selection 編集点の選択を解除 + Clear Finished Exports 終了した書き出しをクリア + Clear Poster Frame ポスターフレームをクリア + Close Bin ビンを閉じる + Close Sequence シーケンスを閉じる + Contains 含む + Cut to Camera 1 カメラ 1 にカット + Cut to Camera 2 カメラ 2 にカット + Cut to Camera 3 カメラ 3 にカット + Cut to Camera 4 カメラ 4 にカット + Cut to Camera 5 カメラ 5 にカット + Cut to Camera 6 カメラ 6 にカット + Cut to Camera 7 カメラ 7 にカット + Cut to Camera 8 カメラ 8 にカット + Cut to Camera 9 カメラ 9 にカット + D1/DV NTSC (0.9091) D1/DV NTSC (0.9091) + D1/DV PAL (1.0940) D1/DV PAL (1.0940) + DVCPRO HD (1.5) DVCPRO HD (1.5) + Darkens outside a feathered oval mask 境界をぼかした楕円マスクの外側を暗くします + Decrease Audio Tracks Height オーディオトラックの高さを縮小 + Decrease Clip Volume クリップの音量を下げる + Decrease Clip Volume Many クリップの音量を大幅に下げる + Decrease Font Size by Five Units フォントサイズを 5 単位下げる + Decrease Font Size by One Unit フォントサイズを 1 単位下げる + Decrease Leading by Five Units 行送りを 5 単位減らす + Decrease Leading by One Unit 行送りを 1 単位減らす + Decrease Video Tracks Height ビデオトラックの高さを縮小 + Delete Captions キャプションを削除 + Delete Graphic Layer グラフィックレイヤーを削除 + Delete Guide Template ガイドテンプレートを削除 + Delete Media Cache Files メディアキャッシュファイルを削除 + Discard Unsaved Changes 未保存の変更を破棄 + Does Not Contain 含まない + Download Speech Model 音声認識モデルをダウンロード + Dynamic Trim Stop ダイナミックトリムを停止 + Edit Caption Text キャプションのテキストを編集 + Ellipse Tool 楕円ツール + Ends With 後方一致 + Entire Source ソース全体 + EssentialSound エッセンシャルサウンド + Export Frame フレームを書き出し + Extend Next Edit To Playhead 次の編集点を再生ヘッドまで延長 + Extend Previous Edit To Playhead 前の編集点を再生ヘッドまで延長 + Extend Selected Edit to Playhead 選択した編集点を再生ヘッドまで延長 + Extend Selection Down 選択範囲を下に拡張 + Extend Selection Left 選択範囲を左に拡張 + Extend Selection Right 選択範囲を右に拡張 + Extend Selection Up 選択範囲を上に拡張 + Fade In フェードイン + Gaussian Blur from 0 to 60 over the last second 最後の 1 秒でガウスぼかしを 0 から 60 に変化 + Gaussian Blur from 60 to 0 over the first second 最初の 1 秒でガウスぼかしを 60 から 0 に変化 + Generate Ducking Keyframes ダッキングキーフレームを生成 + Go to Next Edit Point 次の編集点へ移動 + Go to Next Edit Point on Any Track いずれかのトラックの次の編集点へ移動 + Go to Previous Edit Point 前の編集点へ移動 + Go to Previous Edit Point on Any Track いずれかのトラックの前の編集点へ移動 + Go to Selected Clip End 選択したクリップの終了位置へ移動 + Go to Selected Clip Start 選択したクリップの開始位置へ移動 + Go to Sequence End シーケンスの終了位置へ移動 + Go to Sequence Start シーケンスの開始位置へ移動 + H.264 H.264 + H.264 1920×1080 for video sharing sites, VBR 1 pass, 16 Mbps 動画共有サイト向けの H.264 1920×1080、VBR 1 パス、16 Mbps + H.264 1920×1080, VBR 1 pass, 20 Mbps target, AAC 320 kbps H.264 1920×1080、VBR 1 パス、ターゲット 20 Mbps、AAC 320 kbps + H.264 1920×1080, VBR 2 pass, 18 Mbps H.264 1920×1080、VBR 2 パス、18 Mbps + H.264 3840×2160 for video sharing sites, VBR 1 pass, 45 Mbps 動画共有サイト向けの H.264 3840×2160、VBR 1 パス、45 Mbps + H.264 3840×2160, VBR 1 pass, 60 Mbps target, AAC 320 kbps H.264 3840×2160、VBR 1 パス、ターゲット 60 Mbps、AAC 320 kbps + H.264 3840×2160, VBR 2 pass, 50 Mbps H.264 3840×2160、VBR 2 パス、50 Mbps + H.264 9:16 portrait for short-form social video, scaled to fill, 12 Mbps SNS ショート動画向けの H.264 9:16 縦型、フレームを埋めるようにスケール、12 Mbps + H.264 at the sequence's frame size and rate; 0.05 bits per pixel シーケンスと同じフレームサイズ・フレームレートの H.264、1 ピクセルあたり 0.05 ビット + H.264 at the sequence's frame size and rate; 0.1 bits per pixel シーケンスと同じフレームサイズ・フレームレートの H.264、1 ピクセルあたり 0.1 ビット + H.264 at the sequence's frame size and rate; bitrate adapts to the frame size (0.2 bits per pixel) シーケンスと同じフレームサイズ・フレームレートの H.264、ビットレートはフレームサイズに応じて変化 (1 ピクセルあたり 0.2 ビット) + HD Anamorphic 1080 (1.333) HD アナモルフィック 1080 (1.333) + Hand Tool 手のひらツール + High Quality 1080p HD 高画質 1080p HD + High Quality 2160p 4K 高画質 2160p 4K + Image Sequence 連番画像 + Increase Audio Tracks Height オーディオトラックの高さを拡大 + Increase Clip Volume クリップの音量を上げる + Increase Clip Volume Many クリップの音量を大幅に上げる + Increase Font Size by Five Units フォントサイズを 5 単位上げる + Increase Font Size by One Unit フォントサイズを 1 単位上げる + Increase Leading by Five Units 行送りを 5 単位増やす + Increase Leading by One Unit 行送りを 1 単位増やす + Increase Video Tracks Height ビデオトラックの高さを拡大 + Large blocks resolving to the picture over the first second 最初の 1 秒で大きなブロックから元の画像に変化 + Left align text テキストを左揃え + Looping GIF, 640×360 at 15 fps ループする GIF、640×360、15 fps + Lower First 下位フィールド優先 + Lumetri Lumetri + MXF OP-Atom MXF OP-Atom + MXF OP-Atom DNxHR (Avid) MXF OP-Atom DNxHR (Avid) + MXF OP1a MXF OP1a + MXF OP1a DNxHR HQ MXF OP1a DNxHR HQ + MXF OP1a H.264 MXF OP1a H.264 + MXF OP1a ProRes 422 HQ MXF OP1a ProRes 422 HQ + MXF OP1a, DNxHR HQ (8-bit 4:2:2), 24-bit 48 kHz PCM, start timecode MXF OP1a、DNxHR HQ (8-bit 4:2:2)、24-bit 48 kHz PCM、開始タイムコード付き + MXF OP1a, H.264 High long GOP (0.3 bits per pixel), 24-bit 48 kHz PCM MXF OP1a、H.264 High ロング GOP (1 ピクセルあたり 0.3 ビット)、24-bit 48 kHz PCM + MXF OP1a, ProRes 422 HQ, 24-bit 48 kHz PCM, start timecode MXF OP1a、ProRes 422 HQ、24-bit 48 kHz PCM、開始タイムコード付き + Main メイン + Mark In (Hover Scrub) イン点をマーク (ホバースクラブ) + Mark Out (Hover Scrub) アウト点をマーク (ホバースクラブ) + Masks マスク + Match Source – Adaptive High Bitrate ソースに合わせる – アダプティブ高ビットレート + Match Source – Adaptive Low Bitrate ソースに合わせる – アダプティブ低ビットレート + Match Source – Adaptive Medium Bitrate ソースに合わせる – アダプティブ中ビットレート + Matches 一致 + Maximize or Restore Active Frame アクティブなパネルを最大化 / 元に戻す + Maximize or Restore Frame Under Cursor カーソル下のパネルを最大化 / 元に戻す + Media Browser Settings メディアブラウザーの設定 + MediaBrowser メディアブラウザー + MediaCache メディアキャッシュ + Merge Captions キャプションを結合 + Mixer ミキサー + Mosaic In モザイクイン + Move All Audio Targets Down すべてのオーディオターゲットを下へ移動 + Move All Audio Targets Up すべてのオーディオターゲットを上へ移動 + Move All Video Targets Down すべてのビデオターゲットを下へ移動 + Move All Video Targets Up すべてのビデオターゲットを上へ移動 + Move Selection Down 選択を下に移動 + Move Selection End 選択を末尾に移動 + Move Selection Home 選択を先頭に移動 + Move Selection Left 選択を左に移動 + Move Selection Page Down 選択を 1 ページ下に移動 + Move Selection Page Up 選択を 1 ページ上に移動 + Move Selection Right 選択を右に移動 + Move Selection Up 選択を上に移動 + Multi-Camera Record On/Off Toggle マルチカメラ記録のオン/オフを切り替え + Multicam マルチカメラ + Navigate to End of Segment セグメントの末尾へ移動 + Navigate to Next Line 次の行へ移動 + Navigate to Next Word 次の単語へ移動 + Navigate to Previous Line 前の行へ移動 + Navigate to Previous Word 前の単語へ移動 + Navigate to Start of Segment セグメントの先頭へ移動 + Navigation ナビゲーション + Next Multi-Camera Page 次のマルチカメラページ + Nudge Clip Selection Down 選択したクリップを下へ微調整 + Nudge Clip Selection Left Five Frames 選択したクリップを 5 フレーム左へ微調整 + Nudge Clip Selection Left One Frame 選択したクリップを 1 フレーム左へ微調整 + Nudge Clip Selection Right Five Frames 選択したクリップを 5 フレーム右へ微調整 + Nudge Clip Selection Right One Frame 選択したクリップを 1 フレーム右へ微調整 + Nudge Clip Selection Up 選択したクリップを上へ微調整 + Nudge Selected Object down by five 選択したオブジェクトを 5 単位下へ微調整 + Nudge Selected Object down by one 選択したオブジェクトを 1 単位下へ微調整 + Nudge Selected Object to left by five 選択したオブジェクトを 5 単位左へ微調整 + Nudge Selected Object to left by one 選択したオブジェクトを 1 単位左へ微調整 + Nudge Selected Object to right by five 選択したオブジェクトを 5 単位右へ微調整 + Nudge Selected Object to right by one 選択したオブジェクトを 1 単位右へ微調整 + Nudge Selected Object up by five 選択したオブジェクトを 5 単位上へ微調整 + Nudge Selected Object up by one 選択したオブジェクトを 1 単位上へ微調整 + Nudge Volume +1dB 音量を +1dB 微調整 + Nudge Volume +3dB 音量を +3dB 微調整 + Nudge Volume -1dB 音量を -1dB 微調整 + Nudge Volume -3dB 音量を -3dB 微調整 + Numbered BMP stills (24-bit) 連番 BMP 静止画 (24-bit) + Numbered PNG stills at the sequence's frame size シーケンスと同じフレームサイズの連番 PNG 静止画 + Numbered TIFF stills (RGB, 8-bit) 連番 TIFF 静止画 (RGB、8-bit) + Opacity 0 → 100 % over the first second 最初の 1 秒で不透明度を 0 → 100 % に変化 + Opacity 100 → 0 % over the last second 最後の 1 秒で不透明度を 100 → 0 % に変化 + Open Bin ビンを開く + Open Search 検索を開く + PNG PNG + PNG Sequence PNG 連番画像 + Panel パネル + Pen Tool ペンツール + PiP 35% Upper Right PiP 35% 右上 + Picture-in-picture: 35 % scale in the upper right ピクチャーインピクチャー: スケール 35 % で右上に配置 + Play Around 前後を再生 + Play In to Out with Preroll/Postroll プリロール/ポストロール付きでインからアウトまで再生 + Play from Playhead to Out Point 再生ヘッドからアウト点まで再生 + Play/Stop 再生/停止 + Previous Multi-Camera Page 前のマルチカメラページ + Progressive プログレッシブ + ProjectPanel プロジェクトパネル + QuickTime (Motion JPEG) QuickTime (Motion JPEG) + QuickTime, APV 422-10 (10-bit 4:2:2), 24-bit 48 kHz PCM QuickTime、APV 422-10 (10-bit 4:2:2)、24-bit 48 kHz PCM + QuickTime, APV 422-12 (12-bit 4:2:2), 24-bit 48 kHz PCM QuickTime、APV 422-12 (12-bit 4:2:2)、24-bit 48 kHz PCM + QuickTime, DNxHR HQ (8-bit 4:2:2), 24-bit 48 kHz PCM QuickTime、DNxHR HQ (8-bit 4:2:2)、24-bit 48 kHz PCM + QuickTime, DNxHR LB (8-bit 4:2:2), 24-bit 48 kHz PCM QuickTime、DNxHR LB (8-bit 4:2:2)、24-bit 48 kHz PCM + QuickTime, DNxHR SQ (8-bit 4:2:2), 24-bit 48 kHz PCM QuickTime、DNxHR SQ (8-bit 4:2:2)、24-bit 48 kHz PCM + QuickTime, ProRes 422 HQ, 24-bit 48 kHz PCM QuickTime、ProRes 422 HQ、24-bit 48 kHz PCM + QuickTime, ProRes 422 LT, 24-bit 48 kHz PCM QuickTime、ProRes 422 LT、24-bit 48 kHz PCM + QuickTime, ProRes 422 Proxy, 24-bit 48 kHz PCM QuickTime、ProRes 422 Proxy、24-bit 48 kHz PCM + QuickTime, ProRes 422, 24-bit 48 kHz PCM QuickTime、ProRes 422、24-bit 48 kHz PCM + Quit FilmCraft FilmCraft を終了 + Rate Stretch Tool レートストレッチツール + Razor Tool カミソリツール + Rectangle Tool 長方形ツール + Remix Tool リミックスツール + Right align text テキストを右揃え + Ripple Edit Tool リップル編集ツール + Ripple Trim Next Edit to Playhead 次の編集点を再生ヘッドまでリップルトリム + Ripple Trim Previous Edit to Playhead 前の編集点を再生ヘッドまでリップルトリム + Rolling Edit Tool ロール編集ツール + Select Camera 1 カメラ 1 を選択 + Select Camera 2 カメラ 2 を選択 + Select Camera 3 カメラ 3 を選択 + Select Camera 4 カメラ 4 を選択 + Select Camera 5 カメラ 5 を選択 + Select Camera 6 カメラ 6 を選択 + Select Camera 7 カメラ 7 を選択 + Select Camera 8 カメラ 8 を選択 + Select Camera 9 カメラ 9 を選択 + Select Clip at Playhead 再生ヘッドの位置のクリップを選択 + Select Find Box 検索ボックスを選択 + Select Next Clip 次のクリップを選択 + Select Next Panel 次のパネルを選択 + Select Previous Clip 前のクリップを選択 + Select Previous Panel 前のパネルを選択 + Select to Next Line 次の行まで選択 + Select to Next Word 次の単語まで選択 + Select to Previous Line 前の行まで選択 + Select to Previous Word 前の単語まで選択 + Select to Segment End セグメントの末尾まで選択 + Select to Segment Start セグメントの先頭まで選択 + Selection Tool 選択ツール + Set Comparison Reference 比較の基準を設定 + Set Creative Look クリエイティブのルックを設定 + Set Input LUT 入力 LUT を設定 + Set Project Panel View プロジェクトパネルの表示を設定 + Show Next Screen 次の画面を表示 + Show Previous Screen 前の画面を表示 + Show Program Transcript プログラムの文字起こしを表示 + Shuttle Left 左へシャトル + Shuttle Right 右へシャトル + Shuttle Slow Left 低速で左へシャトル + Shuttle Slow Right 低速で右へシャトル + Shuttle Stop シャトルを停止 + Slide Clip Selection Left Five Frames 選択したクリップを 5 フレーム左へスライド + Slide Clip Selection Left One Frame 選択したクリップを 1 フレーム左へスライド + Slide Clip Selection Right Five Frames 選択したクリップを 5 フレーム右へスライド + Slide Clip Selection Right One Frame 選択したクリップを 1 フレーム右へスライド + Slide Tool スライドツール + Slip Clip Selection Left Five Frames 選択したクリップを 5 フレーム左へスリップ + Slip Clip Selection Left One Frame 選択したクリップを 1 フレーム左へスリップ + Slip Clip Selection Right Five Frames 選択したクリップを 5 フレーム右へスリップ + Slip Clip Selection Right One Frame 選択したクリップを 1 フレーム右へスリップ + Slip Tool スリップツール + Social Vertical 1080×1920 SNS 縦型 1080×1920 + Soft Vignette ソフトビネット + Source In/Out ソースのイン/アウト + Split Caption キャプションを分割 + Square Pixels (1.0) 正方形ピクセル (1.0) + Stack Clips クリップをスタック + Start Export Queue 書き出しキューを開始 + Start Voice-over Recording ナレーションの録音を開始 + Step Back Five Frames 5 フレーム戻る + Step Back One Frame 1 フレーム戻る + Step Forward Five Frames 5 フレーム進む + Step Forward One Frame 1 フレーム進む + Stop Export Queue 書き出しキューを停止 + Stop Multi-Camera Recording マルチカメラの記録を停止 + Stop Voice-over Recording ナレーションの録音を停止 + Sync Voice-over Capture ナレーションのキャプチャを同期 + TIFF TIFF + TIFF Sequence TIFF 連番画像 + TextPanel テキストパネル + Thumbnail Size Next 次のサムネイルサイズ + Thumbnail Size Previous 前のサムネイルサイズ + Toggle All Audio Targets すべてのオーディオターゲットを切り替え + Toggle All Source Audio すべてのソースオーディオを切り替え + Toggle All Source Video すべてのソースビデオを切り替え + Toggle All Video Targets すべてのビデオターゲットを切り替え + Toggle Audio During Scrubbing スクラブ中のオーディオを切り替え + Toggle Full Screen 全画面表示を切り替え + Toggle Mute for All Targeted Audio Tracks ターゲット設定されたすべてのオーディオトラックのミュートを切り替え + Toggle Solo for All Targeted Audio Tracks ターゲット設定されたすべてのオーディオトラックのソロを切り替え + Toggle Source/Program Monitor Focus ソース/プログラムモニターのフォーカスを切り替え + Toggle Target Audio 1 オーディオ 1 のターゲットを切り替え + Toggle Target Audio 2 オーディオ 2 のターゲットを切り替え + Toggle Target Audio 3 オーディオ 3 のターゲットを切り替え + Toggle Target Audio 4 オーディオ 4 のターゲットを切り替え + Toggle Target Audio 5 オーディオ 5 のターゲットを切り替え + Toggle Target Audio 6 オーディオ 6 のターゲットを切り替え + Toggle Target Audio 7 オーディオ 7 のターゲットを切り替え + Toggle Target Audio 8 オーディオ 8 のターゲットを切り替え + Toggle Target Video 1 ビデオ 1 のターゲットを切り替え + Toggle Target Video 2 ビデオ 2 のターゲットを切り替え + Toggle Target Video 3 ビデオ 3 のターゲットを切り替え + Toggle Target Video 4 ビデオ 4 のターゲットを切り替え + Toggle Target Video 5 ビデオ 5 のターゲットを切り替え + Toggle Target Video 6 ビデオ 6 のターゲットを切り替え + Toggle Target Video 7 ビデオ 7 のターゲットを切り替え + Toggle Target Video 8 ビデオ 8 のターゲットを切り替え + Toggle Track Output for All Targeted Video Tracks ターゲット設定されたすべてのビデオトラックの出力を切り替え + Toggle Trim Type トリムの種類を切り替え + Toggle View 表示を切り替え + Track Select Backward Tool 後方トラック選択ツール + Track Select Forward Tool 前方トラック選択ツール + Track Selected Mask 選択したマスクをトラッキング + Trim Next Edit to Playhead 次の編集点を再生ヘッドまでトリム + Trim Previous Edit to Playhead 前の編集点を再生ヘッドまでトリム + Trimming トリム Type Tool 文字ツール + Unstack Clips クリップのスタックを解除 + Upper First 上位フィールド優先 + VBR, 1 pass VBR、1 パス + VBR, 2 pass VBR、2 パス + Vimeo 1080p Full HD Vimeo 1080p Full HD + Vimeo 2160p 4K Ultra HD Vimeo 2160p 4K Ultra HD + Voice-over Record ナレーションを録音 + Voiceover ナレーション + WAV, stereo 48 kHz 16-bit PCM WAV、ステレオ 48 kHz 16-bit PCM + WAV, stereo 48 kHz 24-bit PCM WAV、ステレオ 48 kHz 24-bit PCM + Warm Tint 暖かい色合い + Warm highlights, brown shadows 暖色のハイライト、茶色のシャドウ + Waveform Audio Waveform Audio + Waveform Audio 48 kHz 16-bit Waveform Audio 48 kHz 16-bit + Waveform Audio 48 kHz 24-bit Waveform Audio 48 kHz 24-bit + Web & Social Web・SNS + Work Area ワークエリア + Write Automation オートメーションを書き込み + YouTube 1080p Full HD YouTube 1080p Full HD + YouTube 2160p 4K Ultra HD YouTube 2160p 4K Ultra HD + Zoom Program Monitor to 100% プログラムモニターを 100% にズーム + Zoom Program Monitor to Fit プログラムモニターを画面に合わせてズーム + Zoom Source Monitor to 100% ソースモニターを 100% にズーム + Zoom Source Monitor to Fit ソースモニターを画面に合わせてズーム + Zoom Tool ズームツール + with: 置換後: + Interface Language 表示言語 + System Language システムの言語 + Cinematic シネマティック + Film Emulation フィルム再現 + Technical テクニカル + Teal Shadows, Warm Skin ティールのシャドウと暖かな肌色 + Night Exterior 夜の屋外 + Golden Dusk 黄金色の夕暮れ + Steel Blue スチールブルー + Hard Sun 強い日差し + Faded Print 色あせたプリント + Silver Retention 銀残し + Cross Processed クロスプロセス + Warm Negative 暖色ネガ + Neutral Mono ニュートラルモノクロ + Hard Mono 硬調モノクロ + Sepia Tone セピア調 + Cold Mono 寒色モノクロ + Lift Shadows シャドウを持ち上げ + Protect Highlights ハイライトを保護 + Half Saturation 彩度半減 + Boost Vibrance 自然な彩度を強化 + Legal Range Squeeze リーガルレンジに圧縮 + A boxed label with a round pointer; move the label and the pointer follows. 丸いポインター付きのボックス型ラベルです。ラベルを動かすと、ポインターも追従します。 + A rounded tag that fits its text, with a status dot. テキストに合わせて大きさが変わる、ステータスドット付きの角丸タグです。 + A title on a rounded card with a soft shadow. ソフトなシャドウ付きの角丸カードに載せたタイトルです。 + Accent Color アクセントカラー + Bar Color バーの色 + Box Color ボックスの色 + Box Opacity ボックスの不透明度 + Callout – Pointer コールアウト – ポインター + Callout – Tag コールアウト – タグ + Callouts コールアウト + Credits クレジット + Dot Color ドットの色 + Heading 見出し + Large centred title, accent rule and subtitle. 中央に大きなタイトルを置き、アクセントの罫線とサブタイトルを添えたデザインです。 + Lower Third – Rule ロワーサード – 罫線 + Lower Third – Slab ロワーサード – 帯 + Lower Thirds ロワーサード + Name Color 名前の色 + Name Size 名前のサイズ + Name and role on a coloured slab that widens with the name. 名前の長さに合わせて幅が広がる色付きの帯に、名前と肩書きを表示します。 + Name over a thin rule that always matches the name's width. 名前の下に、常に名前と同じ幅の細い罫線を引きます。 + Pointer Color ポインターの色 + Role 肩書き + Rule Color 罫線の色 + Show Dot ドットを表示 + Show Role 肩書きを表示 + Slab Color 帯の色 + Tag Color タグの色 + Title Color タイトルの色 + Title Size タイトルのサイズ + Title – Boxed タイトル – ボックス + Title – Centered タイトル – 中央揃え + Titles タイトル + Select an item to preview it プレビューする項目を選択してください + End Credits エンドクレジット + End Credits – Roll エンドクレジット – ロール + Ticker – Crawl ティッカー – クロール + Centred credits that roll up from below the frame and leave at the top. 中央揃えのクレジットが画面の下から現れて上へ流れ、上端から抜けていきます。 + A news-style line that crawls in from the right and leaves at the left. ニュース風のテキスト行が右から流れ込み、左へ抜けていきます。 + out アウト + in イン + The language changed but could not be saved: {e} 表示言語は変更されましたが、保存できませんでした: {e} + no Japanese font is installed on this system; the interface stays in English このシステムには日本語フォントがインストールされていないため、表示は英語のままです + Show 表示 + Sort 並べ替え + Reverse the order 順序を反転 + Grab bag 一覧 + Table 表 + No contributor data was built into this copy. このビルドにはコントリビューターのデータが含まれていません。 + No model credits were built into this copy. このビルドにはモデルのクレジットが含まれていません。 + Company 会社 + Model モデル + Version バージョン + Commits コミット + % of all commits 全コミットに占める割合 (%) + Lines +/− 行数 +/− + Audio output failed: playing without sound (check Settings ▸ Audio Hardware) オーディオ出力に失敗したため、無音で再生しています (「環境設定 ▸ オーディオハードウェア」を確認してください) + Audio output is not responding: playing without sound (check Settings ▸ Audio Hardware) オーディオ出力が応答しないため、無音で再生しています (「環境設定 ▸ オーディオハードウェア」を確認してください) + Clip Speed / Duration クリップの速度 / 長さ + Speed: 速度: + Reverse Speed 逆再生する + Ripple Edit, Shifting Trailing Clips 後続のクリップをずらしてリップル編集する + About バージョン情報 + Contributors コントリビューター + Models モデル + Main (8-bit) メイン (8 ビット) + Main 10 (10-bit, HDR) メイン 10 (10 ビット、HDR) + Main (8-bit; Main 10 for HDR sequences) メイン (8 ビット、HDR シーケンスではメイン 10) + Encoder エンコーダー + Hardware (H.265 has no software encoder) ハードウェア (H.265 にはソフトウェアエンコーダーがありません) + Text Properties テキストのプロパティ + Text Box Height テキストボックスの高さ + Paragraph Text 段落テキスト + Point Text ポイントテキスト + Text Layer Type テキストレイヤーの種類 + Text Styling テキストのスタイル + New Color Matte 新規カラーマット + Color Matte Color カラーマットの色 + Color: 色: + · {time} left · 残り {time} + Discard take テイクを破棄 + Voice-over take discarded ナレーションのテイクを破棄しました + Binary assets added 追加したバイナリアセット + Binary assets removed 削除したバイナリアセット + Deselect All Project Items すべてのプロジェクト項目の選択を解除 + Display name 表示名 + First commit 最初のコミット + H.265 (HEVC) H.265 (HEVC) + Last commit 最後のコミット + Line delta 行数の増減 + Lines added 追加した行数 + Lines deleted 削除した行数 + Merged PRs マージされた PR + Name (A–Z) 名前 (A–Z) + Real name 本名 + Select All Project Items すべてのプロジェクト項目を選択 + Username ユーザー名 Vertical Type Tool 縦書き文字ツール + Go to previous keyframe 前のキーフレームへ移動 + Go to next keyframe 次のキーフレームへ移動 + Add keyframe キーフレームを追加 + Remove keyframe キーフレームを削除 + Turn on animation and add keyframe アニメーションをオンにしてキーフレームを追加 + Click to select, or click in empty space and drag to marquee select. Use Shift, Alt, and Ctrl for other options. クリックで選択します。何もない場所からドラッグすると範囲選択になります。Shift、Alt、Ctrl キーを併用すると、ほかのオプションも使えます。 + Click to zoom in; Alt-click to zoom out. クリックでズームイン、Alt+クリックでズームアウトします。 + Drag: move · Alt-click: smooth/corner · Ctrl-click: delete ドラッグ: 移動 · Alt+クリック: スムーズ/コーナー · Ctrl+クリック: 削除 + Include Alpha Channel アルファチャンネルを含める + Sequence Settings シーケンス設定 + Color Management カラーマネジメント + VR Properties VR プロパティ + Editing Mode: 編集モード: + Timebase: タイムベース: + Frame Size: フレームサイズ: + Pixel Aspect Ratio: ピクセルアスペクト比: + Fields: フィールド: + Channel Format: チャンネル形式: + Number of Channels: チャンネル数: + Preview File Format: プレビューファイル形式: + Working Color Space: 作業用カラースペース: + Projection: 投影法: + Layout: レイアウト: + Captured View: 撮影視野: + Video Previews ビデオプレビュー + No Fields (Progressive Scan) フィールドなし (プログレッシブスキャン) + Custom ({ratio}) カスタム ({ratio}) + {rate} frames/second {rate} フレーム/秒 + {rate} fps Drop-Frame Timecode {rate} fps ドロップフレームタイムコード + {rate} fps Non Drop-Frame Timecode {rate} fps ノンドロップフレームタイムコード + {rate} fps Timecode {rate} fps タイムコード + horizontal 水平 + vertical 垂直 + Scale motion effects proportionally when changing frame size フレームサイズの変更時にモーションエフェクトを比例してスケールする + Maximum Bit Depth 最大ビット深度 + Maximum Render Quality 最高レンダリング画質 + Composite in Linear Color リニアカラーで合成 + Auto Tone Map Media (HDR and log into SDR) メディアの自動トーンマッピング (HDR と log を SDR に変換) + Configure… 設定… + Not supported in FilmCraft yet FilmCraft ではまだサポートされていません + FilmCraft always composites in linear light FilmCraft は常にリニアライトで合成します + Loop marked range マークした範囲をループ + Play Source ソースを再生 + Source Play/Stop ソースの再生/停止 + Stop Source ソースを停止 + New Sequence 新規シーケンス + Tracks トラック + tracks トラック From a123da6d0da6e2406ee752255c5e51bc83c58715 Mon Sep 17 00:00:00 2001 From: Ikki Nishihara Date: Sat, 10 Oct 2026 02:19:01 +0900 Subject: [PATCH 09/64] Text panel: tab widths follow the drawn label The Transcript / Captions / Graphics tabs were laid out at 7 px per character. That suits Latin text, but Japanese glyphs are about twice as wide, so with the interface in Japanese the labels ran into each other. A tab is now as wide as its label is drawn when that is wider than the old estimate, measured in the semibold the active tab uses so selecting a tab does not move its neighbours. Latin labels keep their old widths. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/panels/text.rs | 48 +++++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/text.rs b/crates/ui-egui/src/panels/text.rs index 4408779d..ba59f9a7 100644 --- a/crates/ui-egui/src/panels/text.rs +++ b/crates/ui-egui/src/panels/text.rs @@ -16,6 +16,15 @@ use crate::theme::Tokens; const TABS: [&str; 3] = ["Transcript", "Captions", "Graphics"]; +/// The width a tab label needs: 7 px per character, which the strip was laid out with for Latin +/// text, or the label as drawn if that is wider (Japanese glyphs are about twice as wide, and a +/// per-character estimate alone let a label run into the next tab). Measured in the semibold the +/// active tab uses, so selecting a tab does not move its neighbours. +fn tab_text_width(painter: &egui::Painter, label: &str) -> f32 { + let drawn = painter.layout_no_wrap(label.to_string(), Tokens::semibold(12.5), Color32::WHITE).size().x; + (label.chars().count() as f32 * 7.0).max(drawn) +} + pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; ui.painter().rect_filled(rect, 0.0, t.panel_bg); @@ -23,7 +32,8 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut x = rect.min.x + 12.0; for tab in TABS { let shown = crate::i18n::t(tab); - let w = shown.chars().count() as f32 * 7.0 + 16.0; + let text_w = tab_text_width(ui.painter(), shown); + let w = text_w + 16.0; let r = Rect::from_min_size(pos2(x, rect.min.y + 4.0), vec2(w, 24.0)); let resp = ui.interact(r, egui::Id::new(("text-tab", tab)), Sense::click()); let active = app.ui.text_tab == tab; @@ -35,7 +45,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { if active { t.text } else { t.text_dim }, ); if active { - ui.painter().line_segment([pos2(r.min.x, r.max.y), pos2(r.min.x + w - 16.0, r.max.y)], Stroke::new(2.0, t.text)); + ui.painter().line_segment([pos2(r.min.x, r.max.y), pos2(r.min.x + text_w, r.max.y)], Stroke::new(2.0, t.text)); } app.auto.add(&format!("text.tab.{tab}"), r, tab); if resp.clicked() { @@ -439,3 +449,37 @@ fn run(app: &mut FilmcraftApp, ui: &egui::Ui, actions: Vec<(String, Value)>) { } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Latin labels keep the 7 px per character they were laid out with; Japanese labels are as + /// wide as they are drawn, so they no longer run into the next tab. + #[test] + fn tab_widths_follow_the_drawn_label() { + let ctx = egui::Context::default(); + crate::theme::install(&ctx, &Tokens::for_kind(crate::theme::ThemeKind::default())); + // Japanese needs a craft-fonts build or an installed font; without one there is nothing wide to measure + if !crate::i18n::install_japanese_font(&ctx) { + return; + } + let labels = ["Transcript", "Captions", "Graphics", "文字起こし", "キャプション", "グラフィックス"]; + let mut widths = Vec::new(); + for _ in 0..2 { + // the added font is in place from the second pass + widths.clear(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + for label in labels { + widths.push(tab_text_width(ui.painter(), label)); + } + }); + out.textures_delta.clear(); + } + assert_eq!(widths[..3], [70.0, 56.0, 56.0], "Latin tabs keep 7 px per character"); + // 5, 6 and 7 Japanese characters of about 12.5 px each, far over the 7 px per character estimate + for ((width, chars), label) in widths[3..].iter().zip([5.0_f32, 6.0, 7.0]).zip(&labels[3..]) { + assert!(*width > chars * 7.0 * 1.5, "{label}: {width} px"); + } + } +} From c724bc5566c8d30e40e1b563af24d6c13afa461e Mon Sep 17 00:00:00 2001 From: MrCloudy2 Date: Fri, 9 Oct 2026 19:36:54 +0200 Subject: [PATCH 10/64] #447: HybridDecoder fallback replay goes on past samples the software decoder rejects After a hardware decoder failed, the replay of the run through the software decoder stopped at the first sample the software decoder rejected (`?` in the replay loop): the pictures it had already output were dropped, the rest of the run was never fed, the error was returned for the failed sample even when the software decoder accepted it, and the replay log was not restored. That happens when the GPU tolerated a damaged sample our decoder rejects and then fails later in the same run; one picture went missing on a damaged HEVC file through VA-API (Intel). The replay now behaves like software decoding from the restart point: a rejected sample is skipped (logged when the hardware had already decoded it); if the failed sample itself is rejected its error is this call's, and the pictures replayed before it are kept in `carry` for the next output. Regression test (every platform, no GPU): a stand-in hardware decoder that decodes one damaged sample the software decoder rejects and fails at that sample or later; every software picture comes out once and in order, errors are reported where the software decoder reports them, and the pictures from the next restart point are the software decoder's. Co-Authored-By: Claude Opus 5.5 --- crates/platform/src/hybrid.rs | 27 ++++++-- crates/platform/tests/fallback.rs | 108 +++++++++++++++++++++++++++++- 2 files changed, 130 insertions(+), 5 deletions(-) diff --git a/crates/platform/src/hybrid.rs b/crates/platform/src/hybrid.rs index 07354918..c010e79e 100644 --- a/crates/platform/src/hybrid.rs +++ b/crates/platform/src/hybrid.rs @@ -129,7 +129,10 @@ impl HybridDecoder { } /// Switch to the software decoder after `err`, replaying the log (which ends with the sample - /// that failed). + /// that failed). The replay goes on past samples the software decoder rejects, as decoding in + /// software from the restart point would: one that the hardware had already decoded is logged + /// (its call returned long ago), and if the failed sample itself is rejected its error is this + /// call's, while the pictures replayed before it are kept for the next output. fn fall_back(&mut self, err: CodecError) -> Result> { let Some(mut hw) = self.hw.take() else { return Err(err) }; log::warn!("{} failed ({err}); continuing with the software decoder", hw.name()); @@ -149,12 +152,28 @@ impl HybridDecoder { return Err(CodecError::Decode(format!("{err} (continuing in software after the next seek)"))); }; let mut frames = Vec::new(); - for (s, p) in &log { + let mut rejected = None; + let last = log.len().saturating_sub(1); + for (i, (s, p)) in log.iter().enumerate() { let Some(sw) = self.sw.as_mut() else { break }; - frames.extend(sw.decode(s, *p)?); + match sw.decode(s, *p) { + Ok(out) => frames.extend(out), + Err(e) if i == last => rejected = Some(e), + Err(e) => log::warn!("software replay after the hardware decoder failed: {e}"), + } } self.log = Some(log); - Ok(self.merge(frames)) + match rejected { + None => Ok(self.merge(frames)), + Some(e) => { + for f in frames { + if !self.emitted.contains(&f.pts) { + self.carry.insert(f.pts, f); + } + } + Err(e) + } + } } } diff --git a/crates/platform/tests/fallback.rs b/crates/platform/tests/fallback.rs index f6005c14..95d8e430 100644 --- a/crates/platform/tests/fallback.rs +++ b/crates/platform/tests/fallback.rs @@ -1,7 +1,9 @@ //! [`HybridDecoder`] fallback on every platform, with a stand-in "hardware" decoder (our software //! decoder that starts failing after N samples, as a lost hardware session does): the output must //! be exactly the software decoder's, whatever sample the failure hits. Also: in-band parameter -//! sets that differ from the sample entry's switch to software; identical ones do not. +//! sets that differ from the sample entry's switch to software; identical ones do not; and a +//! damaged sample that the hardware decoded but the software decoder rejects, inside the replayed +//! run, loses no picture. mod common; @@ -105,3 +107,107 @@ fn changed_in_band_parameter_sets_switch_to_software() { assert!(!d.is_hardware(), "changed SPS: software"); assert_same("changed SPS", &out, &decode_all(filmcraft_codecs::software_video_decoder(&s.entry).unwrap().as_mut(), &changed)); } + +/// Like [`Failing`], but tolerant of one damaged sample the way a GPU can be: at `damaged_at` it +/// decodes the intact sample instead of the one it is given (which the software decoder rejects). +struct Tolerant { + inner: Box, + intact: Vec, + damaged_at: usize, + fail_after: usize, + fed: usize, +} + +impl VideoDecoder for Tolerant { + fn decode(&mut self, sample: &[u8], pts: i64) -> Result> { + let i = self.fed; + self.fed += 1; + if self.fed > self.fail_after { + return Err(filmcraft_codecs::CodecError::Decode("session invalidated (test)".into())); + } + self.inner.decode(if i == self.damaged_at { &self.intact } else { sample }, pts) + } + fn flush(&mut self) -> Vec { + self.inner.flush() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn name(&self) -> &str { + "stand-in hardware" + } +} + +/// Feed `samples` then flush, carrying on past samples the decoder rejects (as playback does); +/// returns the pictures and the indices of the rejected samples. +fn decode_lenient(d: &mut dyn VideoDecoder, samples: &[(Vec, i64)]) -> (Vec, Vec) { + let (mut out, mut rejected) = (Vec::new(), Vec::new()); + for (i, (s, p)) in samples.iter().enumerate() { + match d.decode(s, *p) { + Ok(f) => out.extend(f), + Err(_) => rejected.push(i), + } + } + out.extend(d.flush()); + (out, rejected) +} + +/// The sample's NAL unit with its header kept and its payload overwritten: the slice header no +/// longer parses, so the software decoder rejects the sample. +fn damaged(sample: &[u8], nal_header: usize) -> Vec { + let mut v = sample.to_vec(); + let start = (4 + nal_header).min(v.len()); + v[start..].fill(0xff); + v +} + +/// A replay (after the hardware fails) that meets a sample the software decoder rejects must go +/// on to the end of the replayed run, like decoding in software does, and must not lose the +/// pictures it decoded before that sample: every picture the software decoder outputs for the +/// stream is output, once and in order, and from the next restart point on the pictures are the +/// software decoder's. +#[test] +fn a_sample_software_rejects_during_the_replay_loses_no_pictures() { + let ff = filmcraft_testkit::require_ffmpeg!(); + let mut checked = 0; + for (name, _) in FIXTURES { + let Some(path) = named(&ff, name) else { continue }; + let s = read_stream(&path); + let info = NalStreamInfo::from_entry(&s.entry).unwrap().unwrap(); + let header = if name.starts_with("hevc") { 2 } else { 1 }; + // the second restart point, and a disposable picture before it that is not the first sample + let Some(next) = (1..s.samples.len()).find(|&i| s.sync[i]) else { continue }; + let Some(d) = (2..next.saturating_sub(2)).find(|&i| info.is_disposable(&s.samples[i].0)) else { continue }; + let mut stream = s.samples.clone(); + stream[d].0 = damaged(&s.samples[d].0, header); + let (reference, rejected) = decode_lenient(filmcraft_codecs::software_video_decoder(&s.entry).unwrap().as_mut(), &stream); + assert_eq!(rejected, [d], "{name}: the software decoder rejects the damaged sample (and only it)"); + let restart = stream[next..].iter().map(|x| x.1).min().unwrap(); + // failing at the damaged sample itself (the software decoder rejects the failed sample + // too), and after the hardware had decoded it + for fail_after in [d, d + 1, d + 2, next - 1] { + let tolerant = Tolerant { + inner: filmcraft_codecs::software_video_decoder(&s.entry).unwrap(), + intact: s.samples[d].0.clone(), + damaged_at: d, + fail_after, + fed: 0, + }; + let mut h = HybridDecoder::new(Box::new(tolerant), s.entry.clone(), info.clone()); + let (out, errors) = decode_lenient(&mut h, &stream); + let what = format!("{name}: sample {d} damaged, hardware failing after {fail_after}"); + assert!(!h.is_hardware(), "{what}: fell back"); + // the damaged sample is reported as the software decoder reports it, unless the + // hardware had already decoded it + assert_eq!(errors, if fail_after == d { vec![d] } else { vec![] }, "{what}: rejected samples"); + let pts: Vec = out.iter().map(|f| f.pts).collect(); + assert!(pts.windows(2).all(|w| w[0] < w[1]), "{what}: pictures once and in order: {pts:?}"); + let lost: Vec = reference.iter().map(|f| f.pts).filter(|p| !pts.contains(p)).collect(); + assert!(lost.is_empty(), "{what}: lost pictures at pts {lost:?}"); + let from = |v: &[DecodedFrame]| v.iter().position(|f| f.pts >= restart).unwrap_or(v.len()); + assert_same(&format!("{what}, from the next restart point"), &out[from(&out)..], &reference[from(&reference)..]); + } + checked += 1; + } + assert_eq!(checked, FIXTURES.len(), "every fixture has a damaged-sample case"); +} From c855c50ba96d6bb76742545e216701c0d7eed4f5 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 10 Oct 2026 04:37:44 +1100 Subject: [PATCH 11/64] Export sequence markers to a spreadsheet-safe CSV. The report is sorted, uses the sequence timecode, escapes formula-like text, and does not change the edit. --- README.md | 2 +- crates/engine/src/commands.rs | 9 + crates/engine/src/lib.rs | 1 + crates/engine/src/marker_export.rs | 311 +++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 5 +- crates/ui-egui/src/menus.rs | 14 +- crates/ui-egui/tests/export_menu_ui.rs | 62 ++++- docs/agents.md | 17 ++ 8 files changed, 411 insertions(+), 10 deletions(-) create mode 100644 crates/engine/src/marker_export.rs diff --git a/README.md b/README.md index 8c498e3d..a4381a8d 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ FilmCraft is a non-linear editor for people who know Premiere: the same panels, - **Three-point editing.** Mark In and Out in the Source monitor, then insert (`,`) or overwrite (`.`) onto the patched tracks. Lift (`;`) and extract (`'`) take ranges back out. - **Every trim.** Ripple, roll, slip, slide, rate stretch and razor tools. Trim mode selects edit points as ripple, roll or trim, nudges them a frame at a time (`⌥←` `⌥→`, ×5 with `⇧`), toggles the trim type with `⌃T` and extends them to the playhead with `E`. `Q` and `W` ripple-trim to the playhead. The **Trim Monitor** shows both sides of the edit, and **dynamic trimming** trims live while it plays: `L` forward, `J` back, `K` to stop and commit as one undo step. - **Exact time.** Every edit is computed on integer ticks: 254,016,000,000 per second, which divides evenly by every common frame rate and sample rate. 23.976, 29.97 drop-frame and 59.94 are exact, not approximate. -- **The details pros rely on.** Markers with colours, names and durations; add edit (`⌘K`) on one or all tracks; nesting; copy, paste and paste insert; ripple delete and close gap; snapping; unlimited undo with a History panel. +- **The details pros rely on.** Markers with colours, names and durations; export sequence review notes with **Markers ▸ Export Markers as CSV…**; add edit (`⌘K`) on one or all tracks; nesting; copy, paste and paste insert; ripple delete and close gap; snapping; unlimited undo with a History panel. - **Your keys.** A Keyboard Shortcuts editor (`⌥⌘K`) with a drawn keyboard, panel-specific shortcuts, conflict warnings and presets for FilmCraft, Premiere Pro, Final Cut Pro and Avid key layouts. - **Never lose work.** Saves are atomic, auto-save keeps a rolling set of versions, and a crash-recovery journal written about a second after each edit brings back unsaved changes after a crash or power cut. diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index b5e4c847..0cc155b0 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1837,6 +1837,15 @@ fn build() -> Vec { })?; Ok(Value::Null) }), + cmd!( + "markers.exportCsv", + "Export Markers as CSV…", + ["Markers"], + None, + r#"{"path":str} (all markers in the active sequence)"#, + has_seq, + crate::marker_export::export + ), cmd!("markers.clearAll", "Clear Markers", ["Markers"], Some("Cmd+Alt+Shift+M"), r#"{"target":"program|source"}"#, always, |s, p| { if let Some(v) = crate::source_monitor::route(s, "markers.clearAll", p)? { return Ok(v); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2578420b..3df16723 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -23,6 +23,7 @@ pub mod graphic_templates; pub mod graphics; pub mod interchange; pub mod keyboard; +mod marker_export; pub mod masks; pub mod media_browser; pub mod media_pool; diff --git a/crates/engine/src/marker_export.rs b/crates/engine/src/marker_export.rs new file mode 100644 index 00000000..44012543 --- /dev/null +++ b/crates/engine/src/marker_export.rs @@ -0,0 +1,311 @@ +//! Sequence review notes as a spreadsheet-safe CSV, using the host's atomic file service. + +use filmcraft_project::Label; +use filmcraft_time::{Tick, format_timecode_frames}; +use serde_json::{Value, json}; + +use crate::commands::bad; +use crate::{EngineError, Result, Session}; + +const CMD: &str = "markers.exportCsv"; +const MAX_BYTES: usize = 16 * 1024 * 1024; +const MAX_MARKERS: usize = 100_000; + +fn label_name(s: &Session, color: Label) -> &str { + s.prefs.labels.colors.get(&crate::settings::label_id(color)).map(|c| c.name.trim()).filter(|n| !n.is_empty()).unwrap_or(color.name()) +} + +pub(crate) fn export(s: &mut Session, p: &Value) -> Result { + let path = p + .get("path") + .and_then(Value::as_str) + .filter(|v| !v.trim().is_empty() && !v.contains('\0')) + .ok_or_else(|| bad(CMD, "need a non-empty `path` without NUL characters"))?; + let seq = s.active_sequence().ok_or(EngineError::NoSequence)?; + seq.settings.validate().map_err(|e| bad(CMD, e))?; + let name = s.state.active_sequence.and_then(|id| s.project.item(id)).map(|i| i.name.as_str()).unwrap_or("Sequence"); + if seq.markers.len() > MAX_MARKERS { + return Err(bad(CMD, "too many markers (maximum 100000)")); + } + // Bound the worst-case quoted output before sorting or allocating it. 1024 covers numeric, + // timecode and enum fields; arbitrary text may double in size when every character is a quote. + let mut bound = 512usize; + for m in &seq.markers { + let row = name + .len() + .checked_add(m.name.len()) + .and_then(|n| n.checked_add(m.comment.len())) + .and_then(|n| n.checked_add(label_name(s, m.color).len())) + .and_then(|n| n.checked_mul(2)) + .and_then(|n| n.checked_add(1024)); + bound = row.and_then(|n| bound.checked_add(n)).filter(|n| *n <= MAX_BYTES).ok_or_else(|| bad(CMD, "marker report exceeds the 16 MiB limit"))?; + } + let rate = seq.settings.frame_rate; + let mut markers: Vec<_> = seq.markers.iter().collect(); + markers.sort_by_key(|m| (m.start, m.id)); + let mut csv = + String::from("Sequence,Marker ID,Name,Comment,Kind,Color,Start Timecode,End Timecode,Duration Frames,Start Ticks,Duration Ticks,Frame Rate\r\n"); + for m in markers { + if m.start.0 < 0 || m.duration.0 < 0 { + return Err(bad(CMD, format!("marker {} has a negative start or duration", m.id.0))); + } + let end = m.start.0.checked_add(m.duration.0).ok_or_else(|| bad(CMD, format!("marker {} end overflows", m.id.0)))?; + let tc = |t: Tick| -> Result { + let frame = rate.frame_at(t).checked_add(seq.start_timecode).ok_or_else(|| bad(CMD, "sequence timecode overflows"))?; + Ok(format_timecode_frames(frame, rate, seq.settings.drop_frame)) + }; + let fields = [ + name.to_string(), + m.id.0.to_string(), + m.name.clone(), + m.comment.clone(), + format!("{:?}", m.kind), + label_name(s, m.color).to_string(), + tc(m.start)?, + tc(Tick(end))?, + rate.frame_at(m.duration).to_string(), + m.start.0.to_string(), + m.duration.0.to_string(), + format!("{}/{}", rate.num, rate.den), + ]; + for (i, field) in fields.iter().enumerate() { + if i > 0 { + csv.push(','); + } + csv.push('"'); + // Quoting alone does not stop spreadsheet formulas. Escape only arbitrary text, + // including formulas preceded by whitespace; numeric/timecode fields stay numeric. + if matches!(i, 0 | 2 | 3 | 5) && field.trim_start().starts_with(['=', '+', '-', '@']) { + csv.push('\''); + } + for ch in field.chars() { + if ch == '"' { + csv.push('"'); + } + csv.push(ch); + } + csv.push('"'); + } + csv.push_str("\r\n"); + } + s.services.write_file(path, csv.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + Ok(json!({"path": path, "markers": seq.markers.len(), "bytes": csv.len()})) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Services; + use filmcraft_project::{Label, Marker, MarkerId, MarkerKind}; + use filmcraft_time::FrameRate; + use std::sync::{Arc, Mutex}; + + #[derive(Default)] + struct Files { + writes: Mutex)>>, + fail: bool, + } + impl Services for Files { + fn read_file(&self, _: &str) -> std::io::Result> { + Err(std::io::ErrorKind::NotFound.into()) + } + fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> { + if self.fail { + return Err(std::io::ErrorKind::PermissionDenied.into()); + } + self.writes.lock().unwrap().push((path.into(), data.to_vec())); + Ok(()) + } + } + fn session(files: Arc) -> Session { + let mut s = Session::default(); + s.services = files; + s.execute("file.newSequence", json!({"name":"Review, 日本語", "fps":24})).unwrap(); + s + } + fn marker(id: u64, start: Tick, duration: Tick, name: &str) -> Marker { + Marker { + id: MarkerId(id), + start, + duration, + name: name.into(), + comment: "First line\r\nSecond, \"quoted\"".into(), + kind: MarkerKind::Chapter, + color: Label::Iris, + } + } + fn set_markers(s: &mut Session, markers: Vec) { + s.edit_sequence("Review markers", |q, _, _| { + q.markers = markers; + Ok(()) + }) + .unwrap(); + } + fn written(files: &Files) -> String { + String::from_utf8(files.writes.lock().unwrap().last().unwrap().1.clone()).unwrap() + } + + #[test] + fn command_exports_sorted_unicode_review_notes_without_changing_the_edit() { + let files = Arc::new(Files::default()); + let mut s = session(files.clone()); + let rate = FrameRate::FPS_24; + set_markers(&mut s, vec![marker(100, rate.tick_of(48), rate.tick_of(24), "Later"), marker(101, rate.tick_of(24), Tick::ZERO, "早い")]); + s.execute("markers.markIn", json!({"frame":12})).unwrap(); + s.state.hidden_marker_colors.push(Label::Iris); + let before = s.project.clone(); + let state = serde_json::to_value(&s.state).unwrap(); + let undo = s.history.undo.len(); + let result = s.execute(CMD, json!({"path":"review.csv"})).unwrap(); + let csv = written(&files); + assert_eq!(result["markers"], 2); + assert_eq!(result["bytes"], csv.len()); + assert!(csv.contains("\"Review, 日本語\",\"101\",\"早い\",\"First line\r\nSecond, \"\"quoted\"\"\"")); + assert!(csv.find("\"101\"").unwrap() < csv.find("\"100\"").unwrap()); + assert!(csv.contains("\"00:00:02:00\",\"00:00:03:00\",\"24\",\"508032000000\",\"254016000000\",\"24/1\"\r\n")); + assert_eq!(*s.project, *before); + assert_eq!(serde_json::to_value(&s.state).unwrap(), state); + assert_eq!(s.history.undo.len(), undo); + s.undo(); + s.redo(); + assert_eq!(*s.project, *before); + } + + #[test] + fn drop_frame_and_sequence_start_timecode_are_used_for_both_bounds() { + let files = Arc::new(Files::default()); + let mut s = session(files.clone()); + let rate = FrameRate::FPS_29_97; + s.edit_sequence("Timing", |q, _, _| { + q.settings.frame_rate = rate; + q.settings.drop_frame = true; + q.start_timecode = 107892; + q.markers = vec![marker(100, rate.tick_of(1800), rate.tick_of(30), "DF")]; + Ok(()) + }) + .unwrap(); + s.execute(CMD, json!({"path":"df.csv"})).unwrap(); + assert!(written(&files).contains("\"01;01;00;02\",\"01;01;01;02\",\"30\"")); + assert!(written(&files).contains("\"30000/1001\"")); + } + + #[test] + fn spreadsheet_formulas_are_escaped_in_all_user_text_columns() { + let files = Arc::new(Files::default()); + let mut s = session(files.clone()); + for text in ["=1+1", "+1+1", "-1+1", "@SUM(1)", "\t =1+1"] { + let mut m = marker(100, Tick::ZERO, Tick::ZERO, text); + m.comment = text.into(); + set_markers(&mut s, vec![m]); + let id = s.state.active_sequence.unwrap(); + s.edit("Name", |p, _| { + p.items.get_mut(&id).unwrap().name = text.into(); + Ok(()) + }) + .unwrap(); + s.execute(CMD, json!({"path":"safe.csv"})).unwrap(); + assert_eq!(written(&files).matches(&format!("\"'{text}\"")).count(), 3); + s.prefs.labels.colors.get_mut("iris").unwrap().name = text.into(); + s.execute(CMD, json!({"path":"safe-label.csv"})).unwrap(); + assert!(written(&files).contains(&format!("\"Chapter\",\"'{}\"", text.trim()))); + s.prefs.labels.colors.get_mut("iris").unwrap().name = "Iris".into(); + } + } + + #[test] + fn empty_sequence_writes_a_header_and_no_sequence_is_disabled() { + let files = Arc::new(Files::default()); + let mut s = session(files.clone()); + let spec = crate::find_command(CMD).unwrap(); + assert_eq!(spec.menu, ["Markers"]); + assert!(spec.params.contains("path")); + assert!(spec.shortcut.is_none()); + assert_eq!(s.execute(CMD, json!({"path":"empty.csv"})).unwrap()["markers"], 0); + assert_eq!(written(&files).lines().count(), 1); + assert!((spec.enabled)(&Session::default()).is_err()); + assert!(matches!(Session::default().execute(CMD, json!({"path":"none.csv"})), Err(EngineError::Disabled(..)))); + } + + #[test] + fn hostile_parameters_and_damaged_timing_fail_before_writing() { + let files = Arc::new(Files::default()); + let mut s = session(files.clone()); + for p in [Value::Null, json!({}), json!({"path":0}), json!({"path":" "}), json!({"path":"bad\0.csv"})] { + assert!(std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| s.execute(CMD, p))).unwrap().is_err()); + } + let id = s.state.active_sequence.unwrap(); + for (start, duration) in [(-1, 0), (0, -1), (i64::MAX, 1)] { + Arc::make_mut(&mut s.project).sequence_mut(id).unwrap().markers = vec![marker(100, Tick(start), Tick(duration), "Bad")]; + assert!(s.execute(CMD, json!({"path":"bad.csv"})).is_err()); + } + let q = Arc::make_mut(&mut s.project).sequence_mut(id).unwrap(); + q.markers = vec![marker(100, Tick::ZERO, Tick::ZERO, "Bad")]; + q.settings.frame_rate.num = i64::MAX; + assert!(s.execute(CMD, json!({"path":"bad.csv"})).is_err()); + let q = Arc::make_mut(&mut s.project).sequence_mut(id).unwrap(); + q.settings.frame_rate = FrameRate::FPS_24; + q.start_timecode = i64::MAX; + q.markers[0].start = FrameRate::FPS_24.tick_of(1); + assert!(s.execute(CMD, json!({"path":"bad.csv"})).is_err()); + q_reset(&mut s, id); + assert!(files.writes.lock().unwrap().is_empty()); + s.execute(CMD, json!({"path":"recovered.csv"})).unwrap(); + } + fn q_reset(s: &mut Session, id: filmcraft_project::ItemId) { + let q = Arc::make_mut(&mut s.project).sequence_mut(id).unwrap(); + q.start_timecode = 0; + q.markers.clear(); + } + + #[test] + fn oversized_reports_and_write_errors_are_actionable() { + let files = Arc::new(Files::default()); + let mut s = session(files.clone()); + set_markers(&mut s, vec![marker(100, Tick::ZERO, Tick::ZERO, &"x".repeat(MAX_BYTES / 2))]); + assert!(s.execute(CMD, json!({"path":"huge.csv"})).unwrap_err().to_string().contains("16 MiB")); + assert!(files.writes.lock().unwrap().is_empty()); + s.services = Arc::new(Files { fail: true, ..Default::default() }); + set_markers(&mut s, vec![]); + s.saved_revision = s.revision; + assert!(!s.is_dirty()); + let before = s.project.clone(); + assert!(s.execute(CMD, json!({"path":"denied.csv"})).unwrap_err().to_string().contains("denied.csv")); + assert_eq!(*s.project, *before); + assert!(!s.is_dirty()); + } + + #[test] + fn locked_tracks_custom_labels_and_unusual_paths_export_without_dirtying() { + let files = Arc::new(Files::default()); + let mut s = session(files.clone()); + let rate = FrameRate::FPS_23_976; + s.edit_sequence("Locked review", |q, _, _| { + q.settings.frame_rate = rate; + for t in q.video_tracks.iter_mut().chain(&mut q.audio_tracks) { + t.locked = true; + } + q.markers = + vec![marker(101, Tick(rate.tick_of(24).0 + 1), Tick(1), "A, \"B\"\n字幕"), marker(100, Tick(rate.tick_of(24).0 + 1), Tick::ZERO, "Point")]; + Ok(()) + }) + .unwrap(); + s.prefs.labels.colors.get_mut("iris").unwrap().name = "Review, \"urgent\"\n紫".into(); + let id = s.state.active_sequence.unwrap(); + s.edit("Review name", |p, _| { + p.items.get_mut(&id).unwrap().name = "Cut, \"night\"\n日本語".into(); + Ok(()) + }) + .unwrap(); + s.saved_revision = s.revision; + let path = "review, \"notes\"\n日本語.csv"; + s.execute(CMD, json!({"path":path})).unwrap(); + assert_eq!(files.writes.lock().unwrap().last().unwrap().0, path); + let csv = written(&files); + assert!(csv.contains("\"Cut, \"\"night\"\"\n日本語\"")); + assert!(csv.contains("\"Review, \"\"urgent\"\"\n紫\"")); + assert!(csv.contains("\"A, \"\"B\"\"\n字幕\"")); + assert!(csv.find("\"100\"").unwrap() < csv.find("\"101\"").unwrap()); + assert!(csv.contains(&format!("\"00:00:01:00\",\"00:00:01:00\",\"0\",\"{}\",\"1\",\"24000/1001\"", rate.tick_of(24).0 + 1))); + assert!(!s.is_dirty()); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 83d18261..66b31fd8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -204,9 +204,10 @@ const SCREENSHOT_TIMEOUT_S: f64 = 10.0; /// timeline zoom animation finished) before capturing what is there. const SCREENSHOT_SETTLE_MAX_S: f64 = 5.0; -/// File ▸ Export entries that run from the menus through the save panel: command, filter label, +/// Export entries that run from the menus through the save panel: command, filter label, /// extension. -pub(crate) const EXPORT_SAVE_DIALOGS: [(&str, &str, &str); 6] = [ +pub(crate) const EXPORT_SAVE_DIALOGS: [(&str, &str, &str); 7] = [ + ("markers.exportCsv", "Marker report (CSV)", "csv"), ("file.exportEdl", "EDL", "edl"), ("file.exportFcp7Xml", "Final Cut Pro XML", "xml"), ("file.exportFcpxml", "FCPXML", "fcpxml"), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d6046935..035d4980 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -695,13 +695,14 @@ pub fn menu_bar(app: &mut FilmcraftApp, ui: &mut egui::Ui) { egui::MenuBar::new().config(egui::containers::menu::MenuConfig::new().style(crate::theme::menu_style)).ui(ui, |ui| { for top in MENUS { let mine: Vec<&MenuItem> = items.iter().filter(|i| i.path.first().map(String::as_str) == Some(top)).collect(); - ui.menu_button(app.ui.language.tr(top), |ui| { + let r = ui.menu_button(app.ui.language.tr(top), |ui| { ui.set_min_width(260.0); if mine.is_empty() { ui.add_enabled(false, egui::Button::new(tl!("(empty)"))); } - menu_level(ui, &mine, 1, &mut clicked); + menu_level(ui, &mine, 1, &mut clicked, &mut app.auto); }); + app.auto.add(&format!("menu.{top}"), r.response.rect, top); } }); if let Some(id) = clicked { @@ -711,7 +712,7 @@ pub fn menu_bar(app: &mut FilmcraftApp, ui: &mut egui::Ui) { /// One menu level: items whose path ends here, and a submenu (at its first item's position) for /// each deeper path segment, recursively (e.g. Clip ▸ Video Options ▸ Time Interpolation). -fn menu_level(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, clicked: &mut Option) { +fn menu_level(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, clicked: &mut Option, auto: &mut crate::automation::Registry) { let mut subs: Vec<&str> = Vec::new(); for it in items { if let Some(sub) = it.path.get(depth).map(String::as_str) { @@ -723,16 +724,16 @@ fn menu_level(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, clicked: &mu let language = ui.ctx().data(|d| d.get_temp::(egui::Id::new("interface-language"))).unwrap_or_default(); ui.menu_button(language.tr(sub), |ui| { ui.set_min_width(220.0); - menu_level(ui, &inner, depth + 1, clicked); + menu_level(ui, &inner, depth + 1, clicked, auto); }); - } else if menu_entry(ui, it) { + } else if menu_entry(ui, it, auto) { *clicked = Some(it.id.clone()); ui.close(); } } } -fn menu_entry(ui: &mut egui::Ui, it: &MenuItem) -> bool { +fn menu_entry(ui: &mut egui::Ui, it: &MenuItem, auto: &mut crate::automation::Registry) -> bool { // checkable items leave room for a checkmark drawn at the left let label = if it.checked.is_some() { format!(" {}", it.label) } else { it.label.clone() }; let mut b = egui::Button::new(label); @@ -740,6 +741,7 @@ fn menu_entry(ui: &mut egui::Ui, it: &MenuItem) -> bool { b = b.shortcut_text(shortcut_text(s)); } let r = ui.add_enabled(it.enabled, b); + auto.add(&format!("menu.{}", it.id), r.rect, &it.label); if it.checked == Some(true) { let c = r.rect.left_center() + egui::vec2(10.0, 0.0); let col = ui.visuals().text_color(); diff --git a/crates/ui-egui/tests/export_menu_ui.rs b/crates/ui-egui/tests/export_menu_ui.rs index 4c1ef8cc..75122089 100644 --- a/crates/ui-egui/tests/export_menu_ui.rs +++ b/crates/ui-egui/tests/export_menu_ui.rs @@ -29,7 +29,11 @@ impl Driver { asked.lock().unwrap().push((filter.to_string(), exts.iter().map(|e| e.to_string()).collect(), suggested.to_string())); save_to.as_ref().map(|d| d.join(suggested).to_string_lossy().into_owned()) })); - let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000).build_eframe(move |_cc| app); + let mut builder = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000); + if std::env::var_os("FILMCRAFT_UI_SNAPSHOT_DIR").is_some() { + builder = builder.with_wgpu_render(); + } + let harness = builder.build_eframe(move |_cc| app); let mut d = Driver { harness, tx }; d.frames(4); d @@ -142,3 +146,59 @@ fn file_export_entries_need_an_open_sequence() { assert_eq!(d.harness.state().ui.mode, Mode::Edit, "{id}"); } } + +#[test] +fn marker_csv_menu_exports_sequence_review_notes() { + let dir = tmp("markers"); + let asked = Asked::default(); + let mut s = session(); + s.execute("markers.clearAll", json!({"target":"program"})).unwrap(); + s.execute("markers.add", json!({"target":"program", "frame":24, "name":"Review, 日本語", "comment":"Change \"this\"\nNext line"})).unwrap(); + let before = s.project.clone(); + let undo = s.history.undo.len(); + let mut d = Driver::new(s, Some(dir.clone()), asked.clone()); + let menu = d.ok("ui.menu.list", json!({})); + let entry = menu.as_array().unwrap().iter().find(|i| i["id"] == "markers.exportCsv").unwrap(); + assert_eq!(entry["path"], json!(["Markers"])); + assert_eq!(entry["enabled"], true); + d.ok("ui.click", json!({"id":"menu.Markers"})); + d.frames(3); + let elements = d.ok("ui.elements", json!({"prefix":"menu.markers.exportCsv"})); + assert!(!elements.as_array().unwrap().is_empty(), "the menu command has an automation id"); + if let Some(dir) = std::env::var_os("FILMCRAFT_UI_SNAPSHOT_DIR") { + let dir = std::path::PathBuf::from(dir); + std::fs::create_dir_all(&dir).unwrap(); + d.harness.render().unwrap().save(dir.join("marker-csv-menu.png")).unwrap(); + } + d.ok("ui.click", json!({"id":"menu.markers.exportCsv"})); + d.frames(3); + let (filter, exts, suggested) = asked.lock().unwrap().last().cloned().unwrap(); + assert_eq!(filter, "Marker report (CSV)"); + assert_eq!(exts, ["csv"]); + assert!(suggested.ends_with(".csv")); + let bytes = std::fs::read(dir.join(suggested)).unwrap(); + let csv = String::from_utf8(bytes).unwrap(); + assert!(csv.contains("\"Review, 日本語\",\"Change \"\"this\"\"\nNext line\"")); + assert_eq!(csv.matches("\"Review, 日本語\"").count(), 1); + assert_eq!(*d.harness.state().session.project, *before); + assert_eq!(d.harness.state().session.history.undo.len(), undo); + assert!(d.harness.state().ui.status.starts_with("Exported ")); + let direct = dir.join("direct, 字幕.csv"); + d.ok("engine.execute", json!({"command":"markers.exportCsv", "params":{"path":direct.to_string_lossy()}})); + assert_eq!(std::fs::read(direct).unwrap(), csv.as_bytes()); + assert_eq!(asked.lock().unwrap().len(), 1); +} + +#[test] +fn marker_csv_save_cancel_and_disabled_menu_leave_the_session_alone() { + let asked = Asked::default(); + let mut d = Driver::new(session(), None, asked.clone()); + let before = d.harness.state().session.project.clone(); + assert_eq!(d.ok("ui.menu.invoke", json!({"id":"markers.exportCsv"})), Value::Null); + assert_eq!(*d.harness.state().session.project, *before); + assert_eq!(asked.lock().unwrap().len(), 1); + let asked = Asked::default(); + let mut d = Driver::new(Session::default(), None, asked.clone()); + assert_eq!(d.call("ui.menu.invoke", json!({"id":"markers.exportCsv"}))["ok"], false); + assert!(asked.lock().unwrap().is_empty(), "disabled exports must not open a save dialog"); +} diff --git a/docs/agents.md b/docs/agents.md index 9ad0b2bc..c0431ff1 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -176,6 +176,7 @@ filmcraft-cli describe timeline.razor # one command: menu, shortcut, filmcraft-cli --demo inspect sequence # project / sequence as JSON filmcraft-cli --project p.fcproj --save exec timeline.razor seconds=3.5 filmcraft-cli --project p.fcproj exec effects.apply '{"effect":"Gaussian Blur"}' +filmcraft-cli --project p.fcproj exec markers.exportCsv path=review.csv filmcraft-cli --project p.fcproj --save import a.mov b.wav filmcraft-cli --project p.fcproj export out.mp4 # format from the extension; waits for the job filmcraft-cli --project p.fcproj export out --preset "YouTube 1080p Full HD" --start 0 --end 10 @@ -190,6 +191,22 @@ strings; dotted keys nest (`color.r=1`). `run` prints one JSON line per command (`{"line","id","ok","result"|"error"}`) and stops at the first failure unless `--keep-going`. `--save` writes back to `--project`; `--save-as path` writes elsewhere. +`markers.exportCsv {path}` exports all markers in the active sequence, including colours hidden +in the UI and markers outside In/Out, sorted by start then id. The UTF-8 CSV has a header and CRLF +records; quotes, commas and embedded newlines are escaped. Columns are Sequence, Marker ID, Name, +Comment, Kind, Color, Start Timecode, End Timecode, Duration Frames, Start Ticks, Duration Ticks and +Frame Rate (exact numerator/denominator). Color uses the user's display label from Settings ▸ +Labels. Timecodes include the sequence's start timecode and +drop-frame setting; End is exclusive, and a point marker's End equals Start. Duration Frames is +rounded down; tick columns preserve sub-frame timing (254016000000 ticks per second). +An empty sequence writes just the header. User text starting with a spreadsheet formula character +(`=`, `+`, `-`, `@`, including after whitespace) gets a leading apostrophe in the exported cell. +The export leaves the project, dirty flag and undo history untouched, works with locked tracks, +and uses the host's file service (atomic +on desktop, a download on web). It exports sequence markers only, not clip/source markers; +reports are limited to 100000 markers and a conservative 16 MiB output budget. Invalid timing or a +failed write reports an error. The menu opens the normal save dialog; cancelling writes nothing. + ## 3. Verifying UI work For any UI change, look at the result: From 7a922ab1395cd81e35063fc7b48d0c37ca8fcb06 Mon Sep 17 00:00:00 2001 From: Oskar Date: Fri, 9 Oct 2026 20:43:22 +0200 Subject: [PATCH 12/64] #223: Effect Controls shows the audio of a linked video clip Selecting a video clip with linked selection on also selects its audio, but the panel only listed the video effects. Now the linked audio clip's Volume, Channel Volume and Panner follow under an Audio heading, like in Premiere. --- crates/ui-egui/src/panels/effect_controls.rs | 202 ++++++++++--------- crates/ui-egui/tests/clip_audio_ui.rs | 114 +++++++++++ 2 files changed, 223 insertions(+), 93 deletions(-) create mode 100644 crates/ui-egui/tests/clip_audio_ui.rs diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index e3ae049c..7bd42b6c 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -3,7 +3,7 @@ //! keyframe lane on the right. Also hosts the Lumetri Color panel body (same editor, grouped). use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, pos2, vec2}; -use filmcraft_project::{ClipId, EffectInstance, ParamKind, ParamValue, TrackItem}; +use filmcraft_project::{ClipId, EffectInstance, ParamKind, ParamValue, TrackItem, TrackKind}; use filmcraft_time::Tick; use serde_json::{Value, json}; @@ -28,24 +28,37 @@ fn timeline_time_of(it: &TrackItem, m: Tick) -> Tick { it.start + Tick(((m - it.source_in).0 as f64 / it.speed.abs().max(1e-6)) as i64) } -fn selected_clip(app: &FilmcraftApp) -> Option<(ClipId, TrackItem, filmcraft_project::TrackKind)> { - let seq = app.session.active_sequence()?; - let mut best = None; - for c in &app.session.state.selection { - if let Some((tid, it)) = seq.find_item(*c) { - let kind = seq.track(tid)?.kind; - if kind == filmcraft_project::TrackKind::Video { - return Some((*c, it.clone(), kind)); - } - best.get_or_insert((*c, it.clone(), kind)); - } +fn selected_clip(app: &FilmcraftApp) -> Option<(ClipId, TrackItem, TrackKind)> { + selected_clips(app).into_iter().next() +} + +/// The selected clip (video first) and, for a video clip, the selected audio linked to it. +fn selected_clips(app: &FilmcraftApp) -> Vec<(ClipId, TrackItem, TrackKind)> { + let Some(seq) = app.session.active_sequence() else { return Vec::new() }; + let picked: Vec<(ClipId, &TrackItem, TrackKind)> = app + .session + .state + .selection + .iter() + .filter_map(|c| { + let (tid, it) = seq.find_item(*c)?; + Some((*c, it, seq.track(tid)?.kind)) + }) + .collect(); + let Some(&(clip, it, kind)) = picked.iter().find(|p| p.2 == TrackKind::Video).or(picked.first()) else { return Vec::new() }; + let mut out = vec![(clip, it.clone(), kind)]; + if kind == TrackKind::Video + && let Some(&(ac, ai, ak)) = picked.iter().find(|p| p.2 == TrackKind::Audio && p.1.link.is_some() && p.1.link == it.link) + { + out.push((ac, ai.clone(), ak)); } - best + out } pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; - let Some((clip, it, kind)) = selected_clip(app) else { + let clips = selected_clips(app); + let Some((clip, it, _)) = clips.first().cloned() else { crate::dock::placeholder(ui, rect, &t, tl!("(no clip selected)")); return; }; @@ -97,90 +110,93 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut actions: Vec<(String, Value)> = Vec::new(); let mut bui = ui.new_child(egui::UiBuilder::new().max_rect(body).id_salt("ec-body")); bui.set_clip_rect(Rect::from_min_max(body.min, pos2(rect.max.x, body.max.y))); - let mt_now = it.source_time_at(ph.clamp(it.start, it.end() - Tick(1))); - let heading = if kind == filmcraft_project::TrackKind::Video { tl!("Video") } else { tl!("Audio") }; - bui.painter().text(pos2(body.min.x + 8.0, body.min.y + 8.0), Align2::LEFT_CENTER, heading, Tokens::semibold(11.5), t.text_dim); - bui.add_space(18.0); - // Premiere lists the fixed effects (Motion, Opacity, Time Remapping / Volume…) first. - let mut order: Vec = (0..it.effects.len()).collect(); - order.sort_by_key(|i| !it.effects[*i].def().is_some_and(|d| d.intrinsic)); let scroll_out = egui::ScrollArea::vertical().id_salt("ec-scroll").auto_shrink([false, false]).show(&mut bui, |bui| { - for idx in order { - let e = &it.effects[idx]; - let Some(def) = e.def() else { continue }; - let key = format!("{}:{}", clip.0, idx); - let open = !app.ui.collapsed_fx.contains(&key); - let (r, resp) = bui.allocate_exact_size(vec2(body.width(), ROW_H), Sense::click()); - if resp.hovered() { - bui.painter().rect_filled(r, 0.0, t.hover); - } - icons::paint( - bui.painter(), - Rect::from_center_size(pos2(r.min.x + 10.0, r.center().y), vec2(10.0, 10.0)), - if open { Icon::ChevronDown } else { Icon::ChevronRight }, - t.text_dim, - ); - // fx enable toggle - let fxr = Rect::from_center_size(pos2(r.min.x + 28.0, r.center().y), vec2(18.0, 14.0)); - let fxresp = bui.interact(fxr, egui::Id::new(("fxen", clip.0, idx)), Sense::click()); - bui.painter().text(fxr.center(), Align2::CENTER_CENTER, "fx", Tokens::semibold(10.5), if e.enabled { t.text } else { t.text_faint }); - if !e.enabled { - bui.painter().line_segment([fxr.left_bottom(), fxr.right_top()], Stroke::new(1.0, t.text_faint)); - } - if fxresp.clicked() { - actions.push(("effects.toggleEnabled".into(), json!({"clip": clip.0, "index": idx}))); - } - bui.painter().text(pos2(r.min.x + 42.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::t(def.name), Tokens::ui(12.0), t.text); - // reset button - let rr = Rect::from_center_size(pos2(r.max.x - 14.0, r.center().y), vec2(16.0, 16.0)); - let rresp = bui.interact(rr, egui::Id::new(("fxreset", clip.0, idx)), Sense::click()).on_hover_text(tl!("Reset Effect")); - icons::paint(bui.painter(), rr.shrink(2.0), Icon::Reset, if rresp.hovered() { t.text } else { t.text_dim }); - if rresp.clicked() { - actions.push(("effects.reset".into(), json!({"clip": clip.0, "index": idx}))); - } - app.auto.add(&format!("effectControls.effect.{}", e.effect), r, def.name); - if resp.clicked() && !fxresp.clicked() && !rresp.clicked() { - if open { - app.ui.collapsed_fx.push(key.clone()); - } else { - app.ui.collapsed_fx.retain(|k| *k != key); + for (clip, it, kind) in &clips { + let clip = *clip; + let (hr, _) = bui.allocate_exact_size(vec2(body.width(), 18.0), Sense::hover()); + let heading = if *kind == TrackKind::Video { tl!("Video") } else { tl!("Audio") }; + bui.painter().text(pos2(hr.min.x + 8.0, hr.center().y), Align2::LEFT_CENTER, heading, Tokens::semibold(11.5), t.text_dim); + let mt_now = it.source_time_at(ph.clamp(it.start, (it.end() - Tick(1)).max(it.start))); + // Premiere lists the fixed effects (Motion, Opacity, Time Remapping / Volume…) first. + let mut order: Vec = (0..it.effects.len()).collect(); + order.sort_by_key(|i| !it.effects[*i].def().is_some_and(|d| d.intrinsic)); + for idx in order { + let e = &it.effects[idx]; + let Some(def) = e.def() else { continue }; + let key = format!("{}:{}", clip.0, idx); + let open = !app.ui.collapsed_fx.contains(&key); + let (r, resp) = bui.allocate_exact_size(vec2(body.width(), ROW_H), Sense::click()); + if resp.hovered() { + bui.painter().rect_filled(r, 0.0, t.hover); } - } - let mut save_preset = false; - let fx_id = e.effect.clone(); - resp.context_menu(|ui| { - let sp = ui.button(tl!("Save Preset…")); - app.auto.add(&format!("effectControls.effect.{fx_id}.savePreset"), sp.rect, "Save Preset…"); - if sp.clicked() { - save_preset = true; - ui.close(); + icons::paint( + bui.painter(), + Rect::from_center_size(pos2(r.min.x + 10.0, r.center().y), vec2(10.0, 10.0)), + if open { Icon::ChevronDown } else { Icon::ChevronRight }, + t.text_dim, + ); + // fx enable toggle + let fxr = Rect::from_center_size(pos2(r.min.x + 28.0, r.center().y), vec2(18.0, 14.0)); + let fxresp = bui.interact(fxr, egui::Id::new(("fxen", clip.0, idx)), Sense::click()); + bui.painter().text(fxr.center(), Align2::CENTER_CENTER, "fx", Tokens::semibold(10.5), if e.enabled { t.text } else { t.text_faint }); + if !e.enabled { + bui.painter().line_segment([fxr.left_bottom(), fxr.right_top()], Stroke::new(1.0, t.text_faint)); } - if !def.intrinsic && ui.button(tl!("Clear")).clicked() { - actions.push(("effects.remove".into(), json!({"clip": clip.0, "index": idx}))); - ui.close(); + if fxresp.clicked() { + actions.push(("effects.toggleEnabled".into(), json!({"clip": clip.0, "index": idx}))); } - }); - if save_preset { - crate::panels::presets::open_save(app, clip.0, vec![idx], def.name); - } - if !open { - continue; - } - if crate::panels::audio_fx_editor::has_editor(&e.effect) { - custom_setup_row(app, bui, body, clip, idx, &e.effect); - } - for pd in &def.params { - param_row(app, bui, body, clip, idx, e, None, pd, mt_now, &mut actions, &lane, &lx, &it); - if app.ui.expanded_fx.contains(&graph_key(clip, idx, pd.id)) - && let Some(param) = e.params.get(pd.id) - && param.is_animated() - && matches!(param.value, ParamValue::Float(_)) - { - graph_rows(app, bui, body, clip, idx, None, pd, param, &lane, &it, &mut actions); + bui.painter().text(pos2(r.min.x + 42.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::t(def.name), Tokens::ui(12.0), t.text); + // reset button + let rr = Rect::from_center_size(pos2(r.max.x - 14.0, r.center().y), vec2(16.0, 16.0)); + let rresp = bui.interact(rr, egui::Id::new(("fxreset", clip.0, idx)), Sense::click()).on_hover_text(tl!("Reset Effect")); + icons::paint(bui.painter(), rr.shrink(2.0), Icon::Reset, if rresp.hovered() { t.text } else { t.text_dim }); + if rresp.clicked() { + actions.push(("effects.reset".into(), json!({"clip": clip.0, "index": idx}))); + } + app.auto.add(&format!("effectControls.effect.{}", e.effect), r, def.name); + if resp.clicked() && !fxresp.clicked() && !rresp.clicked() { + if open { + app.ui.collapsed_fx.push(key.clone()); + } else { + app.ui.collapsed_fx.retain(|k| *k != key); + } + } + let mut save_preset = false; + let fx_id = e.effect.clone(); + resp.context_menu(|ui| { + let sp = ui.button(tl!("Save Preset…")); + app.auto.add(&format!("effectControls.effect.{fx_id}.savePreset"), sp.rect, "Save Preset…"); + if sp.clicked() { + save_preset = true; + ui.close(); + } + if !def.intrinsic && ui.button(tl!("Clear")).clicked() { + actions.push(("effects.remove".into(), json!({"clip": clip.0, "index": idx}))); + ui.close(); + } + }); + if save_preset { + crate::panels::presets::open_save(app, clip.0, vec![idx], def.name); + } + if !open { + continue; + } + if crate::panels::audio_fx_editor::has_editor(&e.effect) { + custom_setup_row(app, bui, body, clip, idx, &e.effect); + } + for pd in &def.params { + param_row(app, bui, body, clip, idx, e, None, pd, mt_now, &mut actions, &lane, &lx, it); + if app.ui.expanded_fx.contains(&graph_key(clip, idx, pd.id)) + && let Some(param) = e.params.get(pd.id) + && param.is_animated() + && matches!(param.value, ParamValue::Float(_)) + { + graph_rows(app, bui, body, clip, idx, None, pd, param, &lane, it, &mut actions); + } + } + if crate::panels::masks::maskable(e) { + crate::panels::masks::effect_rows(app, bui, body, clip, idx, e, mt_now, &mut actions, &lane, &lx, it); } - } - if crate::panels::masks::maskable(e) { - crate::panels::masks::effect_rows(app, bui, body, clip, idx, e, mt_now, &mut actions, &lane, &lx, &it); } } }); diff --git a/crates/ui-egui/tests/clip_audio_ui.rs b/crates/ui-egui/tests/clip_audio_ui.rs new file mode 100644 index 00000000..1e8dbc8a --- /dev/null +++ b/crates/ui-egui/tests/clip_audio_ui.rs @@ -0,0 +1,114 @@ +//! Audio of video clips (#223): a selected video clip shows the Volume, Channel Volume and Panner +//! of the audio linked to it as well, as in Premiere. + +use std::sync::mpsc::{Sender, channel}; + +use egui_kittest::Harness; +use filmcraft_engine::Session; +use filmcraft_project::{ClipId, TrackItem}; +use filmcraft_ui_egui::FilmcraftApp; +use filmcraft_ui_egui::control::ControlRequest; +use serde_json::{Value, json}; + +struct Driver { + harness: Harness<'static, FilmcraftApp>, + tx: Sender, +} + +/// The demo's first clip: its video on V1 and the audio linked to it on A1. +struct Pair { + video: u64, + audio: u64, +} + +impl Driver { + fn demo(panels: &[&str]) -> (Self, Pair) { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).expect("demo project"); + let (tx, rx) = channel(); + let app = FilmcraftApp::new(session).with_control(rx); + let harness = Harness::builder().with_size(egui::vec2(1600.0, 1100.0)).with_max_steps(10_000).build_eframe(move |_cc| app); + let mut d = Driver { harness, tx }; + d.frames(4); + let seq = d.exec("sequence.inspect", json!({})); + let v = &seq["video"][0]["items"][0]; + let a = seq["audio"][0]["items"].as_array().unwrap().iter().find(|a| a["link"] == v["link"]).expect("linked audio").clone(); + let pair = Pair { video: v["clip"].as_u64().unwrap(), audio: a["clip"].as_u64().unwrap() }; + for panel in panels { + d.ok("ui.panel.show", json!({"panel": panel})); + } + d.frames(3); + (d, pair) + } + fn frames(&mut self, n: usize) { + for _ in 0..n { + let ctx = self.harness.ctx.clone(); + let mut raw = std::mem::take(self.harness.input_mut()); + eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw); + *self.harness.input_mut() = raw; + self.harness.step(); + } + } + fn call(&mut self, method: &str, params: Value) -> Value { + let (req, reply) = ControlRequest::new(method, params.clone()); + self.tx.send(req).unwrap(); + for _ in 0..600 { + self.frames(1); + if let Ok(v) = reply.try_recv() { + return v; + } + } + panic!("no reply to {method} {params}"); + } + fn ok(&mut self, method: &str, params: Value) -> Value { + let r = self.call(method, params.clone()); + assert_eq!(r["ok"], true, "{method} {params}: {r}"); + r["result"].clone() + } + fn exec(&mut self, command: &str, params: Value) -> Value { + self.ok("engine.execute", json!({"command": command, "params": params})) + } + fn find(&mut self, id: &str) -> Option<[f64; 4]> { + self.frames(2); + let v = self.ok("ui.elements", json!({"prefix": id})); + let e = v.as_array().unwrap().iter().find(|e| e["id"] == id)?; + let r: Vec = e["rect"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect(); + Some([r[0], r[1], r[2], r[3]]) + } + fn rect(&mut self, id: &str) -> [f64; 4] { + self.find(id).unwrap_or_else(|| panic!("no element {id}")) + } + fn click(&mut self, id: &str) { + self.rect(id); + self.ok("ui.click", json!({"id": id})); + self.frames(3); + } + fn item(&self, clip: u64) -> TrackItem { + self.harness.state().session.active_sequence().unwrap().find_item(ClipId(clip)).unwrap().1.clone() + } +} + +#[test] +fn effect_controls_show_the_audio_of_a_video_clip() { + let (mut d, pair) = Driver::demo(&["EffectControls"]); + d.exec("timeline.select", json!({"clips": [pair.video]})); + let motion = d.rect("effectControls.effect.motion"); + let volume = d.rect("effectControls.effect.volume"); + assert!(volume[1] > motion[1], "Audio comes after Video: {volume:?} {motion:?}"); + d.rect("effectControls.effect.channel_volume"); + d.rect("effectControls.effect.panner"); + + // the audio rows edit the audio clip + for fx in ["motion", "opacity", "time_remap"] { + d.click(&format!("effectControls.effect.{fx}")); + } + d.click("effectControls.volume.level.stopwatch"); + assert!(d.item(pair.audio).effect("volume").unwrap().param("level").unwrap().is_animated()); + assert!(d.item(pair.video).effect("volume").is_none()); + + // only the video selected: no audio + d.exec("sequence.linkedSelection", json!({"on": false})); + d.exec("timeline.select", json!({"clips": [pair.video]})); + assert!(d.find("effectControls.effect.volume").is_none()); + d.rect("effectControls.effect.motion"); +} From 01a8eb90a65c0502bfc928e646236fc6fab3561a Mon Sep 17 00:00:00 2001 From: Oskar Date: Fri, 9 Oct 2026 20:44:33 +0200 Subject: [PATCH 13/64] #223: Properties panel shows the audio of a linked video clip Same as Effect Controls: with a video clip and its linked audio selected the Audio section (Level, Pan) now edits the audio clip instead of being hidden behind Transform and Crop. --- crates/ui-egui/src/panels/effect_controls.rs | 15 +++++++++++---- crates/ui-egui/tests/clip_audio_ui.rs | 16 ++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index 7bd42b6c..aeac70d2 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -675,7 +675,8 @@ pub fn lumetri_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { /// Properties panel (Premiere 26): a compact inspector for the selected clip. pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; - let Some((clip, it, kind)) = selected_clip(app) else { + let clips = selected_clips(app); + let Some((clip, it, kind)) = clips.first().cloned() else { crate::dock::placeholder(ui, rect, &t, tl!("Select a clip to see its properties")); return; }; @@ -725,7 +726,7 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { }; let eff_idx = |id: &str| it.effects.iter().position(|e| e.effect == id); let val = |eid: &str, p: &str| it.effect(eid).and_then(|e| e.param(p)).map(|p| p.value_at(mt)); - if kind == filmcraft_project::TrackKind::Video { + if kind == TrackKind::Video { if section(&mut bui, app, "Transform", eff_idx("motion").map(|i| (i, clip.0)), &mut actions) { for (label, p, unit, speed, range) in [ (tl!("Position"), "position", "", 1.0, (-100_000.0, 100_000.0)), @@ -796,7 +797,13 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { properties_nav(app, &mut bui, r, clip, &it, mt, "crop", p, &mut actions); } } - } else if section(&mut bui, app, "Audio", eff_idx("volume").map(|i| (i, clip.0)), &mut actions) { + } + if let Some((clip, it, _)) = clips.iter().find(|c| c.2 == TrackKind::Audio) + && section(&mut bui, app, "Audio", it.effects.iter().position(|e| e.effect == "volume").map(|i| (i, clip.0)), &mut actions) + { + let clip = *clip; + let mt = it.source_time_at(ph.clamp(it.start, (it.end() - Tick(1)).max(it.start))); + let val = |eid: &str, p: &str| it.effect(eid).and_then(|e| e.param(p)).map(|p| p.value_at(mt)); let r = row(&mut bui, tl!("Level")); let mut vui = bui.new_child( egui::UiBuilder::new() @@ -808,7 +815,7 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { if let Some(nv) = nv { actions.push(("effects.setParam".into(), json!({"clip": clip.0, "effect": "volume", "param": "level", "value": nv}))); } - properties_nav(app, &mut bui, r, clip, &it, mt, "volume", "level", &mut actions); + properties_nav(app, &mut bui, r, clip, it, mt, "volume", "level", &mut actions); let r = row(&mut bui, tl!("Pan")); let mut vui = bui.new_child( egui::UiBuilder::new() diff --git a/crates/ui-egui/tests/clip_audio_ui.rs b/crates/ui-egui/tests/clip_audio_ui.rs index 1e8dbc8a..6258905e 100644 --- a/crates/ui-egui/tests/clip_audio_ui.rs +++ b/crates/ui-egui/tests/clip_audio_ui.rs @@ -112,3 +112,19 @@ fn effect_controls_show_the_audio_of_a_video_clip() { assert!(d.find("effectControls.effect.volume").is_none()); d.rect("effectControls.effect.motion"); } + +#[test] +fn properties_show_the_audio_of_a_video_clip() { + let (mut d, pair) = Driver::demo(&["Properties"]); + d.exec("timeline.select", json!({"clips": [pair.video]})); + let scale = d.rect("properties.motion.scale.addKeyframe"); + let level = d.rect("properties.volume.level.addKeyframe"); + assert!(level[1] > scale[1]); + d.click("properties.volume.level.addKeyframe"); + assert_eq!(d.item(pair.audio).effect("volume").unwrap().param("level").unwrap().keyframes.len(), 1); + + d.exec("sequence.linkedSelection", json!({"on": false})); + d.exec("timeline.select", json!({"clips": [pair.audio]})); + d.rect("properties.volume.level.addKeyframe"); + assert!(d.find("properties.motion.scale.addKeyframe").is_none()); +} From 5fbc5cf8e5257c877d5f0d28a4a2ec0145074824 Mon Sep 17 00:00:00 2001 From: Oskar Date: Fri, 9 Oct 2026 20:47:28 +0200 Subject: [PATCH 14/64] #223: keyframe commands can merge a drag into one undo step effects.setKeyframe and effects.moveKeyframe take merge/begin like effects.setParam does, and moveKeyframe can set the value on the way (same as mixer.moveKeyframe). The timeline volume line needs this to update keyframes live while dragging without flooding the undo history. --- crates/engine/src/commands.rs | 14 ++++++++--- crates/engine/src/tests.rs | 45 +++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index b5e4c847..c9655294 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -2497,7 +2497,7 @@ fn build() -> Vec { "Move Keyframe", [], None, - r#"{"clip":id,"effect":str|index,"param":str,"mediaTime":ticks,"to":ticks}"#, + r#"{"clip":id,"effect":str|index,"param":str,"mediaTime":ticks,"to":ticks,"value":any?,"merge":bool?,"begin":bool?}"#, has_seq, |s, p| keyframe_op(s, p, "move") ), @@ -2515,7 +2515,7 @@ fn build() -> Vec { "Edit Keyframe", [], None, - r#"{"clip":id,"effect":str|index,"param":str,"mediaTime":ticks,"value":any?,"inInfluence":0..1?,"outInfluence":0..1?}"#, + r#"{"clip":id,"effect":str|index,"param":str,"mediaTime":ticks,"value":any?,"inInfluence":0..1?,"outInfluence":0..1?,"merge":bool?,"begin":bool?}"#, has_seq, |s, p| keyframe_op(s, p, "set") ), @@ -2704,7 +2704,12 @@ fn keyframe_op(s: &mut Session, p: &Value, op: &str) -> Result { "set" => "Edit Keyframe", _ => "Keyframe Interpolation", }; - s.edit_sequence(label, |q, _, _| { + // a keyframe dragged on the timeline is one undo step, like a dragged value + let merge = bool_p(p, "merge").unwrap_or(false).then(|| format!("keyframe:{op}:{}:{eff}:{pid}:{}", c.0, p.get("mask").unwrap_or(&Value::Null))); + if bool_p(p, "begin").unwrap_or(false) { + s.history.merge_key = None; + } + s.edit_sequence_as(label, merge.as_deref(), |q, _, _| { let (_, it) = q.find_item_mut(c).ok_or(filmcraft_edit::EditError::NoItem(c))?; let mt_now = it.source_time_at(ph.clamp(it.start, (it.end() - Tick(1)).max(it.start))); let e = match &eff { @@ -2730,6 +2735,9 @@ fn keyframe_op(s: &mut Session, p: &Value, op: &str) -> Result { if let Some(i) = prm.keyframes.iter().position(|k| k.time == from) { let mut k = prm.keyframes.remove(i); k.time = to; + if let Some(v) = p.get("value") { + k.value = json_to_param(&k.value, v).ok_or_else(|| bad("keyframe", "value has the wrong type"))?; + } prm.keyframes.retain(|x| x.time != to); let at = prm.keyframes.partition_point(|x| x.time < to); prm.keyframes.insert(at, k); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 01d5b813..c7025afd 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -282,6 +282,51 @@ fn add_keyframe_toggles_the_keyframe_at_the_playhead() { assert_eq!(scale(&s), (0, 100.0)); } +/// Dragging the Volume line or one of its keyframes in the timeline (#223) sends a keyframe edit +/// every frame; `merge` keeps the whole drag one undo step and `begin` starts the next one. +#[test] +fn keyframe_drags_are_one_undo_step() { + let mut s = demo(); + let c = s.active_sequence().unwrap().audio_tracks[0].items[0].id.0; + let level = |s: &Session| -> Vec<(i64, f64)> { + let it = s.active_sequence().unwrap().find_item(filmcraft_project::ClipId(c)).unwrap().1; + it.effect("volume").unwrap().params["level"].keyframes.iter().map(|k| (k.time.0, k.value.as_f64().unwrap())).collect() + }; + let key = json!({"clip": c, "effect": "volume", "param": "level"}); + for sec in [0.5, 1.5] { + s.execute("playhead.set", json!({"seconds": sec})).unwrap(); + s.execute("effects.addKeyframe", key.clone()).unwrap(); + } + let before = level(&s); + let [(t0, _), (t1, _)] = before[..] else { panic!("two keyframes: {before:?}") }; + + // the line between them: both keyframes move together, frame after frame + for (i, v) in [-1.0, -2.0, -3.0].into_iter().enumerate() { + for t in [t0, t1] { + let p = json!({"clip": c, "effect": "volume", "param": "level", "mediaTime": t, "value": v, "merge": true, "begin": i == 0 && t == t0}); + s.execute("effects.setKeyframe", p).unwrap(); + } + } + assert_eq!(level(&s), [(t0, -3.0), (t1, -3.0)]); + s.undo(); + assert_eq!(level(&s), before); + + // one keyframe: time and value in one command + let mut at = t1; + for (i, d) in [1000, 2000, 3000].into_iter().enumerate() { + let p = json!({"clip": c, "effect": "volume", "param": "level", "mediaTime": at, "to": t1 + d, "value": -6.0, "merge": true, "begin": i == 0}); + s.execute("effects.moveKeyframe", p).unwrap(); + at = t1 + d; + } + assert_eq!(level(&s), [before[0], (t1 + 3000, -6.0)]); + s.undo(); + assert_eq!(level(&s), before); + + let bad = json!({"clip": c, "effect": "volume", "param": "level", "mediaTime": t0, "to": t0 + 1, "value": "loud"}); + assert!(s.execute("effects.moveKeyframe", bad).is_err()); + assert_eq!(level(&s), before); +} + #[test] fn transitions_and_markers() { let mut s = demo(); From 684c0a0d2f6839874c86f93b920ca420623857e8 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:55:36 +0200 Subject: [PATCH 15/64] MCP render_frame: max_side 0 means no limit (#459) --- crates/automation/src/server.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index 4a43cd2c..4c705c3c 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -402,7 +402,7 @@ impl FilmcraftMcp { let t = p.seconds.map_or_else(|| g.playhead(), filmcraft_time::Tick::from_seconds_f64); let (w, h) = g.active_sequence().map(|q| (q.settings.width, q.settings.height)).ok_or_else(|| AutomationError::Other("no sequence".into()))?; - let scale = (max as f32 / w.max(h) as f32).min(1.0); + let scale = render_scale(max, w, h); let img = g.try_render_program_at(scale, t).map_err(|e| AutomationError::Other(e.to_string()))?; png_rgba(img.w as u32, img.h as u32, img.over_black_rgba8(), max) }) @@ -648,10 +648,26 @@ impl ServerHandler for FilmcraftMcp { } } +/// Render scale that fits the longest side of a `w` x `h` sequence into `max_side` (never upscales; 0 = no limit, as in `png_rgba`). +fn render_scale(max_side: u32, w: u32, h: u32) -> f32 { + if max_side == 0 { + return 1.0; + } + (max_side as f32 / w.max(h).max(1) as f32).min(1.0) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn render_scale_zero_means_no_limit() { + assert_eq!(render_scale(0, 1920, 1080), 1.0); + assert_eq!(render_scale(960, 1920, 1080), 0.5); + assert_eq!(render_scale(960, 640, 360), 1.0); + assert_eq!(render_scale(960, 0, 0), 1.0); + } + #[tokio::test] async fn headless_commands_and_render() { let mut s = Session::default(); From 44c462d43d023a9beca7f555061b3a0f0d094cb6 Mon Sep 17 00:00:00 2001 From: Oskar Date: Fri, 9 Oct 2026 21:12:48 +0200 Subject: [PATCH 16/64] #223: clip volume line follows its keyframes The white line on audio clips was drawn at the level of the first frame only, squeezed above the waveform, and not at all with waveforms off. It now spans the clip under the name on the track fader scale, bends with Volume keyframes and shows them as diamonds. Tracks showing a track keyframe lane hide it. --- crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/panels/timeline.rs | 18 ++- crates/ui-egui/src/panels/timeline_volume.rs | 143 +++++++++++++++++++ 3 files changed, 152 insertions(+), 10 deletions(-) create mode 100644 crates/ui-egui/src/panels/timeline_volume.rs diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index e19bbce3..91613447 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -46,6 +46,7 @@ pub mod timeline; pub mod timeline_automation; pub mod timeline_captions; pub mod timeline_hit; +pub mod timeline_volume; pub mod tools; pub mod trim_monitor; pub mod voiceover; diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 811a75ee..807b9b65 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -363,6 +363,8 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { continue; } let p = painter.with_clip_rect(Rect::from_min_max(pos2(content.min.x, row.min.y), pos2(content.max.x, row.max.y))); + // a track showing a track keyframe lane hides the clips' lines + let volume_line = r.kind == TrackKind::Audio && !app.ui.timeline.track_lanes.contains_key(&r.track.0); for it in &tr.items { let (start, dur, moved_track) = previews.get(&it.id).copied().unwrap_or((it.start, it.duration, None)); if moved_track.is_some_and(|m| m != r.track) { @@ -375,6 +377,9 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let x1 = layout.x_of(start + dur); let body = Rect::from_min_max(pos2(x0, r.rect.min.y + 1.0), pos2(x1.max(x0 + 1.0), r.rect.max.y - 1.0)); draw_clip(app, &ctx, &p, body, it, r.kind, selection.contains(&it.id), &t, rate); + if volume_line { + super::timeline_volume::paint(&p, body, it); + } app.auto.add(&format!("timeline.clip.{}", it.id.0), body.intersect(content), &it.name); // a nest that runs past the end of its sequence's contents: that part is empty if !previews.contains_key(&it.id) @@ -396,6 +401,9 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { { let body = Rect::from_min_max(pos2(layout.x_of(*start), r.rect.min.y + 1.0), pos2(layout.x_of(*start + *dur), r.rect.max.y - 1.0)); draw_clip(app, &ctx, &p, body, it, r.kind, true, &t, rate); + if volume_line { + super::timeline_volume::paint(&p, body, it); + } } } // Show Through Edits: a small bow-tie on cuts between continuous pieces of one clip @@ -739,16 +747,6 @@ fn draw_waveform(app: &mut FilmcraftApp, p: &egui::Painter, body: Rect, it: &Tra p.rect_filled(cb, 1.0, Color32::from_black_alpha(160)); p.text(cb.center(), Align2::CENTER_CENTER, "1", Tokens::ui(7.5), Color32::from_rgb(0xd9, 0xd9, 0xd9)); } - // volume rubber band (white line with a black shadow) at mid-height of the upper zone - let level = it.effect("volume").map(|e| e.f64_at("level", it.source_in)).unwrap_or(0.0); - let upper = Rect::from_min_max(pos2(body.min.x, body.min.y + 16.0), pos2(body.max.x, area.min.y)); - if upper.height() > 6.0 { - let norm = ((level + 60.0) / 66.0).clamp(0.0, 1.0) as f32; - let y = upper.max.y - norm * upper.height(); - let cp = p.with_clip_rect(body.intersect(p.clip_rect())); - cp.line_segment([pos2(body.min.x, y + 1.0), pos2(body.max.x, y + 1.0)], Stroke::new(1.0, Color32::BLACK)); - cp.line_segment([pos2(body.min.x, y), pos2(body.max.x, y)], Stroke::new(1.0, Color32::WHITE)); - } } /// Display gain for a clip's waveform: clip gain, plus normalisation of the source to full scale diff --git a/crates/ui-egui/src/panels/timeline_volume.rs b/crates/ui-egui/src/panels/timeline_volume.rs new file mode 100644 index 00000000..50e66628 --- /dev/null +++ b/crates/ui-egui/src/panels/timeline_volume.rs @@ -0,0 +1,143 @@ +//! The Volume line on audio clips (#223): the clip's Volume level over time, with its keyframes, +//! on the same scale as the track faders. + +use egui::{Color32, Pos2, Rect, Stroke, pos2, vec2}; +use filmcraft_project::{Param, TrackItem}; +use filmcraft_time::Tick; + +use super::mixer::db_to_pos; +use crate::icons::{self, Icon}; + +const KEY_COL: Color32 = Color32::from_rgb(0xc8, 0xc8, 0xc8); + +/// Where the line goes in a clip drawn at `body`: under the name, down to the bottom edge. +fn band(body: Rect) -> Rect { + Rect::from_min_max(pos2(body.min.x, body.min.y + 17.0), pos2(body.max.x, body.max.y - 3.0)) +} + +fn level(it: &TrackItem) -> Option<&Param> { + it.effect("volume")?.param("level") +} + +/// The level (dB) the clip plays at timeline time `t`. +fn level_at(it: &TrackItem, t: Tick) -> f64 { + level(it).map_or(0.0, |p| p.f64_at(it.source_time_at(t))) +} + +/// Timeline time of media time `m`, the inverse of [`TrackItem::moving_source_time_at`]. +fn timeline_time(it: &TrackItem, m: Tick) -> Tick { + let rel = if it.reverse { it.source_out().0.saturating_sub(1).saturating_sub(m.0) } else { m.0.saturating_sub(it.source_in.0) }; + Tick(it.start.0.saturating_add((rel as f64 / it.speed.abs().max(1e-6)).round() as i64)) +} + +fn x_of(it: &TrackItem, body: Rect, t: Tick) -> f32 { + body.min.x + (t.0.saturating_sub(it.start.0) as f64 / it.duration.0.max(1) as f64) as f32 * body.width() +} + +fn t_at(it: &TrackItem, body: Rect, x: f32) -> Tick { + let f = ((x - body.min.x) / body.width().max(1.0)).clamp(0.0, 1.0) as f64; + Tick(it.start.0.saturating_add((f * it.duration.0 as f64) as i64)) +} + +fn y_of(body: Rect, db: f64) -> f32 { + let b = band(body); + b.max.y - db_to_pos(db) * b.height() +} + +/// The line from `x0` to `x1`; keyframes bend it, so it then gets a point every 2 px. +fn line(it: &TrackItem, body: Rect, x0: f32, x1: f32) -> Vec { + let n = if level(it).is_some_and(Param::is_animated) { (((x1 - x0) / 2.0).ceil() as usize).clamp(1, 4096) } else { 1 }; + (0..=n) + .map(|i| { + let x = x0 + (x1 - x0) * i as f32 / n as f32; + pos2(x, y_of(body, level_at(it, t_at(it, body, x)))) + }) + .collect() +} + +/// The keyframes: media time and where each is drawn. +fn keys(it: &TrackItem, body: Rect) -> Vec<(Tick, Pos2)> { + let Some(p) = level(it) else { return Vec::new() }; + p.keyframes.iter().map(|k| (k.time, pos2(x_of(it, body, timeline_time(it, k.time)), y_of(body, k.value.as_f64().unwrap_or(0.0))))).collect() +} + +/// Draw the line over an audio clip drawn at `body`: white with a dark shadow. +pub fn paint(p: &egui::Painter, body: Rect, it: &TrackItem) { + let vis = p.clip_rect().intersect(body); + if band(body).height() < 6.0 || vis.width() <= 0.0 { + return; + } + let p = p.with_clip_rect(vis); + let pts = line(it, body, vis.min.x, vis.max.x); + p.add(egui::Shape::line(pts.iter().map(|q| *q + vec2(0.0, 1.0)).collect(), Stroke::new(1.0, Color32::BLACK))); + p.add(egui::Shape::line(pts, Stroke::new(1.0, Color32::WHITE))); + for (_, c) in keys(it, body) { + icons::paint(&p, Rect::from_center_size(c, vec2(9.0, 9.0)), Icon::Keyframe, KEY_COL); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use filmcraft_project::{ClipId, ItemId, Label}; + + fn clip(speed: f64, reverse: bool) -> TrackItem { + TrackItem { + id: ClipId(1), + item: ItemId(1), + name: String::new(), + label: Label::Iris, + start: Tick(1_000), + duration: Tick(4_000), + source_in: Tick(500), + speed, + reverse, + enabled: true, + link: None, + group: None, + effects: filmcraft_project::effect::intrinsic_audio(), + markers: Vec::new(), + gain_db: 0.0, + frame_hold: None, + scale_to_frame: false, + essential: None, + multicam: None, + time_interpolation: Default::default(), + hold_filters: false, + field_options: None, + source_channels: Vec::new(), + audio_stream: 0, + graphic: None, + } + } + + #[test] + fn keyframes_sit_where_their_media_time_plays() { + for (speed, reverse) in [(1.0, false), (2.0, false), (0.5, false), (1.0, true), (2.0, true)] { + let it = clip(speed, reverse); + for t in [1_000, 1_001, 2_500, 4_999] { + let m = it.moving_source_time_at(Tick(t)); + assert!((timeline_time(&it, m).0 - t).abs() <= 1, "speed {speed} reverse {reverse}: {t}"); + } + } + } + + #[test] + fn the_line_follows_the_level() { + let mut it = clip(1.0, false); + let body = Rect::from_min_max(pos2(0.0, 0.0), pos2(400.0, 80.0)); + let flat = line(&it, body, 0.0, 400.0); + assert_eq!(flat.len(), 2); + assert!(flat.iter().all(|p| (p.y - y_of(body, 0.0)).abs() < 1e-3)); + let p = it.effect_mut("volume").and_then(|e| e.param_mut("level")).unwrap(); + p.put_keyframe(Tick(500), filmcraft_project::ParamValue::Float(0.0)); + p.put_keyframe(Tick(4_500), filmcraft_project::ParamValue::Float(-20.0)); + let ramp = line(&it, body, 0.0, 400.0); + assert!(ramp.len() > 100); + assert!(ramp.windows(2).all(|w| w[1].y >= w[0].y), "goes down as the level falls"); + assert!(ramp.last().unwrap().y > y_of(body, -19.0)); + // damaged geometry draws nothing silly rather than looping or panicking + assert_eq!(line(&it, body, 0.0, f32::NAN).len(), 2); + assert!(line(&it, body, 0.0, f32::INFINITY).len() <= 4097); + } +} From b4b4ea631787ff0bf2dedcd6c8037ecefbc05c68 Mon Sep 17 00:00:00 2001 From: Oskar Date: Fri, 9 Oct 2026 21:39:41 +0200 Subject: [PATCH 17/64] #223: drag the clip volume line and its keyframes in the timeline With the Selection or Pen tool the line on audio clips is now a grab target of its own (Grab::Volume): dragging it up or down changes the clip's Volume level live, as one undo step. With keyframes it moves the two keyframes around the pointer, like Premiere. The Pen tool or a Cmd/Ctrl-click adds a keyframe on the line; keyframes drag in time and level and right-click deletes them. A plain click on the line still selects the clip and a right-click still opens the clip menu, since presses go through the timeline's own hit-testing. --- crates/ui-egui/src/panels/timeline.rs | 33 ++- crates/ui-egui/src/panels/timeline_hit.rs | 15 +- crates/ui-egui/src/panels/timeline_volume.rs | 239 ++++++++++++++++++- crates/ui-egui/tests/clip_audio_ui.rs | 112 ++++++++- 4 files changed, 383 insertions(+), 16 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 807b9b65..70a18c30 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -114,6 +114,8 @@ pub enum Drag { clip: ClipId, delta: Tick, }, + /// An audio clip's Volume line (see [`super::timeline_volume`]). + Volume(super::timeline_volume::LineDrag), Stretch { clip: ClipId, edge: filmcraft_edit::Edge, @@ -150,6 +152,8 @@ pub struct Row { pub kind: TrackKind, pub index: usize, pub rect: Rect, + /// An audio track showing a track keyframe lane, which hides its clips' Volume lines. + pub lane: bool, } #[derive(Clone, Debug)] @@ -315,14 +319,16 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { app.ui.timeline.v_scroll = app.ui.timeline.v_scroll.clamp(0.0, max_vs); for (i, tr) in seq.video_tracks.iter().enumerate() { let top = video_area.min.y + v_off + (nv - 1 - i) as f32 * vh - (max_vs - app.ui.timeline.v_scroll); - rows.push(Row { track: tr.id, kind: TrackKind::Video, index: i, rect: Rect::from_min_max(pos2(content.min.x, top), pos2(content.max.x, top + vh)) }); + let rect = Rect::from_min_max(pos2(content.min.x, top), pos2(content.max.x, top + vh)); + rows.push(Row { track: tr.id, kind: TrackKind::Video, index: i, rect, lane: false }); } let na = seq.audio_tracks.len(); let max_as = (na as f32 * ah - audio_area.height()).max(0.0); app.ui.timeline.a_scroll = app.ui.timeline.a_scroll.clamp(0.0, max_as); for (i, tr) in seq.audio_tracks.iter().enumerate() { let top = audio_area.min.y + i as f32 * ah - app.ui.timeline.a_scroll; - rows.push(Row { track: tr.id, kind: TrackKind::Audio, index: i, rect: Rect::from_min_max(pos2(content.min.x, top), pos2(content.max.x, top + ah)) }); + let rect = Rect::from_min_max(pos2(content.min.x, top), pos2(content.max.x, top + ah)); + rows.push(Row { track: tr.id, kind: TrackKind::Audio, index: i, rect, lane: app.ui.timeline.track_lanes.contains_key(&tr.id.0) }); } let layout = Layout { content, ruler, rows: rows.clone(), pps, scroll, split_y }; app.tl.layout = Some(layout.clone()); @@ -363,8 +369,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { continue; } let p = painter.with_clip_rect(Rect::from_min_max(pos2(content.min.x, row.min.y), pos2(content.max.x, row.max.y))); - // a track showing a track keyframe lane hides the clips' lines - let volume_line = r.kind == TrackKind::Audio && !app.ui.timeline.track_lanes.contains_key(&r.track.0); + let volume_line = r.kind == TrackKind::Audio && !r.lane; for it in &tr.items { let (start, dur, moved_track) = previews.get(&it.id).copied().unwrap_or((it.start, it.duration, None)); if moved_track.is_some_and(|m| m != r.track) { @@ -515,6 +520,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { // ---- interaction interact(app, ui, &seq, &layout, rect); + super::timeline_volume::interact(app, ui, &seq, &layout, aclip); // track keyframes (drawn and edited on top of the clips) super::timeline_automation::show(app, ui, &seq, &layout, aclip); if cap_n > 0 { @@ -1451,6 +1457,7 @@ pub fn hit_json(app: &FilmcraftApp, pos: Pos2, mods: egui::Modifiers) -> Value { let grab = grab_at(seq, layout, pos, app.ui.tool, mods, app.session.prefs.trim.selection_tool_roll_ripple); let kind = match grab { Grab::Edge { kind, .. } => json!(kind.name()), + Grab::Volume { .. } => json!("volume"), Grab::Other(_) => Value::Null, }; match grab.hit() { @@ -1728,6 +1735,8 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & let g = grab_at(seq, layout, p, tool, mods, roll_ripple); let cur = match (tool, g) { (_, Grab::Edge { .. }) => CursorIcon::ResizeColumn, + (_, Grab::Volume { .. }) if tool == Tool::Pen || mods.command => CursorIcon::Crosshair, + (_, Grab::Volume { .. }) => CursorIcon::ResizeVertical, (Tool::Razor, Grab::Other(Hit::Clip { .. })) => CursorIcon::Crosshair, (Tool::Slip | Tool::Slide, Grab::Other(Hit::Clip { .. })) => CursorIcon::ResizeHorizontal, (Tool::Hand, _) => CursorIcon::Grab, @@ -1761,7 +1770,13 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & } }; let (p, t) = (pressed.pos, pressed.tick); - let started = match (tool, pressed.grab) { + let adding = tool == Tool::Pen || mods.command; + // a plain click on a Volume line selects the clip, as anywhere else on it + let grab = match pressed.grab { + Grab::Volume { .. } if resp.clicked() && !adding => Grab::Other(pressed.grab.hit()), + g => g, + }; + let started = match (tool, grab) { (_, Grab::Other(Hit::Ruler)) => Some(Drag::Scrub), (Tool::Hand, _) => Some(Drag::Pan { last: p }), (Tool::Zoom, _) => { @@ -1822,6 +1837,11 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & EdgeKind::Stretch => Drag::Stretch { clip, edge, delta: Tick::ZERO, from: t }, EdgeKind::Remix => Drag::Remix { clip, delta: Tick::ZERO, from: t }, }), + (_, Grab::Volume { clip, .. }) if resp.drag_started() => super::timeline_volume::start(seq, layout, clip, p).map(Drag::Volume), + (_, Grab::Volume { clip, .. }) => { + super::timeline_volume::add_keyframe(app, seq, layout, clip, p.x); + None + } (Tool::Slip, Grab::Other(Hit::Clip { clip, .. })) => Some(Drag::Slip { clip, delta: Tick::ZERO }), (Tool::Slide, Grab::Other(Hit::Clip { clip, .. })) => Some(Drag::Slide { clip, delta: Tick::ZERO }), (_, Grab::Other(Hit::Clip { clip, track, .. })) => { @@ -1937,6 +1957,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & let origin = ctx.input(|i| i.pointer.press_origin()).map(|o| layout.tick_at(o.x)).unwrap_or(t_here); Some(Drag::Slide { clip, delta: rate.snap_nearest(t_here - origin) }) } + Drag::Volume(v) => Some(Drag::Volume(super::timeline_volume::drag(app, ui, seq, layout, v, p.y))), Drag::Marquee { start } => { let r = Rect::from_two_pos(start, p); ui.painter().rect_filled(r, 0.0, Color32::from_white_alpha(18)); @@ -1947,7 +1968,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & }; app.tl.drag = new; // auto-scroll when dragging near the edges - if !matches!(app.tl.drag, Some(Drag::Pan { .. }) | Some(Drag::ZoomBar { .. })) { + if !matches!(app.tl.drag, Some(Drag::Pan { .. }) | Some(Drag::ZoomBar { .. }) | Some(Drag::Volume(_))) { let v = &mut app.ui.timeline; if p.x > layout.content.max.x - 20.0 { v.target_scroll += 6.0 / v.pps; diff --git a/crates/ui-egui/src/panels/timeline_hit.rs b/crates/ui-egui/src/panels/timeline_hit.rs index fa536c77..1716649f 100644 --- a/crates/ui-egui/src/panels/timeline_hit.rs +++ b/crates/ui-egui/src/panels/timeline_hit.rs @@ -173,6 +173,12 @@ pub enum Grab { edge: Edge, kind: EdgeKind, }, + /// An audio clip's Volume line (Selection and Pen tools): a drag changes the level, a Pen or + /// Cmd/Ctrl click adds a keyframe. + Volume { + track: TrackId, + clip: ClipId, + }, /// Not an edge this tool trims: a clip body, a transition, an empty track, the ruler or /// nothing. A clip's `edge` may still be set, for tools that do something else there (Razor, /// Slip…). @@ -184,6 +190,7 @@ impl Grab { pub fn hit(self) -> Hit { match self { Grab::Edge { track, clip, edge, .. } => Hit::Clip { track, clip, edge: Some(edge) }, + Grab::Volume { track, clip } => Hit::Clip { track, clip, edge: None }, Grab::Other(h) => h, } } @@ -194,6 +201,12 @@ impl Grab { /// Selection tool to choose Roll and Ripple trims without modifier key". pub fn grab_at(seq: &Sequence, layout: &Layout, pos: Pos2, tool: Tool, mods: Modifiers, roll_ripple: bool) -> Grab { let h = hit(seq, layout, pos); + if let Hit::Clip { track, clip, edge: None } = h + && matches!(tool, Tool::Selection | Tool::Pen) + && super::timeline_volume::on_line(seq, layout, clip, pos) + { + return Grab::Volume { track, clip }; + } let Hit::Clip { track, clip, edge: Some(edge) } = h else { return Grab::Other(h) }; let kind = match tool { Tool::Selection => { @@ -288,7 +301,7 @@ mod tests { let layout = Layout { content: row, ruler: Rect::from_min_max(pos2(0.0, 0.0), pos2(2000.0, 40.0)), - rows: vec![Row { track, kind: TrackKind::Video, index: 0, rect: row }], + rows: vec![Row { track, kind: TrackKind::Video, index: 0, rect: row, lane: false }], pps: 100.0, scroll: 0.0, split_y: TOP + 60.0, diff --git a/crates/ui-egui/src/panels/timeline_volume.rs b/crates/ui-egui/src/panels/timeline_volume.rs index 50e66628..3b679a87 100644 --- a/crates/ui-egui/src/panels/timeline_volume.rs +++ b/crates/ui-egui/src/panels/timeline_volume.rs @@ -1,12 +1,25 @@ //! The Volume line on audio clips (#223): the clip's Volume level over time, with its keyframes, -//! on the same scale as the track faders. +//! on the same scale as the track faders. As in Premiere, drag the line up or down to change the +//! level (with keyframes: the stretch between the two around the pointer), Cmd/Ctrl-click it or +//! click it with the Pen tool to add a keyframe, drag keyframes, right-click one to delete it. +//! Presses on the line go through the Timeline's own hit-testing ([`super::timeline_hit`]), so a +//! right-click on it still opens the clip menu. +//! +//! Automation ids: `timeline.clip..volume` (a point on the line), +//! `timeline.clip..volume.kf.` (the keyframes) and `….kf..delete` (their menu). -use egui::{Color32, Pos2, Rect, Stroke, pos2, vec2}; -use filmcraft_project::{Param, TrackItem}; +use egui::{Color32, CursorIcon, Pos2, Rect, Sense, Stroke, pos2, vec2}; +use filmcraft_project::mixer::{FADER_MAX_DB, FADER_MIN_DB}; +use filmcraft_project::{ClipId, Param, Sequence, TrackItem, TrackKind}; use filmcraft_time::Tick; +use serde_json::{Value, json}; -use super::mixer::db_to_pos; +use super::mixer::{db_to_pos, pos_to_db}; +use super::timeline::{Layout, Row}; +use super::timeline_hit::EDGE_PX; +use crate::FilmcraftApp; use crate::icons::{self, Icon}; +use crate::state::Tool; const KEY_COL: Color32 = Color32::from_rgb(0xc8, 0xc8, 0xc8); @@ -71,15 +84,217 @@ pub fn paint(p: &egui::Painter, body: Rect, it: &TrackItem) { let pts = line(it, body, vis.min.x, vis.max.x); p.add(egui::Shape::line(pts.iter().map(|q| *q + vec2(0.0, 1.0)).collect(), Stroke::new(1.0, Color32::BLACK))); p.add(egui::Shape::line(pts, Stroke::new(1.0, Color32::WHITE))); - for (_, c) in keys(it, body) { + for (_, c) in keys(it, body).into_iter().filter(|(_, c)| vis.expand(6.0).contains(*c)) { icons::paint(&p, Rect::from_center_size(c, vec2(9.0, 9.0)), Icon::Keyframe, KEY_COL); } } +/// A drag of a clip's Volume line, pressed at height `y` where the line was at `from` dB. `keys` +/// are the keyframes on either side of that point, which move with it (none: the level is not +/// animated). `last` is the level sent last: the first change starts the drag's undo step. +#[derive(Clone, Debug)] +pub struct LineDrag { + clip: ClipId, + from: f64, + y: f32, + keys: [Option<(Tick, f64)>; 2], + last: Option, +} + +fn find<'a>(seq: &'a Sequence, layout: &'a Layout, clip: ClipId) -> Option<(&'a Row, &'a TrackItem)> { + let (tid, it) = seq.find_item(clip)?; + Some((layout.rows.iter().find(|r| r.track == tid)?, it)) +} + +fn body(layout: &Layout, r: &Row, it: &TrackItem) -> Rect { + Rect::from_min_max(pos2(layout.x_of(it.start), r.rect.min.y + 1.0), pos2(layout.x_of(it.end()), r.rect.max.y - 1.0)) +} + +/// Whether `pos` is on `clip`'s Volume line, within 4 px. +pub fn on_line(seq: &Sequence, layout: &Layout, clip: ClipId, pos: Pos2) -> bool { + let Some((r, it)) = find(seq, layout, clip) else { return false }; + if r.kind != TrackKind::Audio || r.lane || seq.track(r.track).is_none_or(|t| t.locked) { + return false; + } + let body = body(layout, r, it); + band(body).height() >= 6.0 && (pos.y - y_of(body, level_at(it, t_at(it, body, pos.x)))).abs() <= 4.0 +} + +/// A press on `clip`'s line at `at` that became a drag. +pub fn start(seq: &Sequence, layout: &Layout, clip: ClipId, at: Pos2) -> Option { + let (r, it) = find(seq, layout, clip)?; + let t = t_at(it, body(layout, r, it), at.x); + let m = it.source_time_at(t); + let keys = level(it).filter(|p| p.is_animated()).map_or([None, None], |p| { + let i = p.keyframes.partition_point(|k| k.time <= m); + let kv = |j: Option| j.and_then(|j| p.keyframes.get(j)).map(|k| (k.time, k.value.as_f64().unwrap_or(0.0))); + [kv(i.checked_sub(1)), kv(Some(i))] + }); + Some(LineDrag { clip, from: level_at(it, t), y: at.y, keys, last: None }) +} + +/// Follow the pointer, now at height `y`. The clip changes as the line moves, so it is heard. +pub fn drag(app: &mut FilmcraftApp, ui: &egui::Ui, seq: &Sequence, layout: &Layout, mut d: LineDrag, y: f32) -> LineDrag { + let Some((r, it)) = find(seq, layout, d.clip) else { return d }; + let db = pos_to_db(db_to_pos(d.from) + (d.y - y) / band(body(layout, r, it)).height().max(1.0)); + tip(ui, db); + if d.last == Some(db) { + return d; + } + let begin = d.last.is_none(); + let mut acts = Vec::new(); + if d.keys.iter().all(Option::is_none) { + acts.push(("effects.setParam", json!({"clip": d.clip.0, "effect": "volume", "param": "level", "value": db, "merge": true, "begin": begin}))); + } + for (n, (m, v)) in d.keys.iter().flatten().enumerate() { + let v = (v + db - d.from).clamp(FADER_MIN_DB, FADER_MAX_DB); + let p = json!({"clip": d.clip.0, "effect": "volume", "param": "level", "mediaTime": m.0, "value": v, "merge": true, "begin": begin && n == 0}); + acts.push(("effects.setKeyframe", p)); + } + run(app, acts); + d.last = Some(db); + d +} + +/// The Pen tool or a Cmd/Ctrl-click on `clip`'s line at `x`: a keyframe there. +pub fn add_keyframe(app: &mut FilmcraftApp, seq: &Sequence, layout: &Layout, clip: ClipId, x: f32) { + let Some((r, it)) = find(seq, layout, clip) else { return }; + let t = seq.settings.frame_rate.snap_nearest(t_at(it, body(layout, r, it), x)); + run(app, vec![("effects.addKeyframe", json!({"clip": clip.0, "effect": "volume", "param": "level", "time": t.0}))]); +} + +fn run(app: &mut FilmcraftApp, acts: Vec<(&str, Value)>) { + for (id, p) in acts { + if let Err(e) = app.session.execute(id, p) { + app.ui.status = e.to_string(); + } + } +} + +fn tip(ui: &egui::Ui, db: f64) { + egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::new("tl-volume-tip"), egui::PopupAnchor::Pointer).show(|ui| { + ui.label(format!("{db:.2} dB")); + }); +} + +/// A keyframe being dragged: `index` of `clip`, grabbed `grab` away from its centre. +#[derive(Clone, Copy)] +struct KeyDrag { + clip: ClipId, + index: usize, + grab: egui::Vec2, + begun: bool, +} + +fn key_drag_id() -> egui::Id { + egui::Id::new("tl-volume-key-drag") +} + +/// The keyframes on the lines of the audio clips in view: drag one in time and level, right-click +/// it to delete it. Runs after the timeline's own interaction, so they are above the clip. +pub fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &Layout, aclip: Rect) { + let held: Option = ui.data(|d| d.get_temp(key_drag_id())); + if held.is_none() && (app.tl.drag.is_some() || !matches!(app.ui.tool, Tool::Selection | Tool::Pen)) { + return; + } + let area = aclip.intersect(layout.content); + let pointer = ui.input(|i| i.pointer.interact_pos()); + let rate = seq.settings.frame_rate; + let mut next = held; + let mut acts: Vec<(&str, Value)> = Vec::new(); + for r in layout.rows.iter().filter(|r| r.kind == TrackKind::Audio && !r.lane) { + let Some(tr) = seq.track(r.track).filter(|t| !t.locked) else { continue }; + let row = r.rect.intersect(area); + if row.height() <= 0.0 { + continue; + } + for it in &tr.items { + let body = body(layout, r, it); + let vis = body.intersect(row); + if vis.width() <= 0.0 || band(body).height() < 6.0 { + continue; + } + // a point on the line for agents, away from the trim zones at the edges + let edge = EDGE_PX.min(body.width() / 3.0); + let (x0, x1) = (vis.min.x.max(body.min.x + edge), vis.max.x.min(body.max.x - edge)); + if x1 > x0 { + let xm = (x0 + x1) / 2.0; + let c = pos2(xm, y_of(body, level_at(it, t_at(it, body, xm)))); + app.auto.add(&format!("timeline.clip.{}.volume", it.id.0), Rect::from_center_size(c, vec2(12.0, 8.0)), "Volume"); + } + let ks = keys(it, body); + for (i, &(time, c)) in ks.iter().enumerate() { + if !vis.expand2(vec2(5.0, 0.0)).contains(c) { + continue; + } + let kr = Rect::from_center_size(c, vec2(11.0, 11.0)); + let resp = ui.interact(kr, egui::Id::new(("tl-volume-kf", it.id.0, i)), Sense::click_and_drag()); + app.auto.add(&format!("timeline.clip.{}.volume.kf.{i}", it.id.0), kr, "Volume keyframe"); + let mine = held.filter(|h| h.clip == it.id && h.index == i); + if resp.hovered() || mine.is_some() { + icons::paint(&ui.painter().with_clip_rect(area), Rect::from_center_size(c, vec2(9.0, 9.0)), Icon::Keyframe, Color32::WHITE); + ui.ctx().set_cursor_icon(CursorIcon::Move); + } + if resp.drag_started() + && let Some(p) = pointer + { + next = Some(KeyDrag { clip: it.id, index: i, grab: p - c, begun: false }); + } + if let (Some(h), Some(p)) = (mine, pointer) + && resp.dragged() + { + let q = p - h.grab; + // on a frame, at least a frame away from the keyframes on either side + let fd = rate.frame_duration(); + let now = timeline_time(it, time); + let (mut lo, mut hi) = (it.start, Tick(it.end().0.saturating_sub(fd.0))); + for j in [i.checked_sub(1), i.checked_add(1)].into_iter().flatten() { + if let Some(&(m, _)) = ks.get(j) { + let n = timeline_time(it, m); + if n < now { + lo = lo.max(Tick(n.0.saturating_add(fd.0))); + } else { + hi = hi.min(Tick(n.0.saturating_sub(fd.0))); + } + } + } + let t = if lo <= hi { rate.snap_nearest(t_at(it, body, q.x)).max(lo).min(hi) } else { now }; + let db = pos_to_db((band(body).max.y - q.y) / band(body).height()); + if resp.drag_delta() != egui::Vec2::ZERO { + let to = it.moving_source_time_at(t); + let p = json!({"clip": h.clip.0, "effect": "volume", "param": "level", "mediaTime": time.0, "to": to.0, "value": db, "merge": true, "begin": !h.begun}); + acts.push(("effects.moveKeyframe", p)); + next = Some(KeyDrag { begun: true, ..h }); + } + tip(ui, db); + } + resp.context_menu(|ui| { + let b = ui.button(tl!("Delete")); + app.auto.add(&format!("timeline.clip.{}.volume.kf.{i}.delete", it.id.0), b.rect, "Delete"); + if b.clicked() { + acts.push(("effects.deleteKeyframe", json!({"clip": it.id.0, "effect": "volume", "param": "level", "mediaTime": time.0}))); + ui.close(); + } + }); + } + } + } + if !ui.input(|i| i.pointer.any_down()) { + next = None; + } + match next { + Some(h) => ui.data_mut(|d| { + d.insert_temp(key_drag_id(), h); + }), + None => ui.data_mut(|d| d.remove::(key_drag_id())), + } + run(app, acts); +} + #[cfg(test)] mod tests { use super::*; - use filmcraft_project::{ClipId, ItemId, Label}; + use filmcraft_project::{ItemId, Label}; fn clip(speed: f64, reverse: bool) -> TrackItem { TrackItem { @@ -122,6 +337,18 @@ mod tests { } } + #[test] + fn damaged_keyframe_times_do_not_panic() { + for (speed, reverse) in [(1.0, false), (1e-12, true), (f64::NAN, false), (-3.0, true)] { + let mut it = clip(speed, reverse); + let p = it.effect_mut("volume").and_then(|e| e.param_mut("level")).unwrap(); + p.put_keyframe(Tick(i64::MIN), filmcraft_project::ParamValue::Float(f64::NAN)); + p.put_keyframe(Tick(i64::MAX), filmcraft_project::ParamValue::Float(1e300)); + let body = Rect::from_min_max(pos2(0.0, 0.0), pos2(400.0, 80.0)); + assert_eq!(keys(&it, body).len(), 2); + } + } + #[test] fn the_line_follows_the_level() { let mut it = clip(1.0, false); diff --git a/crates/ui-egui/tests/clip_audio_ui.rs b/crates/ui-egui/tests/clip_audio_ui.rs index 6258905e..3ca87a33 100644 --- a/crates/ui-egui/tests/clip_audio_ui.rs +++ b/crates/ui-egui/tests/clip_audio_ui.rs @@ -1,5 +1,8 @@ //! Audio of video clips (#223): a selected video clip shows the Volume, Channel Volume and Panner -//! of the audio linked to it as well, as in Premiere. +//! of the audio linked to it as well, as in Premiere, and the Volume line on audio clips in the +//! timeline can be dragged and keyframed. +//! +//! With `FILMCRAFT_UI_SHOTS=` the tests also render the UI with wgpu and write PNGs there. use std::sync::mpsc::{Sender, channel}; @@ -13,6 +16,7 @@ use serde_json::{Value, json}; struct Driver { harness: Harness<'static, FilmcraftApp>, tx: Sender, + shots: Option, } /// The demo's first clip: its video on V1 and the audio linked to it on A1. @@ -27,8 +31,13 @@ impl Driver { session.execute("file.openDemoProject", json!({})).expect("demo project"); let (tx, rx) = channel(); let app = FilmcraftApp::new(session).with_control(rx); - let harness = Harness::builder().with_size(egui::vec2(1600.0, 1100.0)).with_max_steps(10_000).build_eframe(move |_cc| app); - let mut d = Driver { harness, tx }; + let shots = std::env::var_os("FILMCRAFT_UI_SHOTS").map(std::path::PathBuf::from); + let mut b = Harness::builder().with_size(egui::vec2(1600.0, 1100.0)).with_max_steps(10_000); + if shots.is_some() { + b = b.wgpu().with_pixels_per_point(1.0); + } + let harness = b.build_eframe(move |_cc| app); + let mut d = Driver { harness, tx, shots }; d.frames(4); let seq = d.exec("sequence.inspect", json!({})); let v = &seq["video"][0]["items"][0]; @@ -86,6 +95,27 @@ impl Driver { fn item(&self, clip: u64) -> TrackItem { self.harness.state().session.active_sequence().unwrap().find_item(ClipId(clip)).unwrap().1.clone() } + /// The clip's Volume level: its value and its keyframes (media time, dB). + fn level(&self, clip: u64) -> (f64, Vec<(i64, f64)>) { + let it = self.item(clip); + let p = it.effect("volume").unwrap().param("level").unwrap().clone(); + (p.value.as_f64().unwrap(), p.keyframes.iter().map(|k| (k.time.0, k.value.as_f64().unwrap())).collect()) + } + fn drag(&mut self, from: (f64, f64), by: (f64, f64)) { + self.ok("ui.drag", json!({"from": {"x": from.0, "y": from.1}, "to": {"x": from.0 + by.0, "y": from.1 + by.1}, "steps": 6})); + self.frames(3); + } + fn shot(&mut self, name: &str) { + let Some(dir) = self.shots.clone() else { return }; + self.frames(10); + let img = self.harness.render().expect("wgpu render"); + std::fs::create_dir_all(&dir).unwrap(); + img.save(dir.join(format!("{name}.png"))).unwrap(); + } +} + +fn centre(r: [f64; 4]) -> (f64, f64) { + (r[0] + r[2] / 2.0, r[1] + r[3] / 2.0) } #[test] @@ -128,3 +158,79 @@ fn properties_show_the_audio_of_a_video_clip() { d.rect("properties.volume.level.addKeyframe"); assert!(d.find("properties.motion.scale.addKeyframe").is_none()); } + +#[test] +fn dragging_the_volume_line_changes_the_level() { + let (mut d, pair) = Driver::demo(&[]); + let start = d.item(pair.audio).start; + let (before, _) = d.level(pair.audio); + d.shot("volume-line"); + let line = d.rect(&format!("timeline.clip.{}.volume", pair.audio)); + d.drag(centre(line), (0.0, -12.0)); + let (after, keys) = d.level(pair.audio); + assert!(after > before + 1.0, "{before} dB → {after} dB"); + assert!(keys.is_empty()); + assert_eq!(d.item(pair.audio).start, start, "the clip did not move"); + let moved = d.rect(&format!("timeline.clip.{}.volume", pair.audio)); + assert!(moved[1] < line[1] - 8.0, "the line went up with the pointer: {line:?} → {moved:?}"); + d.shot("volume-line-dragged"); + d.exec("edit.undo", json!({})); + assert_eq!(d.level(pair.audio).0, before, "the drag was one undo step"); +} + +/// The line belongs to the clip: a click on it selects the clip and a right-click opens the clip +/// menu, only a drag (or the Pen tool) edits the level. +#[test] +fn clicks_on_the_volume_line_still_reach_the_clip() { + let (mut d, pair) = Driver::demo(&[]); + let (x, y) = centre(d.rect(&format!("timeline.clip.{}.volume", pair.audio))); + assert_eq!(d.ok("ui.timeline.hit", json!({"x": x, "y": y}))["kind"], "volume"); + d.ok("ui.click", json!({"x": x, "y": y})); + d.frames(3); + assert!(d.harness.state().session.state.selection.contains(&ClipId(pair.audio))); + d.ok("ui.click", json!({"x": x, "y": y, "button": "right"})); + d.frames(3); + d.rect("timeline.clipMenu.clip.link"); + assert!(d.level(pair.audio).1.is_empty()); +} + +#[test] +fn volume_keyframes_on_the_timeline() { + let (mut d, pair) = Driver::demo(&[]); + let id = format!("timeline.clip.{}.volume", pair.audio); + let clip = d.rect(&format!("timeline.clip.{}", pair.audio)); + let (_, y) = centre(d.rect(&id)); + // the Pen tool adds keyframes on the line + d.ok("ui.set", json!({"tool": "Pen"})); + for f in [0.3, 0.7] { + d.ok("ui.click", json!({"x": clip[0] + clip[2] * f, "y": y})); + d.frames(3); + } + let (_, keys) = d.level(pair.audio); + assert_eq!(keys.len(), 2, "{keys:?}"); + d.ok("ui.set", json!({"tool": "Selection"})); + + // the line between them takes both down + d.drag((clip[0] + clip[2] * 0.5, y), (0.0, 10.0)); + let (_, low) = d.level(pair.audio); + assert_eq!(low.iter().map(|k| k.0).collect::>(), keys.iter().map(|k| k.0).collect::>()); + assert!(low[0].1 < keys[0].1 - 1.0 && (low[0].1 - low[1].1).abs() < 1e-9, "{keys:?} → {low:?}"); + d.exec("edit.undo", json!({})); + assert_eq!(d.level(pair.audio).1, keys); + + // a keyframe moves in time and level + let k = d.rect(&format!("{id}.kf.1")); + d.drag(centre(k), (40.0, -10.0)); + let (_, moved) = d.level(pair.audio); + assert_eq!(moved[0], keys[0]); + assert!(moved[1].0 > keys[1].0 && moved[1].1 > keys[1].1, "{keys:?} → {moved:?}"); + d.shot("volume-keyframes"); + d.exec("edit.undo", json!({})); + assert_eq!(d.level(pair.audio).1, keys, "the keyframe drag was one undo step"); + + // right-click ▸ Delete + d.ok("ui.click", json!({"id": format!("{id}.kf.0"), "button": "right"})); + d.frames(3); + d.click(&format!("{id}.kf.0.delete")); + assert_eq!(d.level(pair.audio).1, keys[1..]); +} From d6e43bcf2c7901852c19460e08a36a63ab60752a Mon Sep 17 00:00:00 2001 From: Oskar Date: Fri, 9 Oct 2026 21:40:02 +0200 Subject: [PATCH 18/64] docs: volume grab kind for ui.timeline.hit, clip_audio_ui tests (#223) --- docs/control-protocol.md | 2 +- docs/testing.md | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 7bca6de0..e17bf464 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -35,7 +35,7 @@ Methods (handlers in `crates/ui-egui/src/control.rs`): | `ui.drag` | `{from, to, steps, modifiers}` | press–move–release | | `ui.scroll` | `{id|x,y, dx, dy, modifiers}` | wheel / trackpad | | `ui.key` / `ui.type` | `{key}` (`Cmd+K`, `Space`…; off macOS `Ctrl+K` is the same key) / `{text}` | keyboard | -| `ui.timeline.hit` / `ui.timeline.locate` | `{x,y,modifiers?}` / `{clip, edge?}` | timeline hit-testing. `hit` reports a clip's `edge` (`In`/`Out`) within 7 px of it (a third of a narrow clip); just left of a cut that is the left clip's Out, from the cut rightwards the right clip's In. `kind` is the trim a press of the current tool with `modifiers` starts there (`trim`, `ripple`, `roll`, `rateStretch`, `remix`), or null | +| `ui.timeline.hit` / `ui.timeline.locate` | `{x,y,modifiers?}` / `{clip, edge?}` | timeline hit-testing. `hit` reports a clip's `edge` (`In`/`Out`) within 7 px of it (a third of a narrow clip); just left of a cut that is the left clip's Out, from the cut rightwards the right clip's In. `kind` is the trim a press of the current tool with `modifiers` starts there (`trim`, `ripple`, `roll`, `rateStretch`, `remix`), `volume` on an audio clip's Volume line (Selection and Pen tools), or null | | `ui.playback` | `{action: play|stop|toggle, speed?}` | | | `ui.screenshot` | `{path?, panel?}` | PNG of the window or one panel; fails after 10 s when no frame is presented (window hidden, display asleep) | | `ui.resize`, `ui.focus`, `app.quit` | | `ui.focus` is the only request that activates the app and takes keyboard focus | diff --git a/docs/testing.md b/docs/testing.md index 1b64781c..43256429 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -194,6 +194,10 @@ window (for example on a locked screen, where `ui.screenshot` cannot capture). `crates/ui-egui/tests/essential_sound_ui.rs` does the same for the Essential Sound panel (type buttons, switches, a slider drag as one undo step, section bypass, Auto-Match, ducking, Browse presets; `essential-sound-*.png`). +`crates/ui-egui/tests/clip_audio_ui.rs` covers the audio of video clips (#223): the linked audio's +Volume, Channel Volume and Panner in Effect Controls and Properties, and the Volume line on audio +clips in the Timeline (a drag is one undo step, Pen-tool keyframes, keyframe drags, a click or +right-click on the line still reaches the clip; `FILMCRAFT_UI_SHOTS=` writes `volume-*.png`). Essential Sound engine tests (`crates/engine/src/essential_sound_tests.rs`) build projects from generated speech-like and tonal WAVs: Auto-Match lands within ±0.5 LU of the target (measured: 0.000 LU, From dd121aeb9c5203353f889423f115b8bd8059f471 Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Sat, 10 Oct 2026 05:53:14 +1000 Subject: [PATCH 19/64] AUDIO.STREAM_ESSENCE: Preserve selected audio streams in AAF/OMF exports Co-authored-by: Codex --- crates/engine/src/aaf_omf.rs | 16 ++- crates/engine/src/aaf_omf_tests.rs | 189 +++++++++++++++++++++++++++++ crates/gpu/src/tests.rs | 2 +- crates/interchange/src/comp.rs | 20 ++- crates/interchange/src/essence.rs | 33 ++++- crates/platform/tests/setting.rs | 10 +- 6 files changed, 254 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/aaf_omf.rs b/crates/engine/src/aaf_omf.rs index fd0d0196..823b895d 100644 --- a/crates/engine/src/aaf_omf.rs +++ b/crates/engine/src/aaf_omf.rs @@ -143,7 +143,7 @@ fn decode(s: &Session, need: &AudioNeed, sample_rate: u32) -> Result o.extend(x.iter().copied().chain(std::iter::repeat(0.0)).take(n)), @@ -230,8 +230,19 @@ fn ensure_dir(s: &Session, dir: &str) { } } +fn check_linked_streams(s: &Session, seq: ItemId, plan: &AudioPlan) -> Result<()> { + if plan.mode == AudioMode::Linked && !plan.trim && !plan.render_effects { + let needs = audio_needs(&s.project, seq, &NeedOptions { nests: plan.nests, ..Default::default() }); + if needs.iter().any(|need| need.audio_stream > 0) { + return Err(EngineError::Other("linked AAF media cannot select container audio streams; choose embedded or separate audio".into())); + } + } + Ok(()) +} + /// Prepare the audio essence of an export; returns the essence and the files written. pub fn prepare_audio(s: &Session, seq: ItemId, plan: &AudioPlan, media_dir: &str) -> Result<(Vec, Vec)> { + check_linked_streams(s, seq, plan)?; if plan.mode == AudioMode::Linked && !plan.trim && !plan.render_effects { return Ok((Vec::new(), Vec::new())); } @@ -255,7 +266,7 @@ pub fn prepare_audio(s: &Session, seq: ItemId, plan: &AudioPlan, media_dir: &str let name = audio_clip(&s.project, seq, c).map(|(_, _, ti)| ti.name.clone()).unwrap_or_default(); format!("{} {}", safe_name(&name), c.0) } - EssenceKey::Media(_) => safe_name( + EssenceKey::Media(_) | EssenceKey::MediaStream { .. } => safe_name( need.path .as_deref() .map(|p| std::path::Path::new(p).file_stem().map(|x| x.to_string_lossy().into_owned()).unwrap_or_default()) @@ -380,6 +391,7 @@ pub fn export_aaf(s: &mut Session, p: &Value) -> Result { let path = str_p(p, "path").ok_or_else(|| bad(cmd, "need `path`"))?.to_string(); let seq = crate::export_tools::sequence_param(s, p, cmd)?; let plan = plan_from(s, p, seq, cmd, false)?; + check_linked_streams(s, seq, &plan)?; let (dir, stem) = split(&path); let media_dir = join(&dir, &format!("{stem} Media")); let mut files = Vec::new(); diff --git a/crates/engine/src/aaf_omf_tests.rs b/crates/engine/src/aaf_omf_tests.rs index 1bd52a8c..495c6962 100644 --- a/crates/engine/src/aaf_omf_tests.rs +++ b/crates/engine/src/aaf_omf_tests.rs @@ -311,3 +311,192 @@ fn commands_are_registered_with_menus_and_validate_params() { assert!(s.execute("file.importAaf", json!({"path": dir.join("a.mov").to_string_lossy()})).is_err()); let _ = std::fs::remove_dir_all(dir); } + +// Selected-stream essence uses only PR361 schema13 placement and decoder fields. +mod selected_streams { + use crate::{Services, Session}; + use filmcraft_frame::{AudioBuffer, VideoFrame}; + use filmcraft_media::{FrameRequest, MediaError, MediaInfo, MediaKind, MediaSource}; + use filmcraft_project::{ClipId, ItemId, ItemKind, Label, MediaClip, MediaRef, SequenceSettings}; + use filmcraft_time::{FrameRate, Tick}; + use serde_json::json; + use std::sync::{Arc, Mutex}; + + type Calls = Arc>>; + + struct Tones { + info: MediaInfo, + calls: Calls, + } + + impl Tones { + fn new(streams: usize) -> Self { + let source = filmcraft_media::generators::GeneratorSource::new( + filmcraft_media::Generator::BarsAndTone, + 32, + 18, + FrameRate::FPS_24, + Tick::from_seconds_f64(2.0), + ); + let mut info = source.info().clone(); + info.kind = MediaKind::AudioOnly; + info.video = None; + info.audio_streams = vec![info.audio_streams[0].clone(); streams]; + Self { info, calls: Arc::default() } + } + + fn sample(stream: usize, channel: usize, frame: i64, rate: u32) -> f32 { + let frequency = (stream * 2 + channel + 1) as f64 * 240.0; + let amplitude = 0.05 * (stream + 1) as f64; + (amplitude * (std::f64::consts::TAU * frequency * frame as f64 / rate as f64).sin()) as f32 + } + } + + impl MediaSource for Tones { + fn info(&self) -> &MediaInfo { + &self.info + } + fn video_frame(&self, _: FrameRequest) -> filmcraft_media::Result> { + Err(MediaError::NoStream("video")) + } + fn audio(&self, start: i64, frames: usize, sample_rate: u32) -> filmcraft_media::Result { + self.audio_stream(0, start, frames, sample_rate) + } + fn audio_stream(&self, stream: usize, start: i64, frames: usize, sample_rate: u32) -> filmcraft_media::Result { + if stream >= self.info.audio_streams.len() { + return Err(MediaError::NoStream("audio")); + } + self.calls.lock().unwrap().push((stream, start, frames, sample_rate)); + Ok(AudioBuffer { + sample_rate, + channels: (0..2).map(|channel| (0..frames).map(|i| Self::sample(stream, channel, start + i as i64, sample_rate)).collect()).collect(), + }) + } + } + + #[derive(Default)] + struct MemoryFiles { + writes: Mutex)>>, + } + impl Services for MemoryFiles { + fn read_file(&self, _: &str) -> std::io::Result> { + Err(std::io::ErrorKind::NotFound.into()) + } + fn write_file(&self, path: &str, bytes: &[u8]) -> std::io::Result<()> { + self.writes.lock().unwrap().push((path.to_owned(), bytes.to_vec())); + Ok(()) + } + fn export_in_memory(&self) -> bool { + true + } + } + + fn add_source(s: &mut Session, streams: usize) -> (ItemId, Calls) { + let source = Arc::new(Tones::new(streams)); + let calls = source.calls.clone(); + let mut project = (*s.project).clone(); + let item = project.add_item( + "distinct tones", + Label::Iris, + ItemKind::Media(MediaClip { + media: MediaRef::File { path: format!("tone-source-{streams}.wav") }, + info: source.info.clone(), + interpret: Default::default(), + mark_in: None, + mark_out: None, + markers: vec![], + offline: false, + proxy: None, + identity: None, + }), + None, + ); + s.project = Arc::new(project); + s.media.insert(item, source); + (item, calls) + } + + fn fixture() -> (Session, ItemId, ItemId, Calls, Arc) { + let files = Arc::new(MemoryFiles::default()); + let mut session = Session::new(files.clone()); + let mut project = (*session.project).clone(); + let sequence = + project.new_sequence("streams", SequenceSettings { width: 32, height: 18, frame_rate: FrameRate::FPS_24, ..Default::default() }, 0, 1, None); + session.project = Arc::new(project); + session.state.active_sequence = Some(sequence); + session.state.open_sequences = vec![sequence]; + let (item, calls) = add_source(&mut session, 3); + session.state.project_selection = vec![item]; + session.execute("sequence.linkedSelection", json!({"on": false})).unwrap(); + (session, item, sequence, calls, files) + } + + fn place(s: &mut Session, item: ItemId, seconds: f64) -> Vec { + let result = s.execute("timeline.place", json!({"item": item.0, "track": "A1", "seconds": seconds})).unwrap(); + result["clips"].as_array().unwrap().iter().map(|id| ClipId(id.as_u64().unwrap())).collect() + } + + #[test] + fn aaf_essence_distinguishes_streams_and_linked_refusal_precedes_writes() { + use crate::aaf_omf::{AudioMode, AudioPlan}; + use filmcraft_interchange::essence::{EssenceData, EssenceKey, NestNeeds}; + let (mut s, item, sequence, calls, files) = fixture(); + place(&mut s, item, 0.0); + let plan = AudioPlan { + mode: AudioMode::Embedded, + aiff: false, + mxf: false, + sample_rate: 48_000, + bits: 16, + trim: true, + handles: Tick::ZERO, + render_effects: false, + breakout: false, + nests: NestNeeds::Inside, + }; + let (essences, paths) = crate::aaf_omf::prepare_audio(&s, sequence, &plan, "ignored").unwrap(); + assert!(paths.is_empty()); + assert_eq!(essences.len(), 3); + for (stream, essence) in essences.iter().enumerate() { + assert_eq!(essence.key, EssenceKey::media(item, stream)); + let EssenceData::Embedded(bytes) = &essence.data else { panic!("expected embedded PCM") }; + for channel in 0..2 { + let offset = (25 * 2 + channel) * 2; + let actual = i16::from_le_bytes([bytes[offset], bytes[offset + 1]]); + let expected = (Tones::sample(stream, channel, 25, 48_000) * 32767.0).round() as i16; + assert_eq!(actual, expected); + } + } + let options = filmcraft_interchange::aaf::AafOptions { + media: filmcraft_interchange::essence::MediaOptions { essence: essences, ..Default::default() }, + ..Default::default() + }; + let (aaf, _) = filmcraft_interchange::aaf::export(&s.project, sequence, &options).unwrap(); + let omf_options = filmcraft_interchange::omf::OmfOptions { media: options.media.clone(), ..Default::default() }; + let (omf, _) = filmcraft_interchange::omf::export(&s.project, sequence, &omf_options).unwrap(); + for (kind, document) in [("AAF", aaf), ("OMF", omf)] { + let (imported, extracted, _) = if kind == "AAF" { + filmcraft_interchange::aaf::import(&document, &Default::default()).unwrap() + } else { + filmcraft_interchange::omf::import(&document, &Default::default()).unwrap() + }; + assert_eq!(extracted.len(), 3, "{kind}: composition memo must retain distinct selected-stream essence"); + let tracks = &imported.project.sequence(imported.sequences[0]).unwrap().audio_tracks; + assert_eq!(tracks.len(), 3, "{kind}"); + let mut source_ids: Vec<_> = tracks.iter().map(|track| track.items[0].item).collect(); + source_ids.sort(); + source_ids.dedup(); + assert_eq!(source_ids.len(), 3, "{kind}: each stream remains a distinct imported source"); + let mut waveforms: Vec<_> = extracted.iter().map(|media| media.wav.clone()).collect(); + waveforms.sort(); + waveforms.dedup(); + assert_eq!(waveforms.len(), 3, "{kind}"); + } + assert!(calls.lock().unwrap().iter().any(|call| call.0 == 2)); + assert!(files.writes.lock().unwrap().is_empty()); + let error = + s.execute("file.exportAaf", json!({"path": "must-not-be-written.aaf", "sequence": sequence.0, "audio": "linked", "trimAudio": false})).unwrap_err(); + assert!(error.to_string().contains("embedded") || error.to_string().contains("separate")); + assert!(files.writes.lock().unwrap().is_empty()); + } +} diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 44714db5..8953da0a 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -204,7 +204,7 @@ fn gpu_push_matches_cpu_with_alpha_and_surrounding_blends() { c.composite_prepared(&plan, Some(&prepare(&plan))); let (_, _, gpu) = c.read_output().expect("readback"); let mut diffs: Vec = - cpu.chunks_exact(4).zip(gpu.chunks_exact(4)).map(|(a, b)| (0..3).map(|k| a[k].abs_diff(b[k]) as u32).max().unwrap()).collect(); + cpu.as_chunks::<4>().0.iter().zip(gpu.as_chunks::<4>().0).map(|(a, b)| (0..3).map(|k| a[k].abs_diff(b[k]) as u32).max().unwrap()).collect(); diffs.sort_unstable(); let p99 = diffs[diffs.len() * 99 / 100]; let mean = diffs.iter().sum::() as f64 / diffs.len() as f64; diff --git a/crates/interchange/src/comp.rs b/crates/interchange/src/comp.rs index bf9b2fba..2ade96bc 100644 --- a/crates/interchange/src/comp.rs +++ b/crates/interchange/src/comp.rs @@ -191,7 +191,7 @@ struct Exporter<'a> { p: &'a Project, media: &'a MediaOptions, doc: Document, - memo: HashMap<(String, CKind, Option), usize>, + memo: HashMap<(String, CKind, usize, Option), usize>, report: &'a mut Report, seq_rate: FrameRate, nests: Nests, @@ -307,7 +307,7 @@ impl Exporter<'_> { if ckind == CKind::Sound && let Some(e) = self.media.essence_for(clip_key, channel).cloned() { - let memo_key = (format!("clip:{}", c.id.0), ckind, channel); + let memo_key = (format!("clip:{}", c.id.0), ckind, 0, channel); if let Some(&i) = self.memo.get(&memo_key) { return Some(i); } @@ -346,7 +346,7 @@ impl Exporter<'_> { self.report.warn(format!("the sound of the nested sequence \"{name}\" was not rendered (left as a gap)")); return None; } - let memo_key = (format!("seq:{}", item.0), ckind, channel); + let memo_key = (format!("seq:{}", item.0), ckind, 0, channel); if let Some(&i) = self.memo.get(&memo_key) { return Some(i); } @@ -429,21 +429,29 @@ impl Exporter<'_> { // the source channel this slot plays when broken out to mono let channel = sub.map(|s| c.source_channels.get(s as usize).map(|&x| x as u32).unwrap_or(s)); let clip_key = EssenceKey::Clip(c.id); - let media_key = EssenceKey::Media(item); + let media_key = EssenceKey::media(item, c.audio_stream); let ess = (ckind == CKind::Sound) .then(|| self.media.essence_for(clip_key, channel).or_else(|| self.media.essence_for(media_key, channel))) .flatten() .cloned(); let key = match &ess { Some(e) if e.key == clip_key => format!("clip:{}", c.id.0), + _ if ckind == CKind::Sound && c.audio_stream > 0 => format!("item:{}:stream:{}", item.0, c.audio_stream), _ => format!("item:{}", item.0), }; - let memo_key = (key.clone(), ckind, channel); + // Original linked file locators cannot name a container stream. Leave an explicit + // unsupported-feature report rather than reference its first audio stream. + if ckind == CKind::Sound && c.audio_stream > 0 && ess.is_none() { + self.report.warn("selected container audio streams require separate or embedded audio essence (left as gaps)"); + return None; + } + let stream = if ckind == CKind::Sound { c.audio_stream } else { 0 }; + let memo_key = (key.clone(), ckind, stream, channel); if let Some(&i) = self.memo.get(&memo_key) { return Some(i); } let video = m.info.video.as_ref(); - let audio = m.info.audio(); + let audio = m.info.audio_streams.get(c.audio_stream); let still = m.info.kind == MediaKind::Still; match ckind { CKind::Picture if video.is_none() && !still => return None, diff --git a/crates/interchange/src/essence.rs b/crates/interchange/src/essence.rs index 45a56878..f1a9cb93 100644 --- a/crates/interchange/src/essence.rs +++ b/crates/interchange/src/essence.rs @@ -19,15 +19,25 @@ use serde::{Deserialize, Serialize}; #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub enum EssenceKey { Media(ItemId), + MediaStream { item: ItemId, stream: usize }, Clip(ClipId), } +impl EssenceKey { + pub fn media(item: ItemId, stream: usize) -> Self { + if stream == 0 { Self::Media(item) } else { Self::MediaStream { item, stream } } + } +} + /// One range of source audio an export references. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct AudioNeed { pub key: EssenceKey, /// The media item (for a clip key: the clip's media). pub item: ItemId, + /// Selected container stream; zero for legacy and sequence sources. + #[serde(default)] + pub audio_stream: usize, /// The media file, when the item is file media. pub path: Option, /// Media time range to supply (handles included, clamped to the media). @@ -120,7 +130,8 @@ pub struct NeedOptions { /// Extra media before and after every used range. pub handles: Tick, /// One range per clip ([`EssenceKey::Clip`]; for rendering clip effects) instead of one range - /// per media item covering all its uses ([`EssenceKey::Media`]). + /// per media item and container stream covering all its uses. Stream zero keeps + /// the legacy [`EssenceKey::Media`] identity. pub per_clip: bool, /// Whole media instead of the used range (embedding / copying without trimming). pub whole_media: bool, @@ -163,7 +174,7 @@ fn collect_needs( let (mut start, mut end) = ((lo - opts.handles).max(Tick::ZERO), hi + opts.handles); match project.item(item).map(|i| &i.kind) { Some(ItemKind::Media(m)) => { - let Some(a) = m.info.audio() else { continue }; + let Some(a) = m.info.audio_streams.get(c.audio_stream) else { continue }; let path = match &m.media { MediaRef::File { path } => Some(path.clone()), MediaRef::Generator(_) => None, @@ -176,8 +187,17 @@ fn collect_needs( start = Tick::ZERO; end = dur.max(end); } - let key = if opts.per_clip { EssenceKey::Clip(c.id) } else { EssenceKey::Media(item) }; - let e = by_item.entry(key).or_insert_with(|| AudioNeed { key, item, path, start, end, channels: a.channels, sample_rate: a.sample_rate }); + let key = if opts.per_clip { EssenceKey::Clip(c.id) } else { EssenceKey::media(item, c.audio_stream) }; + let e = by_item.entry(key).or_insert_with(|| AudioNeed { + key, + item, + audio_stream: c.audio_stream, + path, + start, + end, + channels: a.channels, + sample_rate: a.sample_rate, + }); e.start = e.start.min(start); e.end = e.end.max(end); } @@ -196,7 +216,10 @@ fn collect_needs( _ => 2, }; let key = EssenceKey::Clip(c.id); - by_item.insert(key, AudioNeed { key, item, path: None, start, end, channels, sample_rate: nested.settings.sample_rate.max(1) }); + by_item.insert( + key, + AudioNeed { key, item, audio_stream: 0, path: None, start, end, channels, sample_rate: nested.settings.sample_rate.max(1) }, + ); } }, _ => {} diff --git a/crates/platform/tests/setting.rs b/crates/platform/tests/setting.rs index c177e55d..cae45b19 100644 --- a/crates/platform/tests/setting.rs +++ b/crates/platform/tests/setting.rs @@ -36,8 +36,14 @@ fn off_uses_the_software_decoder() { assert_eq!(d.name(), "FilmCraft HEVC"); return; } - let (backend, hw_name) = if cfg!(target_os = "macos") { ("VideoToolbox", "VideoToolbox HEVC") } else { ("Media Foundation", "Media Foundation HEVC") }; - assert_eq!(d.name(), hw_name); + let backend = if cfg!(target_os = "linux") { + assert!(d.name().starts_with("VA-API HEVC ("), "Linux Auto decoder: {}", d.name()); + "VA-API" + } else { + let (backend, hw_name) = if cfg!(target_os = "macos") { ("VideoToolbox", "VideoToolbox HEVC") } else { ("Media Foundation", "Media Foundation HEVC") }; + assert_eq!(d.name(), hw_name); + backend + }; assert_eq!(filmcraft_codecs::hw::hw_backend(), Some(backend), "perf.stats reports the backend"); assert!(filmcraft_platform::registered(), "register() put the factory in the registry"); let h1 = filmcraft_codecs::hw::hw_stats(); From cf694f86b35f9a120ed5403c606756323d2adeb4 Mon Sep 17 00:00:00 2001 From: Oskar Date: Fri, 9 Oct 2026 22:18:03 +0200 Subject: [PATCH 20/64] #223: volume tooltip keeps one width while dragging The dB readout next to the pointer resized itself on every value change and sometimes wrapped into a column of single characters. It is now one line in a monospace font with a fixed number format, so the box stays put. --- crates/ui-egui/src/panels/timeline_volume.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/timeline_volume.rs b/crates/ui-egui/src/panels/timeline_volume.rs index 3b679a87..a0330383 100644 --- a/crates/ui-egui/src/panels/timeline_volume.rs +++ b/crates/ui-egui/src/panels/timeline_volume.rs @@ -20,6 +20,7 @@ use super::timeline_hit::EDGE_PX; use crate::FilmcraftApp; use crate::icons::{self, Icon}; use crate::state::Tool; +use crate::theme::Tokens; const KEY_COL: Color32 = Color32::from_rgb(0xc8, 0xc8, 0xc8); @@ -171,9 +172,11 @@ fn run(app: &mut FilmcraftApp, acts: Vec<(&str, Value)>) { } } +/// The level next to the pointer while dragging: one line, always as wide, so it does not jump. fn tip(ui: &egui::Ui, db: f64) { + let text = if db <= FADER_MIN_DB { format!("{:>7} dB", "-∞") } else { format!("{db:>+7.2} dB") }; egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::new("tl-volume-tip"), egui::PopupAnchor::Pointer).show(|ui| { - ui.label(format!("{db:.2} dB")); + ui.add(egui::Label::new(egui::RichText::new(text).font(Tokens::mono(12.0))).wrap_mode(egui::TextWrapMode::Extend)); }); } From e9baec01546882e5ab1647da3e8b59530ca86680 Mon Sep 17 00:00:00 2001 From: Oskar Date: Fri, 9 Oct 2026 22:28:41 +0200 Subject: [PATCH 21/64] #223: audio waveforms follow the clip volume Like Premiere, the waveform in an audio clip now grows and shrinks with its Volume level, keyframes included, so dragging the volume line down visibly lowers the waveform. A bypassed or disabled Volume leaves it as it was. --- crates/ui-egui/src/panels/timeline.rs | 2 ++ crates/ui-egui/src/panels/timeline_volume.rs | 21 ++++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 70a18c30..30351a76 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -734,6 +734,8 @@ fn draw_waveform(app: &mut FilmcraftApp, p: &egui::Painter, body: Rect, it: &Tra for (a, b) in peaks.iter().skip(s0).take(s1 - s0) { m = m.max(a.abs()).max(b.abs()); } + // as in Premiere, the waveform shows the clip's Volume + m *= super::timeline_volume::gain_at(it, t0); // View ▸ Dynamic Audio Waveforms (default): logarithmic scale, −48 dB → 0, 0 dB → full; // off: linear amplitude let h = if dynamic { diff --git a/crates/ui-egui/src/panels/timeline_volume.rs b/crates/ui-egui/src/panels/timeline_volume.rs index a0330383..c6a05250 100644 --- a/crates/ui-egui/src/panels/timeline_volume.rs +++ b/crates/ui-egui/src/panels/timeline_volume.rs @@ -38,6 +38,15 @@ fn level_at(it: &TrackItem, t: Tick) -> f64 { level(it).map_or(0.0, |p| p.f64_at(it.source_time_at(t))) } +/// The clip's Volume as a gain at timeline time `t`, for its waveform (1 when Volume is off or +/// bypassed, as the mixer plays it). +pub fn gain_at(it: &TrackItem, t: Tick) -> f32 { + match it.effect("volume").filter(|e| e.enabled && !e.param("bypass").and_then(|p| p.value.as_bool()).unwrap_or(false)) { + Some(e) => filmcraft_render::audio::db_to_gain(e.f64_at("level", it.source_time_at(t))), + None => 1.0, + } +} + /// Timeline time of media time `m`, the inverse of [`TrackItem::moving_source_time_at`]. fn timeline_time(it: &TrackItem, m: Tick) -> Tick { let rel = if it.reverse { it.source_out().0.saturating_sub(1).saturating_sub(m.0) } else { m.0.saturating_sub(it.source_in.0) }; @@ -352,6 +361,18 @@ mod tests { } } + #[test] + fn the_waveform_gain_is_the_volume_that_plays() { + let mut it = clip(1.0, false); + assert_eq!(gain_at(&it, Tick(2_000)), 1.0); + let e = it.effect_mut("volume").unwrap(); + e.param_mut("level").unwrap().value = filmcraft_project::ParamValue::Float(-6.0); + assert!((gain_at(&it, Tick(2_000)) - 0.501).abs() < 0.01); + let e = it.effect_mut("volume").unwrap(); + e.param_mut("bypass").unwrap().value = filmcraft_project::ParamValue::Bool(true); + assert_eq!(gain_at(&it, Tick(2_000)), 1.0, "bypassed"); + } + #[test] fn the_line_follows_the_level() { let mut it = clip(1.0, false); From 30ff11e6697ceff49f918210e165bc474873dbb6 Mon Sep 17 00:00:00 2001 From: MrCloudy2 Date: Fri, 9 Oct 2026 21:12:45 +0200 Subject: [PATCH 22/64] #474: VA-API decoders share one display instead of opening one each Every VA-API session opened its own display and terminated it when dropped. With NVIDIA's driver that is a CUDA context each: 0.2-0.3 s to open, 60-70 ms to terminate and about 100 MB of video memory, on the thread creating or dropping the decoder (a frame worker at every clip change, the UI thread when media is removed: a ~0.6 s freeze deleting five played clips, and dropped frames at clip changes). Sessions now take the process's shared display (the startup probe's, kept for the life of the process like the libraries) and make only their own configuration, context and surfaces. Display is Sync on the strength of libva's Multithreading Guide (va.h): its functions and drivers are thread-safe, and every session uses only its own VA objects. A session that cannot be made on the shared display is tried once on a new display, which is shared from then on if it works (a GPU reset). On NVIDIA (RTX 3060): deleting five played clips 650-690 ms -> 132-198 ms, dropped frames in 20 s of playback 2-3 -> 0, peak video memory 1281 -> 1010 MiB. Intel and software unchanged. Test: tests/vaapi_displays.rs (decoders one after another and six at once on six threads open no display; pictures bit-exact). The VA-API hardware suite passes on NVIDIA and Intel, and the concurrency tests 20 times each. Co-Authored-By: Claude Opus 5.5 --- crates/platform/src/vaapi/va.rs | 76 ++++++++++++++++++++++--- crates/platform/tests/vaapi_displays.rs | 68 ++++++++++++++++++++++ 2 files changed, 136 insertions(+), 8 deletions(-) create mode 100644 crates/platform/tests/vaapi_displays.rs diff --git a/crates/platform/src/vaapi/va.rs b/crates/platform/src/vaapi/va.rs index 5ee5e06e..aa07512b 100644 --- a/crates/platform/src/vaapi/va.rs +++ b/crates/platform/src/vaapi/va.rs @@ -10,7 +10,8 @@ use std::ffi::{CStr, c_char, c_int, c_uint, c_void}; use std::fs::{File, OpenOptions}; use std::os::fd::AsRawFd; -use std::sync::OnceLock; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, OnceLock, PoisonError}; use filmcraft_frame::VideoFrame; use libloading::Library; @@ -125,9 +126,49 @@ pub struct Display { _device: File, } -// SAFETY: a VA display is not tied to the thread that opened it, and a `Display` is only used -// through its single owner (`&mut` or `&` of one `Session` at a time), never concurrently. +// SAFETY: a VA display is not tied to the thread that opened it. unsafe impl Send for Display {} +// SAFETY: libva's functions are thread-safe and so must its drivers' be (va.h, "Multithreading +// Guide"): one display may be used from several threads at once, as long as each VA object +// (context, surfaces, buffers, images) is used by one thread at a time. Every `Session` on a +// shared display has its own configuration, context, surfaces and image, used only through that +// session (`&mut` or `&` of one owner); the display itself is only read (`api`, `dpy`, `vendor`). +unsafe impl Sync for Display {} + +/// The display every [`Session`] decodes on. Opening a display takes a few milliseconds with +/// Intel's and Mesa's drivers but 0.2-0.3 s with NVIDIA's (a CUDA context each, about 100 MB of +/// video memory), and terminating one 60-70 ms, on the thread that creates or drops the decoder: a +/// frame worker at every clip on the timeline, the UI thread when media is removed. So one display +/// is opened (by [`probe`] at startup) and kept for the life of the process, like the libraries, +/// and sessions only make and destroy their own configuration, context and surfaces. +static SHARED: Mutex>> = Mutex::new(None); +/// Displays opened so far (diagnostics and tests). +static OPENED: AtomicUsize = AtomicUsize::new(0); + +/// The number of VA displays this process has opened (one, unless the shared one stopped working). +pub fn displays_opened() -> usize { + OPENED.load(Ordering::Relaxed) +} + +/// The shared display, opened on first use. +fn shared() -> Result, String> { + let mut shared = SHARED.lock().unwrap_or_else(PoisonError::into_inner); + if let Some(d) = shared.as_ref() { + return Ok(d.clone()); + } + let d = Arc::new(Display::open()?); + *shared = Some(d.clone()); + Ok(d) +} + +/// Share `new` from now on instead of `old` (if `old` is still the shared one). Sessions on `old` +/// keep it until they end. +fn replace_shared(old: &Arc, new: Arc) { + let mut shared = SHARED.lock().unwrap_or_else(PoisonError::into_inner); + if shared.as_ref().is_none_or(|d| Arc::ptr_eq(d, old)) { + *shared = Some(new); + } +} impl Display { /// The first DRM render node with a working VA-API driver. @@ -164,6 +205,7 @@ impl Display { // SAFETY: `dpy` is valid; the version outputs point to live locals. let st = unsafe { (api.initialize)(dpy, &mut major, &mut minor) }; d.check(st, "vaInitialize")?; + OPENED.fetch_add(1, Ordering::Relaxed); // SAFETY: `dpy` is an initialised display; the vendor string is NUL-terminated and owned by // the driver for the life of the display (copied out here). let vendor = unsafe { (api.vendor_string)(dpy) }; @@ -206,7 +248,8 @@ impl Drop for Display { /// One stream's decoder: a VLD configuration of a profile, its surfaces and context, and the image /// pictures are read back through. pub struct Session { - display: Display, + /// The shared display (see [`SHARED`]), or the one that replaced it. + display: Arc, config: ffi::VAConfigID, context: ffi::VAContextID, surfaces: Vec, @@ -231,8 +274,23 @@ impl Session { /// 8 or 10), with `count` surfaces of `size` (coded luma samples); pictures read back are cut /// to `geometry`. pub fn new(profiles: &[ffi::VAProfile], size: (u32, u32), count: usize, geometry: Geometry) -> Result { + let display = shared()?; + match Self::on(display.clone(), profiles, size, count, geometry) { + Ok(s) => Ok(s), + Err(first) => { + // The shared display may have stopped working (a GPU reset): try a new one, and + // share that one from now on if it works. (A stream the driver does not take + // fails on both and costs the one extra display opening.) + let fresh = Arc::new(Display::open().map_err(|e| format!("{first}; a new display: {e}"))?); + let s = Self::on(fresh.clone(), profiles, size, count, geometry).map_err(|_| first)?; + replace_shared(&display, fresh); + Ok(s) + } + } + } + + fn on(display: Arc, profiles: &[ffi::VAProfile], size: (u32, u32), count: usize, geometry: Geometry) -> Result { let (rt_format, _, _) = formats(geometry.bits)?; - let display = Display::open()?; let api = display.api; let dpy = display.dpy; let mut config = None; @@ -497,8 +555,9 @@ impl Accel for Session { impl Drop for Session { fn drop(&mut self) { let (api, dpy) = (self.display.api, self.display.dpy); - // SAFETY: each object below was made on this display and is destroyed once; the display - // itself is terminated afterwards (when the `display` field is dropped). + // SAFETY: each object below was made on this display and is destroyed once, by this + // session only; the display itself is terminated when its last user lets it go (the + // shared one never is). unsafe { if let Some(image) = self.image.take() { (api.destroy_image)(dpy, image.image_id); @@ -521,5 +580,6 @@ impl Drop for Session { /// driver's description, or why not. pub fn probe() -> &'static Result { static PROBE: OnceLock> = OnceLock::new(); - PROBE.get_or_init(|| Display::open().map(|d| d.vendor().to_string())) + // the display opened to answer is the one sessions share + PROBE.get_or_init(|| shared().map(|d| d.vendor().to_string())) } diff --git a/crates/platform/tests/vaapi_displays.rs b/crates/platform/tests/vaapi_displays.rs new file mode 100644 index 00000000..8ec45334 --- /dev/null +++ b/crates/platform/tests/vaapi_displays.rs @@ -0,0 +1,68 @@ +//! VA-API decoders share one display (Linux). Opening a display takes 0.2-0.3 s with NVIDIA's +//! driver (about 100 MB of video memory each) and terminating one 60-70 ms, on the thread that +//! creates or drops the decoder (a frame worker at every clip, the UI thread when media is +//! removed), so the process opens one and every decoder makes only its own context and surfaces on +//! it, also several decoders at once on different threads. Its own test binary: the count of +//! opened displays is process-wide, and other tests running at the same time would change it. +//! Skips without ffmpeg (fixture generator) or without a VA-API driver. +#![cfg(target_os = "linux")] + +mod common; + +use common::*; +use filmcraft_codecs::VideoDecoder; +use filmcraft_platform::vaapi::va::displays_opened; + +fn hardware(s: &Stream) -> Option> { + match filmcraft_platform::vaapi_factory(&s.entry) { + Some(Ok(d)) if d.name().starts_with("VA-API") => Some(d), + _ => { + eprintln!("SKIPPED: no VA-API decoder for this stream on this machine"); + None + } + } +} + +#[test] +fn decoders_share_one_display() { + let ff = filmcraft_testkit::require_ffmpeg!(); + let paths: Vec<_> = FIXTURES.iter().filter_map(|(name, _)| named(&ff, name)).collect(); + let streams: Vec<_> = paths.iter().map(|p| read_stream(p)).collect(); + let Some(first) = streams.first() else { return }; + let Some(hw) = hardware(first) else { return }; + drop(hw); + let opened = displays_opened(); + assert_eq!(opened, 1, "the first decoder opened one display"); + + // one decoder after another, of every fixture (H.264, HEVC Main, HEVC Main 10) + for round in 0..10 { + for s in &streams { + let Some(mut hw) = hardware(s) else { return }; + let n = s.samples.len().min(13); + let reference = decode_all(filmcraft_codecs::software_video_decoder(&s.entry).unwrap().as_mut(), &s.samples[..n]); + assert_same(&format!("round {round}"), &decode_all(hw.as_mut(), &s.samples[..n]), &reference); + } + } + assert_eq!(displays_opened(), opened, "decoders one after another open no display"); + + // several decoders at once on different threads, each used from its own thread, repeatedly + for round in 0..3 { + let jobs: Vec<_> = (0..6) + .map(|i| { + let path = paths[i % paths.len()].clone(); + std::thread::spawn(move || { + let s = read_stream(&path); + let Some(mut hw) = hardware(&s) else { return }; + let reference = decode_all(filmcraft_codecs::software_video_decoder(&s.entry).unwrap().as_mut(), &s.samples); + assert_same(&format!("round {round}, thread {i}"), &decode_all(hw.as_mut(), &s.samples), &reference); + hw.reset(); + assert_same(&format!("round {round}, thread {i}, after a reset"), &decode_all(hw.as_mut(), &s.samples), &reference); + }) + }) + .collect(); + for j in jobs { + j.join().unwrap(); + } + } + assert_eq!(displays_opened(), opened, "decoders at once on several threads open no display"); +} From 72e3eeb5deaaed296d9e03fa594e8465e0c623e6 Mon Sep 17 00:00:00 2001 From: liangforstudy <36067395+liangforstudy@users.noreply.github.com> Date: Sat, 10 Oct 2026 05:22:05 +0800 Subject: [PATCH 23/64] Project files: each sequence reopens with its playhead where it was left The engine already keeps one playhead per sequence while a project is open, and the project file's view already stores each sequence's zoom, scroll and track heights, but not its playhead: after saving and reopening, every sequence started at zero. ProjectView gains `playheads` (sequence id -> Tick), written beside the tabs and views and restored with them when "Restore open sequences when opening projects" is on. Files written before it have none and open as they did. Values from a file are not trusted: ids that are not sequences are dropped, playheads are clamped to 0..ProjectView::MAX_PLAYHEAD and snapped to a frame of their sequence. Tests: each_sequence_reopens_with_its_playhead_where_it_was_left (fails without the fix), hostile playheads in a_damaged_view_in_a_project_file_is_cleaned_up_or_ignored, a deleted sequence's playhead is not written, and the format round trip carries it. Also adds my contributor credit entry (people.toml). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qq8BK1LmohzE1ga4NgAdWM --- contributors/people.toml | 3 + crates/engine/src/lib.rs | 13 ++++- crates/engine/src/sequence_tabs_tests.rs | 71 +++++++++++++++++++++++- crates/format/src/lib.rs | 1 + crates/project/src/lib.rs | 17 +++++- docs/project-files.md | 6 +- 6 files changed, 99 insertions(+), 12 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index 25cf020e..0acb9978 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -40,3 +40,6 @@ sync_github_name = true [people.ItzBloxy] display_name = "Bloxy" + +[people.liangforstudy] +sync_github_name = true diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2578420b..d63e10e1 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -707,14 +707,15 @@ impl Session { open_sequences: self.state.open_sequences.iter().copied().filter(is_seq).collect(), active_sequence: self.state.active_sequence.filter(is_seq), sequences: self.state.timeline_views.iter().filter(|(id, _)| is_seq(id)).map(|(id, v)| (*id, *v)).collect(), + playheads: self.state.playheads.iter().filter(|(id, _)| is_seq(id)).map(|(id, t)| (*id, *t)).collect(), } } /// Open what was open when the project was saved. Nothing in `view` is trusted: ids that are /// not sequences of this project are dropped (also a second mention of the same sequence), - /// and numbers are brought into range. A view without any open sequence leaves the project - /// on its first sequence: a project saved by a session that never showed one (a script, the - /// CLI) should not open on an empty Timeline. + /// and numbers are brought into range (a playhead also lands on a frame of its sequence). A + /// view without any open sequence leaves the project on its first sequence: a project saved + /// by a session that never showed one (a script, the CLI) should not open on an empty Timeline. pub fn restore_project_view(&mut self, view: filmcraft_project::ProjectView) { let mut open: Vec = Vec::new(); for id in view.open_sequences { @@ -728,6 +729,12 @@ impl Session { } self.state.timeline_views = view.sequences.into_iter().filter(|(id, _)| self.project.sequence(*id).is_some()).filter_map(|(id, v)| Some((id, v.checked()?))).collect(); + for (id, t) in view.playheads { + if let Some(seq) = self.project.sequence(id) { + let t = t.clamp(Tick::ZERO, filmcraft_project::ProjectView::MAX_PLAYHEAD); + self.state.playheads.insert(id, seq.settings.frame_rate.snap(t)); + } + } } /// Every edit passes through here: one that would put a sequence inside itself (directly or diff --git a/crates/engine/src/sequence_tabs_tests.rs b/crates/engine/src/sequence_tabs_tests.rs index d8d6cd48..f3cb5f36 100644 --- a/crates/engine/src/sequence_tabs_tests.rs +++ b/crates/engine/src/sequence_tabs_tests.rs @@ -1,9 +1,10 @@ //! The Timeline's sequence tabs: closing the others, reordering, and what a project file keeps of -//! them (the open tabs in order, the active one, and each sequence's zoom, scroll and track -//! heights). Premiere's behaviour was observed in Premiere Pro 26.5.2. +//! them (the open tabs in order, the active one, each sequence's zoom, scroll and track heights, +//! and each sequence's playhead). Premiere's tab behaviour was observed in Premiere Pro 26.5.2. use super::*; use filmcraft_project::SequenceView; +use filmcraft_time::TICKS_PER_SECOND; use serde_json::json; /// The demo project with two more sequences open: tabs [main, b, c], `c` active. @@ -106,6 +107,52 @@ fn a_saved_project_reopens_with_its_tabs_and_their_views() { let _ = std::fs::remove_dir_all(&dir); } +/// Each sequence reopens with its playhead where it was left: the project file keeps one playhead +/// per sequence beside the tabs and views. +#[test] +fn each_sequence_reopens_with_its_playhead_where_it_was_left() { + let (mut s, [main, b, c]) = three_tabs(); + let at = |s: &Session, seq: ItemId, secs: i64| s.project.sequence(seq).unwrap().settings.frame_rate.snap(Tick(secs * TICKS_PER_SECOND)); + s.execute("sequence.open", json!({"item": main.0})).unwrap(); + s.execute("playhead.set", json!({"seconds": 7})).unwrap(); + s.execute("sequence.open", json!({"item": b.0})).unwrap(); + s.execute("playhead.set", json!({"seconds": 2})).unwrap(); + let (main_at, b_at) = (at(&s, main, 7), at(&s, b, 2)); + assert_eq!(s.playhead(), b_at); + assert_eq!(s.project_view().playheads.get(&c), None, "a playhead that never moved is not written"); + let dir = temp_dir("playheads"); + let path = dir.join("tabs.fcproj").to_string_lossy().to_string(); + s.execute("file.saveAs", json!({"path": path})).unwrap(); + + let mut t = Session::default(); + t.execute("file.open", json!({"path": path})).unwrap(); + assert_eq!(t.state.active_sequence, Some(b)); + assert_eq!(t.playhead(), b_at, "the sequence that was shown is back where it was"); + t.execute("sequence.open", json!({"item": main.0})).unwrap(); + assert_eq!(t.playhead(), main_at, "and so is every other sequence"); + t.execute("sequence.open", json!({"item": c.0})).unwrap(); + assert_eq!(t.playhead(), Tick::ZERO); + assert!(!t.is_dirty(), "a restored playhead is not an edit"); + + // with the preference off, nothing of the view comes back, playheads included + let mut u = Session::default(); + u.prefs.timeline.restore_open_sequences = false; + u.execute("file.open", json!({"path": path})).unwrap(); + assert_eq!(u.playhead(), Tick::ZERO); + + // a file written before playheads were kept opens as it did then: every playhead at zero + let mut doc: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert!(doc["view"]["playheads"].is_object()); + doc["view"].as_object_mut().unwrap().remove("playheads"); + std::fs::write(&path, serde_json::to_vec(&doc).unwrap()).unwrap(); + let mut w = Session::default(); + w.execute("file.open", json!({"path": path})).unwrap(); + assert_eq!(w.state.active_sequence, Some(b)); + assert_eq!(w.playhead(), Tick::ZERO); + assert!(w.state.playheads.is_empty()); + let _ = std::fs::remove_dir_all(&dir); +} + /// The view in a project file is not trusted: ids of things that are not sequences, repeats and /// numbers out of range are dropped or brought into range, and a view that cannot be read at all /// does not stop the project from opening. @@ -134,6 +181,13 @@ fn a_damaged_view_in_a_project_file_is_cleaned_up_or_ignored() { "999999": {"pps": 40.0, "scroll": 0.0, "video_track_h": 60.0, "audio_track_h": 56.0}, footage.0.to_string(): {"pps": 40.0, "scroll": 0.0, "video_track_h": 60.0, "audio_track_h": 56.0}, }, + "playheads": { + c.0.to_string(): -5, + b.0.to_string(): i64::MAX, + main.0.to_string(): TICKS_PER_SECOND + 1, + "999999": TICKS_PER_SECOND, + footage.0.to_string(): TICKS_PER_SECOND, + }, })); assert_eq!(t.state.open_sequences, [c, b]); assert_eq!(t.state.active_sequence, Some(c), "an active tab that is not open: the first tab"); @@ -141,6 +195,16 @@ fn a_damaged_view_in_a_project_file_is_cleaned_up_or_ignored() { let v = t.state.timeline_views[&b]; assert!(v.pps <= 1e5 && v.scroll == 0.0 && v.v_scroll <= 1e6 && v.a_scroll == 0.0); assert!(v.video_track_h >= 8.0 && v.audio_track_h <= 600.0); + // playheads: only sequences keep one, never before zero or past the bound, always on a frame + let rate = |seq: ItemId| t.project.sequence(seq).unwrap().settings.frame_rate; + assert_eq!(t.state.playheads.keys().copied().collect::>(), { + let mut ids = vec![main, b, c]; + ids.sort(); + ids + }); + assert_eq!(t.state.playheads[&c], Tick::ZERO); + assert_eq!(t.state.playheads[&b], rate(b).snap(filmcraft_project::ProjectView::MAX_PLAYHEAD)); + assert_eq!(t.state.playheads[&main], rate(main).snap(Tick(TICKS_PER_SECOND + 1))); // not a view at all: the project opens as it does without one for junk in [json!("tabs"), json!([1, 2, 3]), json!({"open_sequences": "all"}), json!({"sequences": {"x": 1}}), json!(null)] { @@ -159,6 +223,7 @@ fn the_view_of_a_deleted_sequence_is_forgotten() { s.execute("project.delete", json!({"items": [b.0]})).unwrap(); assert_eq!(s.state.timeline_views.keys().copied().collect::>(), [main, c]); assert_eq!(s.state.open_sequences, [main, c]); - // the saved view never names it either + // the saved view never names it either, nor keeps its playhead assert_eq!(s.project_view().open_sequences, [main, c]); + assert!(!s.project_view().playheads.contains_key(&b)); } diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 2c97f20a..1abdc666 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -418,6 +418,7 @@ mod tests { open_sequences: vec![seq], active_sequence: Some(seq), sequences: [(seq, SequenceView { pps: 80.0, scroll: 1.5, v_scroll: 0.0, a_scroll: 4.0, video_track_h: 60.0, audio_track_h: 56.0 })].into(), + playheads: [(seq, filmcraft_time::Tick(3 * filmcraft_time::TICKS_PER_SECOND))].into(), }; for pretty in [true, false] { let l = decode(&encode_with_view(&p, Some(&view), pretty)).unwrap(); diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 40c2eb9b..650d42fc 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -1232,9 +1232,9 @@ impl SequenceView { } } -/// What was open when a project was saved: the Timeline's sequence tabs in order, the active one -/// and how each sequence was shown. Stored beside the project in its file, not in it: it is not -/// part of the edit and never an undo step. +/// What was open when a project was saved: the Timeline's sequence tabs in order, the active one, +/// how each sequence was shown and where its playhead was. Stored beside the project in its file, +/// not in it: it is not part of the edit and never an undo step. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct ProjectView { #[serde(default)] @@ -1243,6 +1243,17 @@ pub struct ProjectView { pub active_sequence: Option, #[serde(default)] pub sequences: BTreeMap, + /// Each sequence's playhead, so a sequence reopens where it was left. Files + /// written before it existed have none, and every playhead starts at zero as it did then. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub playheads: BTreeMap, +} + +impl ProjectView { + /// The latest playhead a project file may ask for: ten million seconds (about 115 days), the + /// same bound as a sequence view's scroll position. Playheads are read from project files, so + /// nothing in them is trusted. + pub const MAX_PLAYHEAD: Tick = Tick(10_000_000 * TICKS_PER_SECOND); } /// A LUT imported into the project (`lut.import`). Lumetri refers to it as `lib:`. diff --git a/docs/project-files.md b/docs/project-files.md index c736ec31..c8827150 100644 --- a/docs/project-files.md +++ b/docs/project-files.md @@ -21,12 +21,12 @@ writing them is `crates/format` (`filmcraft-format`); the engine's `file.*` comm clips, effects, keyframes, markers). Media is referenced by path, never embedded. - Files are written compact (no indentation). A 2,000-clip project is about 1.4 MB. - `view` (optional) is what was open when the project was saved: the Timeline's sequence tabs in - order, the active one, and each sequence's zoom, scroll and track heights - (`filmcraft_project::ProjectView`). It is beside the project, not in it: it is not part of the + order, the active one, each sequence's zoom, scroll and track heights, and each sequence's + playhead (`filmcraft_project::ProjectView`). It is beside the project, not in it: it is not part of the edit, never an undo step, and changing it does not mark the project as changed. Opening a project restores it when the Timeline preference "Restore open sequences when opening projects" is on. It is outside the schema version: older builds ignore it, ids that are not sequences and - numbers out of range are dropped or clamped, and a `view` that cannot be read is ignored (the + numbers out of range are dropped or clamped (a playhead also snaps to a frame of its sequence), and a `view` that cannot be read is ignored (the project opens on its first sequence, as it does without one or with no open sequence in it). - A coordinate of an effect's point parameter may be `null`. Point parameters use NaN for "auto" (the frame centre, or the source centre for `anchor`), and JSON writes NaN as `null`; it reads back From 73f2727ee485cd5ef9d0ece2cd387e63312afed2 Mon Sep 17 00:00:00 2001 From: ReReddo <249398616+ReReddo@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:30:29 +0300 Subject: [PATCH 24/64] appearance: View > Appearance names the middle theme "Dark", as Settings does (#471) View > Appearance called the middle color theme "Medium" while Settings > Appearance > Color Theme called the same choice "Dark". Use the Settings name, which is also the stored value and the name in docs/project-files.md. Only the menu label changes. Command ids (view.theme.dark/medium/light) and stored values (darkest/dark/light) are as before. In pt-br.tsv the "Medium" entry becomes "Dark" (Escuro), because that catalog may only hold menu labels. Adds a settings_ui test that each View > Appearance label equals the matching Settings dropdown label. Tests: settings_ui and the i18n tests pass, except i18n::tests::spanish_translates_every_tl_literal. It fails on Windows checkouts with core.autocrlf=true (the source scan looks for "\n") and is unrelated to this change. --- crates/ui-egui/src/i18n/pt-br.tsv | 2 +- crates/ui-egui/src/menus.rs | 3 ++- crates/ui-egui/tests/settings_ui.rs | 15 +++++++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 40dd8244..8a8c7e5c 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -86,6 +86,7 @@ Create Multi-Camera Source Sequence… Criar sequência de origem multicâmera… Create Proxies… Criar proxies… Cut Recortar + Dark Escuro Darkest Mais escuro Delete Render Files Excluir arquivos de renderização Delete Render Files In to Out Excluir arquivos de renderização da entrada à saída @@ -203,7 +204,6 @@ Media Browser Navegador de mídia Media Cache… Cache de mídia… Media… Mídia… - Medium Médio Memory… Memória… Merge Clips… Mesclar clipes… Metadata Metadados diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d6046935..b35c2771 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -90,8 +90,9 @@ pub const UI_COMMANDS: &[UiCommand] = &[ uic!("multicam.editCamerasDialog", "Edit Cameras…", [], None), uic!("voiceover.recordToggle", "Voice-over Record", [], None), uic!("voiceover.settingsDialog", "Voice-Over Record Settings…", [], None), + // ids follow `ThemeKind`, labels follow Settings ▸ Appearance ▸ Color Theme (`darkest`, `dark`, `light`) uic!("view.theme.dark", "Darkest", ["View", "Appearance"], None), - uic!("view.theme.medium", "Medium", ["View", "Appearance"], None), + uic!("view.theme.medium", "Dark", ["View", "Appearance"], None), uic!("view.theme.light", "Light", ["View", "Appearance"], None), uic!("window.workspace.editing", "Editing", ["Window", "Workspaces"], Some("Alt+Shift+1")), uic!("window.workspace.assembly", "Assembly", ["Window", "Workspaces"], Some("Alt+Shift+2")), diff --git a/crates/ui-egui/tests/settings_ui.rs b/crates/ui-egui/tests/settings_ui.rs index c8513618..d5f0d0e8 100644 --- a/crates/ui-egui/tests/settings_ui.rs +++ b/crates/ui-egui/tests/settings_ui.rs @@ -245,6 +245,21 @@ fn dropdowns_numbers_and_reset() { assert_eq!(d.draft()["page"], "memory"); } +#[test] +fn view_menu_and_settings_name_each_color_theme_alike() { + let mut d = Driver::demo(); + let menu = d.ok("ui.menu.list", json!({})); + let menu_label = |id: &str| menu.as_array().unwrap().iter().find(|m| m["id"] == id).unwrap()["label"].as_str().unwrap().to_string(); + d.menu("app.settings.appearance"); + d.click("settings.appearance.colorTheme"); + for (menu_id, pref) in [("view.theme.dark", "darkest"), ("view.theme.medium", "dark"), ("view.theme.light", "light")] { + let id = format!("settings.appearance.colorTheme.{pref}"); + let found = d.ok("ui.elements", json!({"prefix": id})); + let choice = found.as_array().unwrap().iter().find(|e| e["id"] == json!(id)).unwrap(); + assert_eq!(menu_label(menu_id), choice["label"].as_str().unwrap(), "{menu_id} and {id} name the same theme"); + } +} + #[test] fn appearance_labels_and_tooltips_take_effect() { let mut d = Driver::demo(); From 63fe547f700f9f44c57f456ecf3d83d4843bbd9b Mon Sep 17 00:00:00 2001 From: ReReddo <249398616+ReReddo@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:31:41 +0300 Subject: [PATCH 25/64] i18n: the tl! source scan in tests passes on CRLF checkouts spanish_translates_every_tl_literal cuts each source file at "#[cfg(test)]\nmod tests". On a Windows checkout with core.autocrlf=true the files have CRLF, so the cut never matched. The scan then read the test module, including its own assertion text, and failed with "use a plain string literal in tl! and tlf!". Normalise CRLF to LF when reading the sources. Checkouts with LF endings (Linux, macOS, CI) are unaffected. Tests: cargo test -p filmcraft-ui-egui --lib passes (71 tests). The test failed before this change on a Windows checkout. --- crates/ui-egui/src/i18n/mod.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index c04416c6..8a0b8996 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -368,7 +368,8 @@ mod tests { if p.is_dir() { dirs.push(p); } else if p.extension().is_some_and(|e| e == "rs") { - let text = std::fs::read_to_string(&p).unwrap_or_default(); + // a Windows checkout with core.autocrlf has CRLF, which the `\n` below would not match + let text = std::fs::read_to_string(&p).unwrap_or_default().replace("\r\n", "\n"); let cut = text.find("#[cfg(test)]\nmod tests").unwrap_or(text.len()); out.push((p.display().to_string(), text[..cut].to_string())); } From 83da21acd14cd0003d5f66fd2b5b7ee727d341f6 Mon Sep 17 00:00:00 2001 From: ReReddo <249398616+ReReddo@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:34:26 +0300 Subject: [PATCH 26/64] contributors: credit ReReddo Add ReReddo's own entry to contributors/people.toml, display name only, as AGENTS.md asks of contributors. The name is shown once the maintainers' contributors.py run verifies it. --- contributors/people.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 25cf020e..58b06996 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -40,3 +40,7 @@ sync_github_name = true [people.ItzBloxy] display_name = "Bloxy" + +[people.ReReddo] +display_name = "ReReddo" +sync_github_name = false From 5001d6bf2948b48a4db1d81d0caf219dffe3eaac Mon Sep 17 00:00:00 2001 From: MasakatsuFunaki <221743542+MasakatsuFunaki@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:49:24 +0200 Subject: [PATCH 27/64] M-HW: NVENC takes the export's RGBA and converts it on the GPU (#30) 8-bit NVENC sessions (H.265 Main, hardware H.264) now take the export's RGBA frames as ABGR input buffers and NVENC converts them to BT.709 limited 4:2:0 on the GPU, with the same codes as rgba_to_yuv420_8. The CPU conversion ran on the encode thread, where it competed with the render of the next frames for the thread pool (about 13 ms per 1080p frame instead of 1.5 ms) and held NVENC back. A driver that refuses RGB input falls back to the CPU conversion into NV12. HEVC Main 10 is unchanged. 60 s of a 1080p30 project as H.265 in the app: 18.7 s -> 15.0 s (96 -> 120 fps); headless 30.3 s -> 16.8 s. Co-Authored-By: Claude --- crates/platform/README.md | 11 +- crates/platform/src/nvenc/abi_tests.rs | 1 + crates/platform/src/nvenc/export.rs | 20 ++- crates/platform/src/nvenc/ffi.rs | 2 + crates/platform/src/nvenc/mod.rs | 60 ++++++++- crates/platform/src/nvenc/session.rs | 20 ++- crates/platform/tests/nvenc_rgba_input.rs | 157 ++++++++++++++++++++++ docs/testing.md | 3 +- 8 files changed, 261 insertions(+), 13 deletions(-) create mode 100644 crates/platform/tests/nvenc_rgba_input.rs diff --git a/crates/platform/README.md b/crates/platform/README.md index 58df2f6e..8170e629 100644 --- a/crates/platform/README.md +++ b/crates/platform/README.md @@ -65,14 +65,18 @@ let availability = filmcraft_platform::register(); // Available("VideoToolbox") `nvEncodeAPI64.dll` (Windows) or `libnvidia-encode.so.1` (Linux), API 12.1, is loaded at run time, so machines without NVIDIA still start. Windows uses a Direct3D 11 device; Linux retains a primary CUDA context through the runtime-loaded `libcuda.so.1`, released after the encoder is destroyed. - No SDK, CUDA toolkit or NVIDIA binaries are bundled or required at build time. RGBA is converted with the software encoder's own BT.709 limited conversion into NV12 - input buffers (a ring of eight); the encoder runs preset P5 with high-quality tuning, CABAC (CAVLC + No SDK, CUDA toolkit or NVIDIA binaries are bundled or required at build time. 8-bit pictures go + in as the export's RGBA (ABGR input buffers, a ring of eight) and NVENC converts them to BT.709 + limited 4:2:0 on the GPU, with the same codes as the software encoder's own conversion; a driver + that refuses RGB input gets that CPU conversion into NV12 instead. The conversion used to run on + the encode thread, where it competed with the render of the next frames for the thread pool and + held NVENC back. The encoder runs preset P5 with high-quality tuning, CABAC (CAVLC for Baseline), one B-frame when the profile and GPU allow it, and an IDR at every keyframe distance; the parameter sets go into `avcC`. Export ▸ Hardware encoding (off by default) selects it. It declines two-pass VBR, HDR, MXF, interlaced output, sizes outside NVENC's limits and systems without an NVIDIA GPU or driver, and the software encoder runs instead. A failure during an export ends it with an error, since a hardware stream cannot be finished in software. The same session, - ring and NV12 path encode H.265, see [Hardware H.265 (HEVC) encoding (Windows, + ring and input path encode H.265, see [Hardware H.265 (HEVC) encoding (Windows, NVENC)](#hardware-h265-hevc-encoding-windows-nvenc). - **Linux: VA-API, H.264 (`avcC`) and HEVC (`hvcC`)**: H.264 8-bit 4:2:0 progressive, Constrained Baseline / Main / High; HEVC Main, Main 10 and Main Still Picture, 4:2:0 8- and 10-bit (`vaapi/`), on Intel (iHD, i965), AMD and other Mesa drivers. `libva.so.2` and `libva-drm.so.2` @@ -281,6 +285,7 @@ level 4 High tier, which many hardware decoders refuse; it is now level 4.1 Main | `tests/nvenc_export.rs` (Windows / Linux; fallback also tested without NVIDIA) | Export with hardware encoding against the software encoder through the export pipeline: the two decoded files at worst 54.8 dB luma PSNR; ffmpeg decodes the file without errors; declined cases go to the software encoder; the counters | | `tests/nvenc_hevc.rs` (Windows, NVIDIA with HEVC) | HEVC from NVENC (1280×720, 6 Mbps, 72 frames, keyframe every 24) decodes with our HEVC decoder at worst 48.9 dB luma / 51.0 dB chroma PSNR; IDR at 0, 24, 48; dts strictly increasing, pts a permutation, `sps_max_num_reorder_pics` within the dts shift; no parameter sets in the samples; the `hvcC`'s profile / tier / level bytes are the SPS's own; VUI BT.709 limited and timing `(1, 24)`; hostile configurations, sizes, planes and encoders dropped mid-stream give errors, never panics; keyframes every 1–2 pictures without B-frames | | `tests/nvenc_hevc_export.rs` (Windows, NVIDIA with HEVC) | H.265 export through the real pipeline, MP4 and QuickTime, toggle Auto and Off: counters (72 frames, 1 session, 0 declined), our decoder against the software H.264 export at worst 54.8 dB luma PSNR, ffprobe `codec_name=hevc`, `profile=Main`, `codec_tag_string=hvc1`, `pix_fmt=yuv420p`, BT.709 limited, 72 frames, 24/1, keyframes at 0 and 48, start 0, `ffmpeg -xerror` clean; with AAC audio; 1920×1080 and 642×362 cropped back from the coded size; every decline (HDR, analysis pass, two-pass, interlaced, non-square pixels, MXF, sizes over 65535, odd sizes, sizes outside the GPU's limits) an error naming NVENC, counted once; H.264 with hardware encoding Off never touches NVENC | +| `tests/nvenc_rgba_input.rs` (Windows, NVIDIA) | RGBA input, HEVC Main and H.264 High, 1280×720: solid red, green, blue, white, black and grey decode (our decoder) to the exact BT.709 limited codes of `rgba_to_yuv420_8`, within 2; a moving picture at worst 47.7 dB luma / 50.7 dB chroma PSNR against that conversion; Main 10 has no RGBA input; short pictures and planar / RGBA pictures for the other kind of encoder are errors | | `tests/nvenc_hevc_probe.rs` (Windows) | the HEVC probe in a fresh process: its cost, the cached answer, `available(Hevc)` following it after `register()` (twice) | | `tests/nvenc_hevc_warm.rs` (Windows) | `register()` answers the HEVC and the Main 10 questions on a thread of its own, before anyone asks; the answers are kept and `hdr_available(Hevc)` follows the Main 10 probe | | `tests/nvenc_hevc_main10.rs` (Windows, NVIDIA with 10-bit HEVC) | HEVC Main 10 in process, PQ and HLG: a float picture (grey ramp to peak white, a moving box, saturated BT.2020 bars) is converted with `rgbf_to_yuv420_10`, encoded and decoded with our HEVC decoder (`Yuv16`, 10 bits, codes below 1024): worst 71.1 dB luma / 70.6 dB chroma PSNR on the 10-bit scale, 803 distinct luma levels on a ramp row, peak code 940 survives; IDR at 0 and 24; the VUI is BT.2020 / 16 or 18 / BT.2020 NCL / limited; the IDR samples carry two prefix SEI NAL units whose messages are byte-for-byte the expected 137 / 144 payloads (HLG: none; other pictures: none); Main and Main 10 refuse each other's pictures; hostile sizes, bitrates, signals, huge or empty SEI payloads, encoders dropped with SEI in flight | diff --git a/crates/platform/src/nvenc/abi_tests.rs b/crates/platform/src/nvenc/abi_tests.rs index 1d6980fb..270108a5 100644 --- a/crates/platform/src/nvenc/abi_tests.rs +++ b/crates/platform/src/nvenc/abi_tests.rs @@ -444,6 +444,7 @@ fn constants_match_the_header() { assert_eq!(NV_ENC_LEVEL_HEVC_62 as i64, 186, "NV_ENC_LEVEL_HEVC_62"); assert_eq!(NVENCAPI_VERSION as i64, 16777228, "NVENCAPI_VERSION"); assert_eq!(NV_ENC_BUFFER_FORMAT_YUV420_10BIT as i64, 65536, "NV_ENC_BUFFER_FORMAT_YUV420_10BIT"); + assert_eq!(NV_ENC_BUFFER_FORMAT_ABGR as i64, 268435456, "NV_ENC_BUFFER_FORMAT_ABGR"); assert_eq!(NV_ENC_CAPS_SUPPORT_10BIT_ENCODE as i64, 39, "NV_ENC_CAPS_SUPPORT_10BIT_ENCODE"); assert_eq!(NV_ENC_VUI_COLOR_PRIMARIES_BT2020 as i64, 9, "NV_ENC_VUI_COLOR_PRIMARIES_BT2020"); assert_eq!(NV_ENC_VUI_TRANSFER_CHARACTERISTIC_SMPTE2084 as i64, 16, "NV_ENC_VUI_TRANSFER_CHARACTERISTIC_SMPTE2084"); diff --git a/crates/platform/src/nvenc/export.rs b/crates/platform/src/nvenc/export.rs index 2acd3ab2..a63b6a0e 100644 --- a/crates/platform/src/nvenc/export.rs +++ b/crates/platform/src/nvenc/export.rs @@ -120,6 +120,8 @@ impl VideoEncoder for NvencEncoder { } (true, None) => return Err(ExportError::Unsupported("NVENC Main 10 needs the HDR picture (EncoderFrame::hdr)".into())), (false, Some(_)) => return Err(ExportError::Unsupported("NVENC Main (8-bit) does not take HDR pictures".into())), + // the GPU converts the RGBA picture itself (BT.709 limited range, the same codes as `rgba_to_yuv420_8`) + (false, None) if self.enc.takes_rgba() => self.enc.encode_rgba(f.rgba, f.index).map_err(|e| ExportError::Encode(format!("NVENC: {e}{hint}")))?, (false, None) => { filmcraft_export::timed(filmcraft_export::Stage::Convert, || { filmcraft_export::rgba_to_yuv420_8(f.rgba, w, h, &mut self.y, &mut self.u, &mut self.v) @@ -209,7 +211,21 @@ pub fn factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettin // H.265 through NVENC is Windows-only for now; on Linux this backend takes H.264 only return if cfg!(target_os = "windows") { hevc_factory(w, h, rate, s) } else { None }; } - factory_with(format, w, h, rate, s, Nvenc::new) + factory_with(format, w, h, rate, s, |cfg| open(cfg, &Signal::default())) +} + +/// Open the encoder for `cfg`. An 8-bit one takes the export's RGBA pictures as they are and converts +/// them on the GPU, which keeps the RGB → 4:2:0 conversion off the CPU (where it competes with the +/// render of the next frames); where the driver refuses RGB input, it takes the CPU's 4:2:0 as before. +/// Main 10 takes its 10-bit planes. +fn open(cfg: &Config, signal: &Signal) -> std::result::Result { + if cfg.profile == Profile::HevcMain10 { + return Nvenc::with_signal(cfg, signal); + } + Nvenc::with_rgba_input(cfg, signal).or_else(|why| { + log::info!("NVENC RGBA input declined ({why}): converting to 4:2:0 on the CPU"); + Nvenc::with_signal(cfg, signal) + }) } /// The H.264 side of [`factory`], with the driver call (`open`) passed in so tests can stand in for it. @@ -254,7 +270,7 @@ fn hevc_factory(w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -> Option c, Err(why) => return declined(&why), }; - match Nvenc::with_signal(&cfg, &nvenc_signal(&s.signal)) { + match open(&cfg, &nvenc_signal(&s.signal)) { Ok(enc) => { filmcraft_export::note_hw_encode_session(); Some(Ok(Box::new(new_encoder(enc, w, h, rate, s.signal)))) diff --git a/crates/platform/src/nvenc/ffi.rs b/crates/platform/src/nvenc/ffi.rs index c3e479f4..dd348cff 100644 --- a/crates/platform/src/nvenc/ffi.rs +++ b/crates/platform/src/nvenc/ffi.rs @@ -94,6 +94,8 @@ pub const NV_ENC_MEMORY_HEAP_SYSMEM_CACHED: u32 = 2; pub const NV_ENC_BUFFER_FORMAT_NV12: u32 = 0x1; /// P010: 10-bit 4:2:0, 16-bit little-endian samples with the value in the high 10 bits. pub const NV_ENC_BUFFER_FORMAT_YUV420_10BIT: u32 = 0x10000; +/// ABGR: 8-bit packed, one 32-bit little-endian word per pixel with R in the lowest byte (bytes R, G, B, A). +pub const NV_ENC_BUFFER_FORMAT_ABGR: u32 = 0x10000000; pub const NV_ENC_PARAMS_RC_VBR: u32 = 1; pub const NV_ENC_PARAMS_RC_CBR: u32 = 2; pub const NV_ENC_TUNING_INFO_HIGH_QUALITY: u32 = 1; diff --git a/crates/platform/src/nvenc/mod.rs b/crates/platform/src/nvenc/mod.rs index e03ed0cf..f97adc5f 100644 --- a/crates/platform/src/nvenc/mod.rs +++ b/crates/platform/src/nvenc/mod.rs @@ -11,8 +11,11 @@ //! only encoder of the H.265 format (which has no software encoder). //! //! ```text -//! RGBA (the export pipeline) ──► BT.709 limited 4:2:0 (the software encoder's conversion) -//! ──► NV12 input buffer ──NVENC──► Annex B ──► length-prefixed samples + avcC / hvcC +//! RGBA (the export pipeline) ──► ABGR input buffer ──NVENC (BT.709 limited 4:2:0 on the GPU)──► +//! Annex B ──► length-prefixed samples + avcC / hvcC +//! +//! where the driver refuses RGB input (`Nvenc::with_rgba_input` fails), as before: +//! RGBA ──► BT.709 limited 4:2:0 (the software encoder's conversion) ──► NV12 input buffer ──NVENC──► … //! //! HDR (PQ / HLG) HEVC Main 10: //! encoded BT.2020 R'G'B' floats ──► 10-bit limited 4:2:0 (`filmcraft_export::rgbf_to_yuv420_10`) @@ -120,6 +123,8 @@ pub struct Nvenc { size: (u32, u32), /// Main 10: pictures go in as 10-bit P010 ([`Nvenc::encode_10`]), otherwise as 8-bit NV12 ([`Nvenc::encode`]). ten_bit: bool, + /// Pictures go in as packed RGBA ([`Nvenc::encode_rgba`]) and the GPU converts them to 4:2:0. + rgba_input: bool, } /// The H.264 encoder (the name it had before HEVC joined it). @@ -231,6 +236,19 @@ impl Nvenc { /// on every IDR picture), or say why NVENC does not take this configuration. H.264 takes the /// default signal only. pub fn with_signal(cfg: &Config, signal: &Signal) -> Result { + Self::open(cfg, signal, false) + } + + /// Open an 8-bit encoder that takes straight RGBA8 pictures ([`Nvenc::encode_rgba`]) and converts + /// them to 4:2:0 on the GPU, or say why not (Main 10, or a driver that refuses RGB input). + pub fn with_rgba_input(cfg: &Config, signal: &Signal) -> Result { + if cfg.profile == Profile::HevcMain10 { + return Err("RGBA input is 8-bit only".into()); + } + Self::open(cfg, signal, true) + } + + fn open(cfg: &Config, signal: &Signal, rgba_input: bool) -> Result { let (w, h) = (cfg.width, cfg.height); if w == 0 || h == 0 || w % 2 != 0 || h % 2 != 0 { return Err(format!("{w}x{h}: NVENC needs even dimensions")); @@ -278,6 +296,7 @@ impl Nvenc { Profile::High => 2, }, ten_bit, + rgba_input, signal: signal.clone(), level, sar: cfg.sar, @@ -312,6 +331,7 @@ impl Nvenc { emitted: 0, size: (w, h), ten_bit, + rgba_input, }) } @@ -345,6 +365,25 @@ impl Nvenc { self.ten_bit } + /// Whether this encoder takes packed RGBA pictures ([`Nvenc::encode_rgba`]) instead of planar ones. + pub fn takes_rgba(&self) -> bool { + self.rgba_input + } + + /// Encode picture `index` from straight RGBA8 (`w * 4` bytes per row, alpha ignored); NVENC + /// converts it to 4:2:0 with the stream's matrix. The pictures that came out, as for + /// [`Nvenc::encode`]. An error for an encoder opened without [`Nvenc::with_rgba_input`]. + pub fn encode_rgba(&mut self, rgba: &[u8], index: u64) -> Result, String> { + if !self.rgba_input { + return Err("an RGBA picture for a planar-input encoder".into()); + } + let (w, h) = (self.size.0 as usize, self.size.1 as usize); + if rgba.len() < w.saturating_mul(h).saturating_mul(4) { + return Err("the picture is smaller than the encoder's size".into()); + } + self.submit_picture(index, |l| fill_rgba(l, rgba, w, h)) + } + /// Encode picture `index` from planar 8-bit 4:2:0 (`u`, `v` at half size, rows `w` and `w / 2` bytes). /// Returns the pictures that came out (usually the oldest; none while the ring fills). An error /// for a Main 10 encoder. @@ -352,6 +391,9 @@ impl Nvenc { if self.ten_bit { return Err("an 8-bit picture for a Main 10 encoder".into()); } + if self.rgba_input { + return Err("a planar picture for an RGBA-input encoder".into()); + } let (w, h) = (self.size.0 as usize, self.size.1 as usize); let (luma, chroma) = (w.saturating_mul(h), (w / 2).saturating_mul(h / 2)); if y.len() < luma || u.len() < chroma || v.len() < chroma { @@ -455,6 +497,20 @@ fn fill_nv12(l: Locked<'_>, y: &[u8], u: &[u8], v: &[u8], w: usize, h: usize) { } } +/// Copy straight RGBA8 rows into an ABGR input buffer (the same byte order: R, G, B, A). A row the +/// pitch cannot hold is skipped, never overrun. +fn fill_rgba(l: Locked<'_>, rgba: &[u8], w: usize, h: usize) { + let row = w.saturating_mul(4); + if row == 0 || l.pitch == 0 { + return; + } + for (src, dst) in rgba.chunks_exact(row).zip(l.data.chunks_mut(l.pitch)).take(h) { + if let Some(d) = dst.get_mut(..row) { + d.copy_from_slice(src); + } + } +} + /// Copy planar 10-bit 4:2:0 into a P010 input buffer: each sample is a little-endian `u16` with the /// 10-bit code in its high bits (`code << 6`), the chroma plane interleaved (Cb, Cr) after the luma rows, /// `pitch` in bytes. Codes above 10 bits are masked; a row the pitch cannot hold is skipped, never overrun. diff --git a/crates/platform/src/nvenc/session.rs b/crates/platform/src/nvenc/session.rs index 1791c017..b19691f7 100644 --- a/crates/platform/src/nvenc/session.rs +++ b/crates/platform/src/nvenc/session.rs @@ -150,6 +150,8 @@ pub struct Params { pub profile: u8, /// HEVC Main 10: 10-bit 4:2:0 pictures (P010), otherwise 8-bit (NV12). pub ten_bit: bool, + /// 8-bit only: pictures go in as packed RGBA (ABGR) and NVENC converts them to 4:2:0 on the GPU. + pub rgba_input: bool, /// HEVC: the VUI colour description and the SEI messages of every IDR picture (H.264 keeps BT.709). pub signal: Signal, /// The codec's own level code: `level × 10` for H.264, `level × 30` for HEVC. @@ -160,7 +162,7 @@ pub struct Params { } /// An input buffer locked for writing: NV12 (or P010) rows go to `data` at `pitch` bytes per row, the -/// chroma plane after `pitch * height` bytes. +/// chroma plane after `pitch * height` bytes; packed RGBA (ABGR) has `height` rows and no chroma plane. pub struct Locked<'a> { pub data: &'a mut [u8], pub pitch: usize, @@ -315,8 +317,14 @@ impl Session { if !p.signal.sei.is_empty() && p.codec != Codec::Hevc { return Err("SEI messages are written for HEVC only".into()); } - self.fmt = if p.ten_bit { NV_ENC_BUFFER_FORMAT_YUV420_10BIT } else { NV_ENC_BUFFER_FORMAT_NV12 }; - self.bytes_per_sample = if p.ten_bit { 2 } else { 1 }; + if p.ten_bit && p.rgba_input { + return Err("RGBA input is 8-bit only".into()); + } + (self.fmt, self.bytes_per_sample) = match (p.ten_bit, p.rgba_input) { + (true, _) => (NV_ENC_BUFFER_FORMAT_YUV420_10BIT, 2), + (false, true) => (NV_ENC_BUFFER_FORMAT_ABGR, 4), + (false, false) => (NV_ENC_BUFFER_FORMAT_NV12, 1), + }; self.gop = u64::from(p.gop.max(1)); self.submitted = 0; // the SEI payloads: heap blocks that outlive every picture (see the field documentation) @@ -516,7 +524,8 @@ impl Session { let (w, h) = self.size; // SAFETY: the buffer handle is one this session created. `LockInputBuffer` returns a // writable block of `pitch * h * 3 / 2` bytes (NV12 or P010: luma rows then chroma rows, the pitch - // in bytes) that stays valid until `UnlockInputBuffer`; the slice borrows it only inside `fill`. + // in bytes), or `pitch * h` bytes (ABGR: packed rows only), that stays valid until + // `UnlockInputBuffer`; the slice borrows it only inside `fill`. let pitch = unsafe { let mut l: NV_ENC_LOCK_INPUT_BUFFER = std::mem::zeroed(); l.version = NV_ENC_LOCK_INPUT_BUFFER_VER; @@ -531,7 +540,8 @@ impl Session { let _ = unlock(self.enc, input); return Err(format!("the driver returned an input pitch of {} bytes for {w}-pixel rows of {}-byte samples", l.pitch, self.bytes_per_sample)); } - let len = (l.pitch as usize).saturating_mul(h as usize).saturating_mul(3) / 2; + let rows = (l.pitch as usize).saturating_mul(h as usize); + let len = if self.fmt == NV_ENC_BUFFER_FORMAT_ABGR { rows } else { rows.saturating_mul(3) / 2 }; fill(Locked { data: std::slice::from_raw_parts_mut(l.bufferDataPtr.cast::(), len), pitch: l.pitch as usize }); let st = unlock(self.enc, input); if st != NV_ENC_SUCCESS { diff --git a/crates/platform/tests/nvenc_rgba_input.rs b/crates/platform/tests/nvenc_rgba_input.rs new file mode 100644 index 00000000..4009f3e6 --- /dev/null +++ b/crates/platform/tests/nvenc_rgba_input.rs @@ -0,0 +1,157 @@ +//! NVENC with packed RGBA input (Windows, NVIDIA GPU): the GPU's RGB → 4:2:0 conversion matches the +//! export's own BT.709 limited-range conversion (`filmcraft_export::rgba_to_yuv420_8`), for HEVC Main +//! and H.264. Skips without an NVIDIA GPU / driver. +#![cfg(target_os = "windows")] + +use filmcraft_isobmff::{AvcConfig, SampleEntry}; +use filmcraft_platform::nvenc::{Codec, Config, Nvenc, Packet, Profile, Signal, available, hevc_available}; + +/// NVENC sessions are a limited resource on consumer GPUs: one test at a time. +static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +/// A moving test picture: gradient background, a moving box, a little texture. +fn moving(w: usize, h: usize, i: usize) -> Vec { + let mut v = vec![0u8; w * h * 4]; + for y in 0..h { + for x in 0..w { + let o = (y * w + x) * 4; + let (bx, by) = ((i * 7) % (w - 64), (i * 3) % (h - 64)); + let in_box = x >= bx && x < bx + 64 && y >= by && y < by + 64; + let tex = ((x * 31 + y * 17 + i * 5) % 23) as u8; + v[o] = if in_box { 230 } else { (x * 200 / w) as u8 + tex }; + v[o + 1] = if in_box { 40 } else { (y * 200 / h) as u8 + tex }; + v[o + 2] = if in_box { 60 } else { 90 + tex }; + v[o + 3] = 255; + } + } + v +} + +fn solid(w: usize, h: usize, rgb: [u8; 3]) -> Vec { + [rgb[0], rgb[1], rgb[2], 255].repeat(w * h) +} + +fn config(w: u32, h: u32, profile: Profile) -> Config { + Config { + width: w, + height: h, + fps: (30, 1), + bitrate_kbps: 8000, + max_bitrate_kbps: 12000, + cbr: false, + keyint: 30, + profile, + level: None, + sar: None, + bframes: true, + } +} + +fn psnr(a: &[u8], b: &[u8]) -> f64 { + let mse = a.iter().zip(b).map(|(x, y)| (f64::from(*x) - f64::from(*y)).powi(2)).sum::() / a.len() as f64; + if mse == 0.0 { 99.0 } else { 10.0 * (255.0f64 * 255.0 / mse).log10() } +} + +fn mean(p: &[u8]) -> f64 { + p.iter().map(|v| f64::from(*v)).sum::() / p.len() as f64 +} + +/// Encode `frames` through the RGBA input, decode with our own decoder: the decoded planes by picture. +fn round_trip(profile: Profile, w: usize, h: usize, frames: &[Vec]) -> Vec<[Vec; 3]> { + let mut enc = + Nvenc::with_rgba_input(&config(w as u32, h as u32, profile), &Signal::default()).unwrap_or_else(|e| panic!("{profile:?} with RGBA input: {e}")); + assert!(enc.takes_rgba()); + assert!(enc.encode(&[0; 4], &[0; 1], &[0; 1], 0).is_err(), "planar pictures are refused"); + let mut packets: Vec = Vec::new(); + for (i, f) in frames.iter().enumerate() { + packets.extend(enc.encode_rgba(f, i as u64).unwrap()); + } + packets.extend(enc.flush().unwrap()); + assert_eq!(packets.len(), frames.len(), "one packet per picture"); + let entry = match enc.codec() { + Codec::Hevc => SampleEntry::hevc(enc.hevc_config().expect("an hvcC record").clone(), w as u16, h as u16), + Codec::H264 => { + let (sps, pps) = enc.parameter_sets(); + SampleEntry::avc(AvcConfig::new(vec![sps.to_vec()], vec![pps.to_vec()], 4), w as u16, h as u16) + } + }; + let mut dec = filmcraft_codecs::software_video_decoder(&entry).unwrap(); + let mut out = Vec::new(); + for p in &packets { + out.extend(dec.decode(&p.data, p.pts).unwrap()); + } + out.extend(dec.flush()); + out.sort_by_key(|f| f.pts); + assert_eq!(out.len(), frames.len()); + out.into_iter() + .map(|f| { + let filmcraft_frame::PixelData::Yuv8 { planes, .. } = &f.frame.data else { panic!("8-bit planes") }; + [planes[0].to_vec(), planes[1].to_vec(), planes[2].to_vec()] + }) + .collect() +} + +fn check(profile: Profile) { + let (w, h) = (1280usize, 720usize); + // solid colours: the decoded means are the BT.709 limited-range codes of the export's own conversion + let colours: [[u8; 3]; 6] = [[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 255], [0, 0, 0], [128, 128, 128]]; + let mut frames: Vec> = colours.iter().map(|c| solid(w, h, *c)).collect(); + frames.extend((0..24).map(|i| moving(w, h, i))); + let decoded = round_trip(profile, w, h, &frames); + let (mut y, mut u, mut v) = (Vec::new(), Vec::new(), Vec::new()); + for (k, c) in colours.iter().enumerate() { + filmcraft_export::rgba_to_yuv420_8(&frames[k], w, h, &mut y, &mut u, &mut v); + let want = [f64::from(y[0]), f64::from(u[0]), f64::from(v[0])]; + let got = [mean(&decoded[k][0]), mean(&decoded[k][1]), mean(&decoded[k][2])]; + eprintln!("{profile:?} {c:?}: Y/Cb/Cr {got:.1?}, BT.709 limited {want:?}"); + for (g, e) in got.iter().zip(want) { + assert!((g - e).abs() <= 2.0, "{profile:?} {c:?}: decoded {got:.1?}, expected {want:?}"); + } + } + // a moving picture: close to the export's own conversion encoded the planar way + let (mut worst, mut worst_chroma) = (99.0f64, 99.0f64); + for (k, f) in frames.iter().enumerate().skip(colours.len()) { + filmcraft_export::rgba_to_yuv420_8(f, w, h, &mut y, &mut u, &mut v); + worst = worst.min(psnr(&decoded[k][0], &y)); + worst_chroma = worst_chroma.min(psnr(&decoded[k][1], &u)).min(psnr(&decoded[k][2], &v)); + } + eprintln!("{profile:?}: worst luma PSNR {worst:.1} dB, chroma {worst_chroma:.1} dB"); + assert!(worst > 40.0, "{profile:?} luma PSNR {worst:.1} dB"); + assert!(worst_chroma > 40.0, "{profile:?} chroma PSNR {worst_chroma:.1} dB"); +} + +#[test] +fn hevc_rgba_input_converts_like_the_export() { + let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if !hevc_available() { + eprintln!("SKIPPED: no NVENC HEVC encoder"); + return; + } + check(Profile::HevcMain); +} + +#[test] +fn h264_rgba_input_converts_like_the_export() { + let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if !available() { + eprintln!("SKIPPED: no NVENC"); + return; + } + check(Profile::High); +} + +#[test] +fn main10_has_no_rgba_input_and_short_pictures_are_errors() { + let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if !hevc_available() { + eprintln!("SKIPPED: no NVENC HEVC encoder"); + return; + } + assert!(Nvenc::with_rgba_input(&config(640, 360, Profile::HevcMain10), &Signal::default()).is_err()); + let mut enc = Nvenc::with_rgba_input(&config(640, 360, Profile::HevcMain), &Signal::default()).unwrap(); + assert!(enc.encode_rgba(&[], 0).is_err()); + assert!(enc.encode_rgba(&vec![0; 640 * 360 * 4 - 1], 0).is_err()); + let mut planar = Nvenc::new(&config(640, 360, Profile::HevcMain)).unwrap(); + assert!(!planar.takes_rgba()); + assert!(planar.encode_rgba(&solid(640, 360, [1, 2, 3]), 0).is_err(), "RGBA pictures for a planar encoder"); +} diff --git a/docs/testing.md b/docs/testing.md index 1b64781c..7e16e8d4 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -73,7 +73,8 @@ are never linked or shipped ([AGENTS.md](../AGENTS.md) §2). without a Direct3D 11 video device or the HEVC / VP9 / AV1 codec extensions of the Microsoft Store. The VP9 and AV1 fixtures (libvpx-vp9, libaom-av1; 360p, 1080p, 2160p, hidden alt-ref frames, two GOPs) need an ffmpeg with those encoders. -The NVENC tests (`crates/platform/tests/nvenc.rs` and `nvenc_export.rs` for H.264, `nvenc_hevc.rs`, +The NVENC tests (`crates/platform/tests/nvenc.rs` and `nvenc_export.rs` for H.264, `nvenc_rgba_input.rs` for +the GPU's RGB → 4:2:0 conversion, `nvenc_hevc.rs`, `nvenc_hevc_export.rs`, `nvenc_hevc_probe.rs` and `nvenc_hevc_warm.rs` for H.265, `nvenc_hevc_main10.rs` and `nvenc_hevc_hdr_export.rs` for Main 10 HDR; Windows only) skip, printing `SKIPPED`, without an NVIDIA GPU with NVENC (H.265: when the HEVC probe says there is no HEVC encoder; Main 10: when the Main 10 probe says From 87a01072d0fc57da257af01adf1bcc07df600822 Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Fri, 9 Oct 2026 16:01:08 -0600 Subject: [PATCH 28/64] #482: Preview transition placement while dragging --- crates/engine/src/commands.rs | 17 ++- crates/engine/src/vfx_tests.rs | 32 +++++ crates/ui-egui/README.md | 12 ++ crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/panels/timeline.rs | 38 +++++- crates/ui-egui/tests/transition_drop_ui.rs | 141 +++++++++++++++++++++ 6 files changed, 232 insertions(+), 10 deletions(-) create mode 100644 crates/ui-egui/tests/transition_drop_ui.rs diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index b5e4c847..4bb8113e 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -3183,6 +3183,16 @@ fn set_transition(s: &mut Session, p: &Value) -> Result { } fn apply_transition(s: &mut Session, p: &Value, kind: TrackKind) -> Result { + let (track, tr) = preview_transition(s, p, kind)?; + let label = format!("Apply {}", tr.effect.def().map_or("Transition", |d| d.name)); + let id = s.edit_sequence(&label, |q, ctx, _| Ok(edit::add_transition(q, track, tr, ctx)?))?; + Ok(json!({"transition": id.0})) +} + +/// Plan an applied transition without editing the project or allocating an id. +/// Uses the same `effect`, `clip`, `edge`, `frames` and settings as transition application, +/// so a drag preview shows the exact range that release will commit. +pub fn preview_transition(s: &Session, p: &Value, kind: TrackKind) -> Result<(TrackId, Transition)> { let eff_id = str_p(p, "effect") .map(str::to_string) .unwrap_or_else(|| if kind == TrackKind::Video { s.state.default_video_transition.clone() } else { s.state.default_audio_transition.clone() }); @@ -3233,6 +3243,9 @@ fn apply_transition(s: &mut Session, p: &Value, kind: TrackKind) -> Result Result Session { s } +#[test] +fn transition_preview_is_read_only_and_matches_apply_and_undo() { + use crate::commands::preview_transition; + use filmcraft_project::TrackKind; + for (kind, effect) in [(TrackKind::Video, "cross_dissolve"), (TrackKind::Audio, "constant_power")] { + let mut s = demo(); + let clips: Vec<_> = s.active_sequence().unwrap().tracks(kind)[0].items.iter().map(|c| c.id).collect(); + for clip in clips { + for edge in ["in", "out"] { + let params = json!({"effect": effect, "clip": clip.0, "edge": edge, "frames": 7}); + let before = s.project.to_json(); + let revision = s.revision; + let (track, planned) = preview_transition(&s, ¶ms, kind).unwrap(); + assert_eq!(s.project.to_json(), before); + assert_eq!(s.revision, revision); + let result = s.execute("effects.apply", params).unwrap(); + let applied = + s.active_sequence().unwrap().track(track).unwrap().transitions.iter().find(|t| Some(t.id.0) == result["transition"].as_u64()).unwrap(); + assert_eq!((applied.start, applied.duration, applied.from, applied.to), (planned.start, planned.duration, planned.from, planned.to)); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.project.to_json(), before); + s.execute("edit.redo", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + } + } + assert!(preview_transition(&s, &json!({"effect": effect, "clip": u64::MAX}), kind).is_err()); + let track = s.active_sequence().unwrap().tracks(kind)[0].id; + std::sync::Arc::make_mut(&mut s.project).sequence_mut(s.state.active_sequence.unwrap()).unwrap().track_mut(track).unwrap().locked = true; + assert!(preview_transition(&s, &json!({"effect": effect, "clip": s.active_sequence().unwrap().track(track).unwrap().items[0].id.0}), kind).is_err()); + } +} + fn first_v1_clip(s: &Session) -> u64 { s.active_sequence().unwrap().video_tracks[0].items[0].id.0 } diff --git a/crates/ui-egui/README.md b/crates/ui-egui/README.md index 21ff94e6..9a0fbf1e 100644 --- a/crates/ui-egui/README.md +++ b/crates/ui-egui/README.md @@ -3,6 +3,18 @@ The egui frontend draws panels and dialogs, and dispatches project changes through the engine. Interactive controls keep stable automation ids regardless of the interface language. +## Transition drop previews + +Dragging a video or audio transition over a compatible clip shows its actual time span, the +selected cut and an In/Out label. Shared cuts center the span; isolated edges use a one-sided +span. The preview uses the engine's read-only transition planner, including Timeline duration +preferences, and release dispatches `effects.apply` with the same edge. Locked tracks and +incompatible clip types show no valid transition preview. Ordinary effects retain their clip +outline. `timeline.transitionDropPreview` exposes the span and edge to automation. + +`transition_drop_ui` covers both edges of short video/audio clips, hover without project edits, +the committed span and undo. Set `FILMCRAFT_UI_SNAPSHOT_DIR` to render screenshots with wgpu. + ## Localisation Edit > Language offers English, Japanese and Spanish. The language is stored in the engine's diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index a39dc82e..17cd5b49 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3480,3 +3480,5 @@ New Sequence Nueva secuencia Tracks Pistas tracks pistas + In Entrada + Out Salida diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 811a75ee..bf083092 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2164,16 +2164,40 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & && let Some(row) = layout.rows.iter().find(|r| r.track == tid) { { - let r = Rect::from_min_max(pos2(layout.x_of(it.start), row.rect.min.y), pos2(layout.x_of(it.end()), row.rect.max.y)); - ui.painter().rect_stroke(r, 3.0, Stroke::new(2.0, app.tokens.accent), StrokeKind::Inside); + let transition_kind = filmcraft_project::find_effect(&effect).and_then(|d| match d.kind { + filmcraft_project::EffectKind::VideoTransition => Some(TrackKind::Video), + filmcraft_project::EffectKind::AudioTransition => Some(TrackKind::Audio), + _ => None, + }); + let edge = if p.x - layout.x_of(it.start) < layout.x_of(it.end()) - p.x { "in" } else { "out" }; + let params = json!({"effect": effect, "clip": clip.0, "edge": edge}); + if let Some(kind) = transition_kind { + if let Ok((_, tr)) = filmcraft_engine::commands::preview_transition(&app.session, ¶ms, kind) { + let r = Rect::from_min_max(pos2(layout.x_of(tr.start), row.rect.min.y + 1.0), pos2(layout.x_of(tr.end()), row.rect.max.y - 1.0)); + let painter = ui.painter().with_clip_rect(layout.content); + painter.rect_filled(r, 2.0, app.tokens.accent.gamma_multiply(0.35)); + paint_hatch(&painter, r.intersect(layout.content)); + painter.rect_stroke(r, 2.0, Stroke::new(2.0, app.tokens.accent), StrokeKind::Inside); + let cut = if edge == "in" { it.start } else { it.end() }; + let x = layout.x_of(cut); + painter.line_segment([pos2(x, r.top()), pos2(x, r.bottom())], Stroke::new(3.0, app.tokens.accent)); + // Keep the edge label readable even when the clip/transition is only a few pixels wide. + let label = if edge == "in" { tl!("In") } else { tl!("Out") }; + let label_width = painter.layout_no_wrap(label.to_string(), Tokens::ui(11.0), app.tokens.accent).size().x + 8.0; + let label_rect = Rect::from_center_size(pos2(p.x, row.rect.top() + 10.0), vec2(label_width, 15.0)); + painter.rect_filled(label_rect, 2.0, app.tokens.panel_bg); + painter.text(label_rect.center(), Align2::CENTER_CENTER, label, Tokens::ui(11.0), app.tokens.accent); + app.auto.add("timeline.transitionDropPreview", r.intersect(layout.content), edge); + } + } else { + let r = Rect::from_min_max(pos2(layout.x_of(it.start), row.rect.min.y), pos2(layout.x_of(it.end()), row.rect.max.y)); + ui.painter().rect_stroke(r, 3.0, Stroke::new(2.0, app.tokens.accent), StrokeKind::Inside); + } if ctx.input(|i| i.pointer.any_released()) { - let is_transition = filmcraft_project::find_effect(&effect) - .is_some_and(|d| matches!(d.kind, filmcraft_project::EffectKind::VideoTransition | filmcraft_project::EffectKind::AudioTransition)); let r = if let Some(name) = effect.strip_prefix("preset:") { app.session.execute("presets.apply", json!({"preset": name, "clips": [clip.0]})) - } else if is_transition { - let edge = if p.x - layout.x_of(it.start) < layout.x_of(it.end()) - p.x { "in" } else { "out" }; - app.session.execute("effects.apply", json!({"effect": effect, "clip": clip.0, "edge": edge})) + } else if transition_kind.is_some() { + app.session.execute("effects.apply", params) } else { app.session.execute("effects.apply", json!({"effect": effect, "clips": [clip.0]})) }; diff --git a/crates/ui-egui/tests/transition_drop_ui.rs b/crates/ui-egui/tests/transition_drop_ui.rs new file mode 100644 index 00000000..e2613347 --- /dev/null +++ b/crates/ui-egui/tests/transition_drop_ui.rs @@ -0,0 +1,141 @@ +//! Transition drag feedback: actual span and edge, no edit until release, one undo step. +use egui::{Event, Modifiers, PointerButton, Pos2, Rect, pos2, vec2}; +use egui_kittest::Harness; +use filmcraft_engine::{Session, commands::preview_transition}; +use filmcraft_project::TrackKind; +use filmcraft_ui_egui::FilmcraftApp; +use serde_json::json; + +fn step(h: &mut Harness<'static, FilmcraftApp>) { + let ctx = h.ctx.clone(); + let mut raw = std::mem::take(h.input_mut()); + eframe::App::raw_input_hook(h.state_mut(), &ctx, &mut raw); + *h.input_mut() = raw; + ctx.request_repaint(); + h.step(); +} + +fn pointer(h: &mut Harness<'static, FilmcraftApp>, pos: Pos2, pressed: Option) { + h.input_mut().events.push(Event::PointerMoved(pos)); + if pressed == Some(true) { + step(h); + } + if let Some(pressed) = pressed { + h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + } + step(h); +} + +fn rect(h: &Harness<'static, FilmcraftApp>, id: &str) -> Rect { + let [x, y, w, height] = h.state().auto.elements.iter().find(|e| e.id == id).unwrap_or_else(|| panic!("missing {id}")).rect; + Rect::from_min_size(pos2(x, y), vec2(w, height)) +} + +fn harness(effect: &str) -> Harness<'static, FilmcraftApp> { + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + s.execute("file.newSequence", json!({"name":"Transition drops","fps":24})).unwrap(); + let rate = s.sequence_rate(); + // A short clip touching a longer clip: either edge must be distinguishable on screen. + for (at, frames) in [(0, 48), (48, 6)] { + s.execute("timeline.place", json!({"item":5,"time":rate.tick_of(at).0,"duration":rate.tick_of(frames).0})).unwrap(); + } + let mut app = FilmcraftApp::new(s); + app.set_workspace("Effects"); + app.ui.effects_search = effect.to_string(); + let mut builder = Harness::builder().with_size(vec2(1600.0, 980.0)).with_max_steps(10_000); + if std::env::var_os("FILMCRAFT_UI_SNAPSHOT_DIR").is_some() { + builder = builder.wgpu(); + } + let mut h = builder.build_eframe(move |_cc| app); + for _ in 0..4 { + step(&mut h); + } + // Opening the new sequence restores its own view; set the test zoom after that initial fit. + h.state_mut().ui.timeline.pps = 60.0; + h.state_mut().ui.timeline.target_pps = 60.0; + h.state_mut().ui.timeline.fit_pending = false; + step(&mut h); + h +} + +#[test] +fn transition_drag_previews_both_edges_of_short_clips_and_commits_that_span() { + for (kind, effect, name) in [(TrackKind::Video, "cross_dissolve", "Cross Dissolve"), (TrackKind::Audio, "constant_power", "Constant Power")] { + let mut h = harness(name); + let clip = h.state().session.active_sequence().unwrap().tracks(kind)[0].items[1].id; + let clip_rect = rect(&h, &format!("timeline.clip.{}", clip.0)); + let from = rect(&h, &format!("effects.item.{effect}")).center(); + let before = h.state().session.project.to_json(); + pointer(&mut h, from, Some(true)); + pointer(&mut h, from + vec2(0.0, 10.0), None); + for (edge, fraction) in [("in", 0.2), ("out", 0.8)] { + let to = pos2(clip_rect.left() + clip_rect.width() * fraction, clip_rect.center().y); + pointer(&mut h, to, None); + let preview = h.state().auto.elements.iter().find(|e| e.id == "timeline.transitionDropPreview").expect("transition preview while dragging"); + assert_eq!(preview.label, edge); + assert_eq!(h.state().session.project.to_json(), before, "hover must not edit"); + let (_, planned) = preview_transition(&h.state().session, &json!({"effect":effect,"clip":clip.0,"edge":edge}), kind).unwrap(); + let rate = h.state().session.sequence_rate(); + let short = &h.state().session.active_sequence().unwrap().tracks(kind)[0].items[1]; + let px_per_tick = clip_rect.width() / short.duration.0 as f32; + let expected_left = clip_rect.left() + (planned.start - short.start).0 as f32 * px_per_tick; + let content = rect(&h, "timeline.tracks"); + let expected_right = (expected_left + planned.duration.0 as f32 * px_per_tick).min(content.right()); + let expected_left = expected_left.max(content.left()); + assert!((preview.rect[0] - expected_left).abs() < 1.0); + assert!( + (preview.rect[2] - (expected_right - expected_left)).abs() < 1.0, + "{kind:?} {edge}: preview {:?}, clip {clip_rect:?}, planned {planned:?}, pps {}", + preview.rect, + h.state().ui.timeline.pps + ); + assert_eq!( + planned.duration, + if kind == TrackKind::Video { + h.state().session.prefs.timeline.video_transition_duration(rate) + } else { + h.state().session.prefs.timeline.audio_transition_duration(rate) + } + ); + if let Some(dir) = std::env::var_os("FILMCRAFT_UI_SNAPSHOT_DIR") { + let dir = std::path::PathBuf::from(dir); + std::fs::create_dir_all(&dir).unwrap(); + h.render().unwrap().save(dir.join(format!("transition-{effect}-{edge}.png"))).unwrap(); + } + } + let to = pos2(clip_rect.left() + clip_rect.width() * 0.8, clip_rect.center().y); + let (_, planned) = preview_transition(&h.state().session, &json!({"effect":effect,"clip":clip.0,"edge":"out"}), kind).unwrap(); + pointer(&mut h, to, Some(false)); + let applied = h.state().session.active_sequence().unwrap().tracks(kind)[0].transitions.last().unwrap(); + assert_eq!((applied.start, applied.duration, applied.from, applied.to), (planned.start, planned.duration, planned.from, planned.to)); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(h.state().session.project.to_json(), before); + step(&mut h); + assert!(!h.state().auto.elements.iter().any(|e| e.id == "timeline.transitionDropPreview")); + } +} + +#[test] +fn incompatible_and_locked_tracks_do_not_offer_a_transition_preview() { + for locked in [false, true] { + let mut h = harness("Cross Dissolve"); + let kind = if locked { TrackKind::Video } else { TrackKind::Audio }; + let q = h.state().session.active_sequence().unwrap(); + let (track, clip) = (q.tracks(kind)[0].id, q.tracks(kind)[0].items[1].id); + if locked { + let s = &mut h.state_mut().session; + std::sync::Arc::make_mut(&mut s.project).sequence_mut(s.state.active_sequence.unwrap()).unwrap().track_mut(track).unwrap().locked = true; + step(&mut h); + } + let to = rect(&h, &format!("timeline.clip.{}", clip.0)).center(); + let from = rect(&h, "effects.item.cross_dissolve").center(); + let before = h.state().session.project.to_json(); + pointer(&mut h, from, Some(true)); + pointer(&mut h, from + vec2(0.0, 10.0), None); + pointer(&mut h, to, None); + assert!(!h.state().auto.elements.iter().any(|e| e.id == "timeline.transitionDropPreview")); + pointer(&mut h, to, Some(false)); + assert_eq!(h.state().session.project.to_json(), before); + } +} From a3ad7a8ce742e208a3199519e083a59c2ca68927 Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:00:34 +1000 Subject: [PATCH 29/64] TIME.FRAME_NAVIGATION: Preserve exact frame steps and bound time arithmetic Co-authored-by: Codex --- crates/engine/src/commands.rs | 7 +- crates/engine/src/tests.rs | 44 +++++ crates/gpu/src/tests.rs | 2 +- crates/platform/tests/setting.rs | 10 +- crates/time/src/lib.rs | 236 ++++++++++++++++++++++----- crates/ui-egui/src/menus.rs | 8 +- crates/ui-egui/src/panels/monitor.rs | 5 +- crates/ui-egui/tests/media_ui.rs | 172 +++++++++++++++++++ 8 files changed, 437 insertions(+), 47 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index cd02fab2..84422264 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1813,8 +1813,11 @@ fn build() -> Vec { cmd!("playhead.step", "Step Frames", [], None, r#"{"frames":i64}"#, has_seq, |s, p| { let n = p.get("frames").and_then(Value::as_i64).unwrap_or(1); let r = s.sequence_rate(); - let f = r.frame_at(s.playhead()) + n; - s.set_playhead(r.tick_of(f.max(0))); + let f = r.frame_at(s.playhead()).checked_add(n).ok_or_else(|| bad("playhead.step", "frame offset is too large"))?.max(0); + if f > r.frame_at(Tick::MAX) { + return Err(bad("playhead.step", "target frame exceeds the supported time range")); + } + s.set_playhead(r.tick_of(f)); Ok(json!({"frame": r.frame_at(s.playhead())})) }), cmd!("playhead.stepForward", "Step Forward One Frame", [], Some("Right"), "{}", has_seq, |s, _| s.execute("playhead.step", json!({"frames": 1}))), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 01d5b813..1648c614 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -35,6 +35,50 @@ fn demo_project_is_valid_and_renders() { assert!(img.px.chunks(4).any(|p| p[3] > 0.9)); } +/// Ordinary and configured multi-frame offsets remain valid. +#[test] +fn frame_steps_in_new_custom_rate_sequences_move_in_the_requested_direction() { + for fps in [31.0, 23.98, 24.999] { + let mut s = Session::default(); + s.execute("file.newSequence", json!({"fps": fps})).unwrap(); + let rate = s.sequence_rate(); + s.execute("playhead.set", json!({"frame": 10})).unwrap(); + assert_eq!(rate.frame_at(s.playhead()), 10); + for frame in 11..=42 { + s.execute("playhead.stepForward", json!({})).unwrap(); + assert_eq!(rate.frame_at(s.playhead()), frame); + } + for frame in (10..42).rev() { + s.execute("playhead.stepBack", json!({})).unwrap(); + assert_eq!(rate.frame_at(s.playhead()), frame); + } + s.execute("prefs.set", json!({"key": "playback.stepManyFrames", "value": 12})).unwrap(); + s.execute("playhead.stepForward5", json!({})).unwrap(); + assert_eq!(rate.frame_at(s.playhead()), 22); + s.execute("playhead.stepBack5", json!({})).unwrap(); + assert_eq!(rate.frame_at(s.playhead()), 10); + s.execute("playhead.step", json!({"frames": -20})).unwrap(); + s.execute("playhead.stepBack", json!({})).unwrap(); + assert_eq!(s.playhead(), Tick::ZERO); + } +} + +#[test] +fn overflowing_frame_steps_are_errors_and_leave_the_playhead_unchanged() { + let mut s = Session::default(); + s.execute("file.newSequence", json!({"fps": 31.0})).unwrap(); + for start in [10, 0] { + s.execute("playhead.set", json!({"frame": start})).unwrap(); + let before = s.playhead(); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| s.execute("playhead.step", json!({"frames": i64::MAX})))); + assert!(result.is_ok(), "an extreme frame offset must not panic"); + assert!(matches!(result.unwrap(), Err(EngineError::BadParams { .. }))); + assert_eq!(s.playhead(), before); + } + s.execute("playhead.step", json!({"frames": i64::MIN})).unwrap(); + assert_eq!(s.playhead(), Tick::ZERO); +} + #[test] fn add_edit_undo_redo() { let mut s = demo(); diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 44714db5..8953da0a 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -204,7 +204,7 @@ fn gpu_push_matches_cpu_with_alpha_and_surrounding_blends() { c.composite_prepared(&plan, Some(&prepare(&plan))); let (_, _, gpu) = c.read_output().expect("readback"); let mut diffs: Vec = - cpu.chunks_exact(4).zip(gpu.chunks_exact(4)).map(|(a, b)| (0..3).map(|k| a[k].abs_diff(b[k]) as u32).max().unwrap()).collect(); + cpu.as_chunks::<4>().0.iter().zip(gpu.as_chunks::<4>().0).map(|(a, b)| (0..3).map(|k| a[k].abs_diff(b[k]) as u32).max().unwrap()).collect(); diffs.sort_unstable(); let p99 = diffs[diffs.len() * 99 / 100]; let mean = diffs.iter().sum::() as f64 / diffs.len() as f64; diff --git a/crates/platform/tests/setting.rs b/crates/platform/tests/setting.rs index c177e55d..cae45b19 100644 --- a/crates/platform/tests/setting.rs +++ b/crates/platform/tests/setting.rs @@ -36,8 +36,14 @@ fn off_uses_the_software_decoder() { assert_eq!(d.name(), "FilmCraft HEVC"); return; } - let (backend, hw_name) = if cfg!(target_os = "macos") { ("VideoToolbox", "VideoToolbox HEVC") } else { ("Media Foundation", "Media Foundation HEVC") }; - assert_eq!(d.name(), hw_name); + let backend = if cfg!(target_os = "linux") { + assert!(d.name().starts_with("VA-API HEVC ("), "Linux Auto decoder: {}", d.name()); + "VA-API" + } else { + let (backend, hw_name) = if cfg!(target_os = "macos") { ("VideoToolbox", "VideoToolbox HEVC") } else { ("Media Foundation", "Media Foundation HEVC") }; + assert_eq!(d.name(), hw_name); + backend + }; assert_eq!(filmcraft_codecs::hw::hw_backend(), Some(backend), "perf.stats reports the backend"); assert!(filmcraft_platform::registered(), "register() put the factory in the registry"); let h1 = filmcraft_codecs::hw::hw_stats(); diff --git a/crates/time/src/lib.rs b/crates/time/src/lib.rs index 4da7e27a..23f328bf 100644 --- a/crates/time/src/lib.rs +++ b/crates/time/src/lib.rs @@ -20,6 +20,7 @@ use std::ops::{Add, AddAssign, Neg, Sub, SubAssign}; pub const TICKS_PER_SECOND: i64 = 254_016_000_000; /// A point or duration in time, in ticks. +/// Arithmetic saturates at the i64 storage limits; representable results remain exact. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(transparent)] pub struct Tick(pub i64); @@ -37,20 +38,26 @@ impl Tick { } /// Exact conversion from a count of `units` at `per_second` (e.g. samples at 48 000). pub fn from_units(units: i64, per_second: i64) -> Tick { - Tick((units as i128 * TICKS_PER_SECOND as i128).checked_div(per_second as i128).unwrap_or(0) as i64) + Tick(clamp_i64((units as i128 * TICKS_PER_SECOND as i128).checked_div(per_second as i128).unwrap_or(0))) } /// Floor conversion to a count of units at `per_second`. pub fn to_units_floor(self, per_second: i64) -> i64 { - (self.0 as i128 * per_second as i128).div_euclid(TICKS_PER_SECOND as i128) as i64 + clamp_i64((self.0 as i128 * per_second as i128).div_euclid(TICKS_PER_SECOND as i128)) } /// Conversion from a rational timestamp `pts * num / den` seconds (container timebases). pub fn from_rational(pts: i64, num: i64, den: i64) -> Tick { - let t = pts as i128 * num as i128 * TICKS_PER_SECOND as i128; - Tick(t.checked_div_euclid(den as i128).unwrap_or(0) as i64) + if den == 0 { + return Tick::ZERO; + } + let Some(t) = (pts as i128 * num as i128).checked_mul(TICKS_PER_SECOND as i128) else { + // A product beyond i128 divided by an i64 cannot fit back into i64. + return Tick(if (pts < 0) ^ (num < 0) ^ (den < 0) { i64::MIN } else { i64::MAX }); + }; + Tick(clamp_i64(t.div_euclid(den as i128))) } /// Inverse of [`Tick::from_rational`], floored to the timebase. pub fn to_rational_floor(self, num: i64, den: i64) -> i64 { - (self.0 as i128 * den as i128).checked_div_euclid(num as i128 * TICKS_PER_SECOND as i128).unwrap_or(0) as i64 + clamp_i64((self.0 as i128 * den as i128).checked_div_euclid(num as i128 * TICKS_PER_SECOND as i128).unwrap_or(0)) } /// Inverse of [`Tick::from_rational`], rounded to the nearest timebase unit (halves up). /// @@ -63,10 +70,10 @@ impl Tick { let (n, unit) = if unit < 0 { (-n, -unit) } else { (n, unit) }; let (Some(q), Some(r)) = (n.checked_div_euclid(unit), n.checked_rem_euclid(unit)) else { return 0 }; // 0 <= r < unit: round up from the halfway point - (if r * 2 >= unit { q + 1 } else { q }) as i64 + clamp_i64(if r * 2 >= unit { q + 1 } else { q }) } pub fn abs(self) -> Tick { - Tick(self.0.abs()) + Tick(self.0.saturating_abs()) } pub fn min(self, o: Tick) -> Tick { Tick(self.0.min(o.0)) @@ -80,36 +87,36 @@ impl Tick { } /// Scale by a rational factor (`num/den`), flooring. pub fn mul_ratio(self, num: i64, den: i64) -> Tick { - Tick((self.0 as i128 * num as i128).checked_div_euclid(den as i128).unwrap_or(0) as i64) + Tick(clamp_i64((self.0 as i128 * num as i128).checked_div_euclid(den as i128).unwrap_or(0))) } } impl Add for Tick { type Output = Tick; fn add(self, o: Tick) -> Tick { - Tick(self.0 + o.0) + Tick(self.0.saturating_add(o.0)) } } impl Sub for Tick { type Output = Tick; fn sub(self, o: Tick) -> Tick { - Tick(self.0 - o.0) + Tick(self.0.saturating_sub(o.0)) } } impl Neg for Tick { type Output = Tick; fn neg(self) -> Tick { - Tick(-self.0) + Tick(self.0.saturating_neg()) } } impl AddAssign for Tick { fn add_assign(&mut self, o: Tick) { - self.0 += o.0; + self.0 = self.0.saturating_add(o.0); } } impl SubAssign for Tick { fn sub_assign(&mut self, o: Tick) { - self.0 -= o.0; + self.0 = self.0.saturating_sub(o.0); } } @@ -188,8 +195,8 @@ impl FrameRate { ]; pub fn new(num: i64, den: i64) -> Self { - let g = gcd(num.abs(), den.abs()).max(1); - FrameRate { num: num / g, den: den / g } + let g = i128::from(gcd(num.unsigned_abs(), den.unsigned_abs()).max(1)); + FrameRate { num: (i128::from(num) / g) as i64, den: (i128::from(den) / g) as i64 } } /// Closest standard rate for a float (e.g. from a container's average rate). @@ -213,49 +220,52 @@ impl FrameRate { } /// Exact duration of one frame (rounded down only for exotic rates). + /// Values outside the integer tick range saturate at its limit. pub fn frame_duration(self) -> Tick { let r = self.sane(); - Tick(((TICKS_PER_SECOND as i128 * r.den as i128) / r.num as i128) as i64) + Tick(clamp_i64((TICKS_PER_SECOND as i128 * r.den as i128) / r.num as i128)) } - /// Index of the frame containing `t` (floor). + /// Index of the frame containing `t` (floor), saturated to the integer frame range. pub fn frame_at(self, t: Tick) -> i64 { - let r = self.sane(); - (t.0 as i128 * r.num as i128).div_euclid(TICKS_PER_SECOND as i128 * r.den as i128) as i64 + clamp_i64(frame_index(self.sane(), t)) } /// Start tick of frame `f` (ceiling division). /// /// Uses ceiling (not floor) so that `frame_at(tick_of(f)) == f` holds for every - /// frame index, even when the frame duration is not an integer number of ticks + /// representable frame boundary with at least one tick per frame, even when the frame duration is not an integer number of ticks /// (exotic rates like 37.516 fps where `TICKS_PER_SECOND * den / num` has a /// fractional remainder). Floor division truncates the remainder, causing each /// frame boundary to drift backward by one tick until `frame_at` returns `f - 1` /// instead of `f` — frame stepping then stalls or moves backward. - /// Ceiling is correct because the frame duration is always > 1 tick, so - /// `floor(ceil(f·D) / D) == f` for every integer `f`. + /// Values outside the integer tick range saturate at its limit. pub fn tick_of(self, f: i64) -> Tick { - let r = self.sane(); - let n = f as i128 * TICKS_PER_SECOND as i128 * r.den as i128; - Tick((-(-n).div_euclid(r.num as i128)) as i64) + Tick(clamp_i64(frame_boundary(self.sane(), i128::from(f)))) } /// Snap `t` down to a frame boundary. pub fn snap(self, t: Tick) -> Tick { - self.tick_of(self.frame_at(t)) + let r = self.sane(); + Tick(clamp_i64(frame_boundary(r, frame_index(r, t)))) } /// Snap `t` to the nearest frame boundary. pub fn snap_nearest(self, t: Tick) -> Tick { - let a = self.snap(t); - let b = self.tick_of(self.frame_at(t) + 1); - if (t - a) <= (b - t) { a } else { b } + let r = self.sane(); + // Keep the frame index and both boundaries wide until choosing the nearest. Saturating + // an unrepresentable upper boundary first would make it seem closer than it really is. + let f = frame_index(r, t); + let a = frame_boundary(r, f); + let b = frame_boundary(r, f.saturating_add(1)); + let t = i128::from(t.0); + Tick(clamp_i64(if t - a <= b - t { a } else { b })) } /// Timecode base (frames counted per timecode second): 30 for 29.97, 24 for 23.976. pub fn timecode_base(self) -> i64 { let r = self.sane(); - ((r.num + r.den - 1) / r.den).max(1) + (r.num / r.den).saturating_add(i64::from(r.num % r.den != 0)).max(1) } /// NTSC (x/1001) rates can use drop-frame timecode. @@ -286,7 +296,29 @@ impl fmt::Display for FrameRate { } } -fn gcd(mut a: i64, mut b: i64) -> i64 { +/// Narrow only after applying the storage bounds, never by a wrapping integer cast. +fn clamp_i64(value: i128) -> i64 { + value.clamp(i128::from(i64::MIN), i128::from(i64::MAX)) as i64 +} + +/// Callers pass a positive rate. Products of two i64 values fit in i128. +fn frame_index(rate: FrameRate, t: Tick) -> i128 { + (i128::from(t.0) * i128::from(rate.num)).div_euclid(i128::from(TICKS_PER_SECOND) * i128::from(rate.den)) +} + +/// Ceiling of the rational boundary. An overflowing numerator divided by an i64 rate cannot +/// fit in an i64 tick, so saturating that intermediate preserves the public result. Boundaries +/// derived from frame_index remain within i128 (at most one frame away from an i64 tick). +fn frame_boundary(rate: FrameRate, frame: i128) -> i128 { + let Some(n) = frame.checked_mul(i128::from(TICKS_PER_SECOND)).and_then(|n| n.checked_mul(i128::from(rate.den))) else { + return if frame < 0 { i128::MIN } else { i128::MAX }; + }; + let d = i128::from(rate.num); + // Flooring a fractional boundary puts the tick in the preceding frame. + n.div_euclid(d) + i128::from(n.rem_euclid(d) != 0) +} + +fn gcd(mut a: u64, mut b: u64) -> u64 { while b != 0 { (a, b) = (b, a % b); } @@ -329,8 +361,9 @@ fn df_params(rate: FrameRate) -> (i64, i64) { /// Convert a frame count to SMPTE fields `(negative, h, m, s, f)`. pub fn frames_to_fields(frame: i64, rate: FrameRate, drop_frame: bool) -> (bool, i64, i64, i64, i64) { let neg = frame < 0; - let mut n = frame.saturating_abs(); + let mut n = i128::from(frame).abs(); let (drop, base) = df_params(rate); + let (drop, base) = (i128::from(drop), i128::from(base)); if drop_frame && rate.supports_drop_frame() { let per_min = base * 60 - drop; let per_10 = per_min * 10 + drop; @@ -345,18 +378,20 @@ pub fn frames_to_fields(frame: i64, rate: FrameRate, drop_frame: bool) -> (bool, let s = (n / base) % 60; let mi = (n / (base * 60)) % 60; let h = n / (base * 3600); - (neg, h, mi, s, f) + (neg, clamp_i64(h), clamp_i64(mi), clamp_i64(s), clamp_i64(f)) } /// Convert SMPTE fields back to a frame count. pub fn fields_to_frames(h: i64, m: i64, s: i64, f: i64, rate: FrameRate, drop_frame: bool) -> i64 { let (drop, base) = df_params(rate); - let mut n = h.saturating_mul(3600).saturating_add(m.saturating_mul(60)).saturating_add(s).saturating_mul(base).saturating_add(f); + let seconds = i128::from(h) * 3600 + i128::from(m) * 60 + i128::from(s); + // Nominal drop-frame labels can exceed i64 even when the final frame count fits. + let mut n = seconds.saturating_mul(i128::from(base)).saturating_add(i128::from(f)); if drop_frame && rate.supports_drop_frame() { - let total_min = h.saturating_mul(60).saturating_add(m); - n = n.saturating_sub(drop.saturating_mul(total_min - total_min / 10)); + let total_min = i128::from(h) * 60 + i128::from(m); + n = n.saturating_sub(i128::from(drop) * (total_min - total_min / 10)); } - n + clamp_i64(n) } /// Format a frame count as SMPTE timecode (`HH:MM:SS:FF`, or `HH;MM;SS;FF` for drop-frame). @@ -377,7 +412,7 @@ pub fn format_time(t: Tick, rate: FrameRate, drop_frame: bool, display: TimeDisp TimeDisplay::Feet35 | TimeDisplay::Feet16 => { let per_ft = if display == TimeDisplay::Feet35 { 16 } else { 40 }; let neg = frame < 0; - let a = frame.abs(); + let a = frame.unsigned_abs(); format!("{}{}+{:02}", if neg { "-" } else { "" }, a / per_ft, a % per_ft) } TimeDisplay::AudioSamples => { @@ -511,6 +546,119 @@ mod tests { } } + #[test] + fn boundary_rates_saturate_without_wrapping_and_keep_representable_values_exact() { + let slow = FrameRate { num: 1, den: i64::MAX }; + assert_eq!(slow.frame_duration(), Tick(i64::MAX)); + assert_eq!(slow.tick_of(i64::MAX), Tick(i64::MAX)); + assert_eq!(slow.tick_of(i64::MIN), Tick(i64::MIN)); + assert_eq!(slow.snap_nearest(Tick(i64::MAX)), Tick::ZERO, "compare the real boundary before saturating"); + assert_eq!(slow.snap_nearest(Tick(i64::MIN)), Tick::ZERO); + let fast = FrameRate { num: i64::MAX, den: 1 }; + assert_eq!(fast.tick_of(i64::MAX), Tick(TICKS_PER_SECOND), "large factors can cancel exactly"); + assert_eq!(fast.tick_of(-i64::MAX), Tick(-TICKS_PER_SECOND)); + assert_eq!(fast.frame_at(Tick(TICKS_PER_SECOND)), i64::MAX); + assert_eq!(fast.frame_at(Tick(i64::MAX)), i64::MAX); + assert_eq!(fast.frame_at(Tick(i64::MIN)), i64::MIN); + assert_eq!(fast.timecode_base(), i64::MAX); + for t in [i64::MIN, -TICKS_PER_SECOND, -1, 0, 1, TICKS_PER_SECOND, i64::MAX] { + assert_eq!(fast.snap(Tick(t)), Tick(t), "sub-tick frames include every integer tick"); + assert_eq!(fast.snap_nearest(Tick(t)), Tick(t)); + } + let balanced = FrameRate { num: i64::MAX, den: i64::MAX }; + assert_eq!(balanced.frame_duration(), Tick(TICKS_PER_SECOND)); + assert_eq!(balanced.tick_of(2), Tick(2 * TICKS_PER_SECOND)); + assert_eq!(balanced.frame_at(Tick(2 * TICKS_PER_SECOND)), 2); + assert_eq!(balanced.timecode_base(), 1); + assert_eq!(FrameRate::new(i64::MIN, i64::MIN), FrameRate { num: -1, den: -1 }); + assert_eq!(FrameRate::new(i64::MIN, 0), FrameRate { num: -1, den: 0 }); + assert_eq!(FrameRate::new(i64::MIN, 1), FrameRate { num: i64::MIN, den: 1 }); + } + + #[test] + fn scalar_time_conversions_saturate_at_storage_limits() { + assert_eq!(Tick::from_units(i64::MAX, 1), Tick(i64::MAX)); + assert_eq!(Tick::from_units(i64::MIN, 1), Tick(i64::MIN)); + assert_eq!(Tick(i64::MAX).to_units_floor(i64::MAX), i64::MAX); + assert_eq!(Tick(i64::MIN).to_units_floor(i64::MAX), i64::MIN); + assert_eq!(Tick::from_rational(i64::MAX, i64::MAX, 1), Tick(i64::MAX)); + assert_eq!(Tick::from_rational(i64::MIN, i64::MAX, 1), Tick(i64::MIN)); + assert_eq!(Tick::from_rational(i64::MIN, i64::MAX, -1), Tick(i64::MAX)); + assert_eq!(Tick::from_rational(i64::MAX, i64::MAX, 0), Tick::ZERO); + assert_eq!(Tick(i64::MAX).to_rational_floor(1, i64::MAX), i64::MAX); + assert_eq!(Tick(i64::MIN).to_rational_round(1, i64::MAX), i64::MIN); + assert_eq!(Tick(i64::MAX).mul_ratio(i64::MAX, 1), Tick(i64::MAX)); + assert_eq!(Tick(i64::MIN).abs(), Tick(i64::MAX)); + } + + #[test] + fn drop_frame_fields_keep_cancelling_terms_wide_at_storage_limits() { + for rate in [FrameRate::FPS_29_97, FrameRate::FPS_59_94, FrameRate::FPS_119_88] { + for frame in [0, 1, 999_999, i64::MAX - 1, i64::MAX] { + let (negative, h, m, s, f) = frames_to_fields(frame, rate, true); + assert!(!negative); + assert_eq!(fields_to_frames(h, m, s, f, rate, true), frame, "{rate}: {frame}"); + assert_eq!(fields_to_frames(-h, -m, -s, -f, rate, true), -frame); + } + let (negative, h, m, s, f) = frames_to_fields(i64::MIN, rate, true); + assert!(negative); + assert_eq!(fields_to_frames(-h, -m, -s, -f, rate, true), i64::MIN); + } + } + + #[test] + fn tick_arithmetic_saturates_only_outside_storage_limits() { + let values = [i64::MIN, i64::MIN + 1, Tick::MIN.0, -1, 0, 1, Tick::MAX.0, i64::MAX - 1, i64::MAX]; + for a in values { + assert_eq!(-Tick(a), Tick(clamp_i64(-i128::from(a)))); + for b in values { + let sum = Tick(clamp_i64(i128::from(a) + i128::from(b))); + let difference = Tick(clamp_i64(i128::from(a) - i128::from(b))); + assert_eq!(Tick(a) + Tick(b), sum); + assert_eq!(Tick(a) - Tick(b), difference); + let mut assigned = Tick(a); + assigned += Tick(b); + assert_eq!(assigned, sum); + assigned = Tick(a); + assigned -= Tick(b); + assert_eq!(assigned, difference); + } + } + assert_eq!(Tick::MAX + Tick(1), Tick(Tick::MAX.0 + 1), "project bounds are not storage bounds"); + assert_eq!(Tick::MIN - Tick(1), Tick(Tick::MIN.0 - 1)); + assert_eq!(TimeRange::new(Tick(i64::MAX), Tick(1)).end(), Tick(i64::MAX)); + assert_eq!(TimeRange::new(Tick(i64::MIN), Tick(-1)).end(), Tick(i64::MIN)); + assert_eq!(TimeRange::from_bounds(Tick(i64::MIN), Tick(i64::MAX)).duration, Tick(i64::MAX)); + assert_eq!(TimeRange::from_bounds(Tick(i64::MAX), Tick(i64::MIN)).duration, Tick(i64::MIN)); + let ordinary = TimeRange::from_bounds(Tick::MIN, Tick::MAX); + assert_eq!(ordinary.end(), Tick::MAX); + } + + #[test] + fn numeric_limits_are_total_across_frame_conversion_and_display() { + let values = [i64::MIN, -i64::MAX, -1, 0, 1, TICKS_PER_SECOND, i64::MAX]; + for num in values { + for den in values { + let r = FrameRate { num, den }; + let _ = FrameRate::new(num, den); + let _ = r.frame_duration(); + assert!(r.timecode_base() >= 1); + for value in values { + let tick = Tick(value); + let _ = r.tick_of(value); + let _ = r.frame_at(tick); + let _ = r.snap(tick); + let _ = r.snap_nearest(tick); + for display in TimeDisplay::ALL { + let _ = format_time(tick, r, true, display, 48_000); + } + let (_, h, m, sec, f) = frames_to_fields(value, r, true); + let _ = fields_to_frames(h, m, sec, f, r, true); + } + } + } + } + #[test] fn every_common_rate_is_exact() { for r in FrameRate::COMMON { @@ -660,6 +808,18 @@ mod tests { } proptest! { + #[test] + fn extreme_frame_math_stays_monotonic(num in 1i64..=i64::MAX, den in 1i64..=i64::MAX, a in any::(), b in any::()) { + let r = FrameRate { num, den }; + let (lo, hi) = (a.min(b), a.max(b)); + prop_assert!(r.tick_of(lo) <= r.tick_of(hi)); + prop_assert!(r.frame_at(Tick(lo)) <= r.frame_at(Tick(hi))); + prop_assert!(r.snap(Tick(lo)) <= r.snap(Tick(hi))); + prop_assert!(r.snap_nearest(Tick(lo)) <= r.snap_nearest(Tick(hi))); + let snapped = r.snap_nearest(Tick(a)); + prop_assert_eq!(r.snap_nearest(snapped), snapped); + } + #[test] fn frame_tick_roundtrip(f in -1_000_000i64..10_000_000, ri in 0usize..11) { let r = FrameRate::COMMON[ri]; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 1bfecf3d..3463dfa0 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -310,8 +310,12 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params: let view = filmcraft_engine::clip_ops::source_view(&app.session, item).ok_or("Source clip is unavailable")?; let count = if id.ends_with('5') { i64::from(app.session.prefs.playback.step_many_frames) } else { 1 }; let direction = if id.contains("Back") { -1 } else { 1 }; - let delta = view.rate.frame_duration().0.saturating_mul(count).saturating_mul(direction); - let time = filmcraft_time::Tick(app.session.state.source_playhead.0.saturating_add(delta)); + let time = if view.rate.frame_duration().0 == 0 { + app.session.state.source_playhead + } else { + let frame = view.rate.frame_at(app.session.state.source_playhead).saturating_add(count.saturating_mul(direction)); + view.rate.tick_of(frame) + }; return app.session.execute("source.setPlayhead", json!({"time": time.0})).map_err(|e| e.to_string()); } "playback.forward" => { diff --git a/crates/ui-egui/src/panels/monitor.rs b/crates/ui-egui/src/panels/monitor.rs index 39034bfa..c5c5a278 100644 --- a/crates/ui-egui/src/panels/monitor.rs +++ b/crates/ui-egui/src/panels/monitor.rs @@ -674,8 +674,9 @@ fn source_nav(app: &mut FilmcraftApp, cmd: &str) { let t = match cmd { "src.goIn" => v.mark_in.unwrap_or(v.start), "src.goOut" => v.mark_out.unwrap_or(v.end - rate.frame_duration()), - "src.stepBack" => cur - rate.frame_duration(), - _ => cur + rate.frame_duration(), + _ if rate.frame_duration() == Tick::ZERO => cur, + "src.stepBack" => rate.tick_of(rate.frame_at(cur).saturating_sub(1)), + _ => rate.tick_of(rate.frame_at(cur).saturating_add(1)), }; let _ = app.session.execute("source.setPlayhead", json!({"time": t.0})); } diff --git a/crates/ui-egui/tests/media_ui.rs b/crates/ui-egui/tests/media_ui.rs index d8bda6c8..80246ef0 100644 --- a/crates/ui-egui/tests/media_ui.rs +++ b/crates/ui-egui/tests/media_ui.rs @@ -179,6 +179,178 @@ impl Driver { } } +/// #301: Source transport must step by frame index when frame durations need rounding. +#[test] +fn source_transport_steps_every_frame_at_a_fractional_tick_rate() { + let rate = FrameRate::new(31, 1); + let generator = Generator::Demo(DemoScene::OceanSunset); + let src = GeneratorSource::new(generator.clone(), 32, 18, rate, rate.tick_of(41)); + let mut session = Session::default(); + let item = Arc::make_mut(&mut session.project).add_item( + "31 fps source", + Label::Iris, + ItemKind::Media(MediaClip { + media: MediaRef::Generator(generator), + info: src.info().clone(), + interpret: Default::default(), + mark_in: None, + mark_out: None, + markers: vec![], + offline: false, + proxy: None, + identity: None, + }), + None, + ); + session.execute("source.open", json!({"item": item.0})).unwrap(); + let mut d = Driver::new(session); + for frame in 1..=40 { + d.click("source.transport.src.stepFwd"); + assert_eq!(rate.frame_at(d.app().session.state.source_playhead), frame); + } + d.click("source.transport.src.stepFwd"); + assert_eq!(rate.frame_at(d.app().session.state.source_playhead), 40, "stop at the last source frame"); + for frame in (0..40).rev() { + d.click("source.transport.src.stepBack"); + assert_eq!(rate.frame_at(d.app().session.state.source_playhead), frame); + } + d.click("source.transport.src.stepBack"); + assert_eq!(d.app().session.state.source_playhead, Tick::ZERO); +} + +/// Source-focused menu/keyboard commands use the same frame grid as the Source toolbar. +#[test] +fn source_menu_steps_use_ordinals_for_explicit_targets_and_focus() { + let rate = FrameRate::new(31, 1); + for explicit_source in [true, false] { + let generator = Generator::Demo(DemoScene::OceanSunset); + let src = GeneratorSource::new(generator.clone(), 32, 18, rate, rate.tick_of(41)); + let mut session = Session::default(); + session.execute("file.newSequence", json!({"fps": 31, "width": 32, "height": 18})).unwrap(); + session.execute("playhead.set", json!({"frame": 10})).unwrap(); + session.execute("prefs.set", json!({"key": "playback.stepManyFrames", "value": 12})).unwrap(); + let item = Arc::make_mut(&mut session.project).add_item( + "31 fps menu source", + Label::Iris, + ItemKind::Media(MediaClip { + media: MediaRef::Generator(generator), + info: src.info().clone(), + interpret: Default::default(), + mark_in: None, + mark_out: None, + markers: vec![], + offline: false, + proxy: None, + identity: None, + }), + None, + ); + session.execute("source.open", json!({"item": item.0})).unwrap(); + let mut d = Driver::new(session); + d.ok("ui.set", json!({"focused": if explicit_source { "Program" } else { "Source" }})); + let project = d.app().session.project.clone(); + let history = d.exec("history.list", json!({})); + let mut source_metadata = d.exec("source.inspect", json!({})); + source_metadata.as_object_mut().unwrap().remove("playhead"); + let source_params = if explicit_source { json!({"monitor": "source"}) } else { json!({}) }; + for (command, frame) in [ + ("playhead.stepForward", 1), + ("playhead.stepForward", 2), + ("playhead.stepBack", 1), + ("playhead.stepBack", 0), + ("playhead.stepBack", 0), + ("playhead.stepForward5", 12), + ("playhead.stepForward5", 24), + ("playhead.stepForward5", 36), + ("playhead.stepForward5", 40), + ("playhead.stepForward5", 40), + ("playhead.stepBack5", 28), + ("playhead.stepBack5", 16), + ("playhead.stepBack5", 4), + ("playhead.stepBack5", 0), + ("playhead.stepBack5", 0), + ] { + d.ok("ui.menu.invoke", json!({"id": command, "params": source_params.clone()})); + let mut source = d.exec("source.inspect", json!({})); + assert_eq!(source["playhead"], json!(rate.tick_of(frame).0), "{command}, explicit Source target: {explicit_source}"); + source.as_object_mut().unwrap().remove("playhead"); + assert_eq!(source, source_metadata); + assert_eq!(d.app().session.playhead(), rate.tick_of(10)); + assert!(Arc::ptr_eq(&d.app().session.project, &project), "Source navigation must preserve the document"); + assert_eq!(d.exec("history.list", json!({})), history); + let state = d.ok("ui.inspect", json!({})); + assert_eq!(state["playback"]["playing"], json!(false)); + assert_eq!(state["sourcePlayback"]["playing"], json!(false)); + } + // An explicit Program target takes priority even while Source has focus. + for (command, frame) in [("playhead.stepForward", 11), ("playhead.stepBack", 10)] { + d.ok("ui.menu.invoke", json!({"id": command, "params": {"monitor": "program"}})); + assert_eq!(d.app().session.playhead(), rate.tick_of(frame)); + assert_eq!(d.app().session.state.source_playhead, Tick::ZERO); + assert!(Arc::ptr_eq(&d.app().session.project, &project)); + assert_eq!(d.exec("history.list", json!({})), history); + } + } +} + +// Requires the wide Time arithmetic: the accepted Source rate has more than one frame per tick. +fn zero_tick_frame_duration_source() -> (Driver, Tick) { + let rate = FrameRate::new(i64::MAX, 1000); + let current = Tick(254_270_016_000_000); // 1001 seconds, beyond the i64 frame-index range. + assert_eq!(rate.frame_duration(), Tick::ZERO); + assert_eq!(rate.frame_at(current), i64::MAX); + assert_eq!(rate.tick_of(i64::MAX), Tick(254_016_000_000_000)); + assert_eq!(rate.snap(current), current, "the composed Time helper must retain the exact cursor"); + let generator = Generator::ColorMatte { color: [0.2, 0.8, 0.2, 1.0] }; + let src = GeneratorSource::new(generator.clone(), 32, 18, rate, Tick(508_032_000_000_000)); // 2000 seconds. + let mut session = Session::default(); + session.execute("file.newSequence", json!({"fps": 31, "width": 32, "height": 18})).unwrap(); + let item = Arc::make_mut(&mut session.project).add_item( + "Source with less than one tick per frame", + Label::Iris, + ItemKind::Media(MediaClip { + media: MediaRef::Generator(generator), + info: src.info().clone(), + interpret: Default::default(), + mark_in: None, + mark_out: None, + markers: vec![], + offline: false, + proxy: None, + identity: None, + }), + None, + ); + assert_eq!(filmcraft_engine::clip_ops::source_view(&session, item).unwrap().rate, rate); + session.execute("source.open", json!({"item": item.0})).unwrap(); + let mut d = Driver::new(session); + d.exec("source.setPlayhead", json!({"time": current.0})); + assert_eq!(d.app().session.state.source_playhead, current, "the public Source seek must accept the exact starting cursor"); + (d, current) +} + +#[test] +fn source_toolbar_steps_preserve_zero_tick_frame_duration_cursor() { + let (mut d, current) = zero_tick_frame_duration_source(); + for button in ["source.transport.src.stepFwd", "source.transport.src.stepBack"] { + d.click(button); + assert_eq!(d.app().session.state.source_playhead, current, "{button} must retain the existing zero-duration no-op"); + } +} + +#[test] +fn source_menu_steps_preserve_zero_tick_frame_duration_cursor() { + for explicit_source in [true, false] { + let (mut d, current) = zero_tick_frame_duration_source(); + d.ok("ui.set", json!({"focused": if explicit_source { "Program" } else { "Source" }})); + let params = if explicit_source { json!({"monitor": "source"}) } else { json!({}) }; + for command in ["playhead.stepForward", "playhead.stepBack"] { + d.ok("ui.menu.invoke", json!({"id": command, "params": params.clone()})); + assert_eq!(d.app().session.state.source_playhead, current, "{command}, explicit Source target: {explicit_source}"); + } + } +} + #[test] fn link_media_dialog_relinks_moved_media() { let root = tmp_dir("link"); From 5e87784af88e140c3e6dd25d0dbb46c028a227b3 Mon Sep 17 00:00:00 2001 From: digitaldecaycreations-lgtm <294164144+digitaldecaycreations-lgtm@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:20:58 -0700 Subject: [PATCH 30/64] interchange: strip Windows verbatim prefix in file URLs `path_to_file_url` treated `\\?\C:\x` (the form `canonicalize` returns on Windows) as a UNC share named `?`, so FCP7 XML exports on Windows wrote `file://%3F/C:/...` and Premiere opened the media offline. Drop the `\\?\` prefix (and map `\\?\UNC\server\share` to `//server/share`) before building the URL. Verified: the exported XML now imports into Premiere Pro 2026 on Windows with all media online. Co-Authored-By: Claude Opus 5.5 --- crates/interchange/src/common.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/interchange/src/common.rs b/crates/interchange/src/common.rs index 5527e06e..45dfa500 100644 --- a/crates/interchange/src/common.rs +++ b/crates/interchange/src/common.rs @@ -81,6 +81,15 @@ pub fn file_url_to_path(url: &str) -> String { /// Convert an absolute path to a `file://` URL (`localhost` form for FCP7 XML). pub fn path_to_file_url(path: &str, localhost: bool) -> String { let p = path.replace('\\', "/"); + // Windows verbatim paths (`\\?\C:\x`, `\\?\UNC\server\share`, as `canonicalize` returns them) + // are not network shares: drop the prefix so `?` doesn't become the URL's host. + let p = if let Some(unc) = p.strip_prefix("//?/UNC/") { + format!("//{unc}") + } else if let Some(local) = p.strip_prefix("//?/") { + local.to_string() + } else { + p + }; let host = if localhost { "localhost" } else { "" }; if is_windows_abs(&p) { format!("file://{host}/{}", percent_encode_path(&p)) @@ -675,6 +684,8 @@ mod tests { assert_eq!(file_url_to_path("/plain/path.mov"), "/plain/path.mov"); assert_eq!(path_to_file_url("/Users/me/My Clip.mov", true), "file://localhost/Users/me/My%20Clip.mov"); assert_eq!(path_to_file_url("C:\\Media\\a b.mxf", false), "file:///C:/Media/a%20b.mxf"); + assert_eq!(path_to_file_url("\\\\?\\C:\\Media\\a.mp4", true), "file://localhost/C:/Media/a.mp4"); + assert_eq!(path_to_file_url("\\\\?\\UNC\\srv\\share\\a.mp4", false), "file://srv/share/a.mp4"); for p in ["/a/b c/ü.mov", "C:/x/y.mp4", "/r/%/#?.mov"] { assert_eq!(file_url_to_path(&path_to_file_url(p, true)), p); assert_eq!(file_url_to_path(&path_to_file_url(p, false)), p); From 1286f135f4f1a1827491804e039bea2557ee0dea Mon Sep 17 00:00:00 2001 From: Sawizzle <156952830+sawizzle@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:06:15 -0700 Subject: [PATCH 31/64] docs: register the MCP server from an installed release Co-Authored-By: Claude Opus 5.5 --- docs/agents.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/agents.md b/docs/agents.md index 9ad0b2bc..2dbad304 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -33,6 +33,26 @@ claude mcp add filmcraft-headless -- /abs/path/filmcraft/target/release/filmcraf { "mcpServers": { "filmcraft": { "command": "/abs/path/filmcraft-cli", "args": ["mcp", "--bridge", "127.0.0.1:9876"] } } } ``` +### From an installed release + +The release packages ship `filmcraft-cli` alongside the desktop app, so no build is needed: + +| Install | CLI | +|---|---| +| Windows (MSI) | `C:\Program Files\FilmCraft\filmcraft-cli.exe`, not added to `PATH` | +| Linux (deb, rpm) | `/usr/bin/filmcraft-cli` | +| macOS | the separate `filmcraft-cli--macos-universal.zip` release asset (the `.app` holds only the desktop app) | + +```sh +# Windows +claude mcp add filmcraft -- "C:\Program Files\FilmCraft\filmcraft-cli.exe" mcp +# Linux, or macOS with the CLI unzipped onto PATH +claude mcp add filmcraft -- filmcraft-cli mcp +``` + +An unofficial community plugin, [artcraft-claude-plugin](https://github.com/sawizzle/artcraft-claude-plugin), +registers the installed ArtCraft apps in Claude Code in one step and adds usage notes for agents. + ### Tools | Tool | Modes | Purpose | From 75559a8f416d901fc6b160324df2fe63c7d867aa Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 9 Oct 2026 20:04:17 -0400 Subject: [PATCH 32/64] chore: bump github actions to fix Node 20 deprecation warnings --- .github/workflows/freebsd.yml | 4 +- .github/workflows/packaging-lint.yml | 2 +- .github/workflows/release.yml | 63 +++++++++++++--------------- .github/workflows/windows-arm64.yml | 10 ++--- 4 files changed, 37 insertions(+), 42 deletions(-) diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index a7446cac..a3fb4af4 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -31,10 +31,10 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 240 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Build and package in a FreeBSD VM # vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build). - uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc + uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9 with: release: "14.3" usesh: true diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index bf7c41d3..ff59f52e 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -23,7 +23,7 @@ jobs: env: ACTIONLINT_VERSION: 1.7.12 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install tools run: | sudo apt-get update diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a65db7bf..08eba22b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -43,7 +43,7 @@ jobs: date: ${{ steps.v.outputs.date }} prerelease: ${{ steps.v.outputs.prerelease }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - id: v name: Resolve version run: | @@ -71,17 +71,16 @@ jobs: FILMCRAFT_VERSION: ${{ needs.version.outputs.version }} FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} steps: - - uses: actions/checkout@v4 - # Fonts (optional build input, required for releases): docs/contributing.md#building-with-craft-fonts - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - 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@stable + - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable with: targets: aarch64-apple-darwin, x86_64-apple-darwin - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: key: release-macos-universal - name: Apply version override @@ -101,7 +100,7 @@ jobs: CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts CRAFT_FONTS_REQUIRED: "1" run: packaging/macos/package.sh --arch universal - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-macos path: dist/release/* @@ -129,17 +128,16 @@ jobs: FILMCRAFT_VERSION: ${{ needs.version.outputs.version }} FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} steps: - - uses: actions/checkout@v4 - # Fonts (optional build input, required for releases): docs/contributing.md#building-with-craft-fonts - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - 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@stable + - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable with: targets: ${{ matrix.target }} - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: key: release-windows-${{ matrix.arch }} - name: Apply version override @@ -163,7 +161,7 @@ jobs: CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts CRAFT_FONTS_REQUIRED: "1" run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }} - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-windows-${{ matrix.arch }} path: dist/release/* @@ -187,9 +185,8 @@ jobs: FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} NFPM_VERSION: 2.47.0 steps: - - uses: actions/checkout@v4 - # Fonts (optional build input, required for releases): docs/contributing.md#building-with-craft-fonts - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: storytold/craft-fonts ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately @@ -203,8 +200,8 @@ jobs: curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \ "https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb" sudo dpkg -i "$RUNNER_TEMP/nfpm.deb" - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: key: release-linux-${{ matrix.arch }} - name: Apply version override @@ -228,7 +225,7 @@ jobs: "$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|' test -s "$appimage.zsync" done - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-linux-${{ matrix.arch }} path: dist/release/* @@ -251,8 +248,8 @@ jobs: FILMCRAFT_VERSION: ${{ needs.version.outputs.version }} FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} steps: - - uses: actions/checkout@v4 - - uses: actions/download-artifact@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: release-linux-${{ matrix.arch }} path: dist/linux @@ -266,7 +263,7 @@ jobs: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - name: Build the Flatpak bundle run: packaging/linux/flatpak-bundle.sh "dist/linux/filmcraft-$FILMCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz" - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-flatpak-${{ matrix.arch }} path: dist/release/*.flatpak @@ -284,16 +281,15 @@ jobs: FILMCRAFT_VERSION: ${{ needs.version.outputs.version }} FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} steps: - - uses: actions/checkout@v4 - # Fonts (optional build input, required for releases): docs/contributing.md#building-with-craft-fonts - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - 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 - name: Build and package in a FreeBSD VM (tar.gz) # vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build). - uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc + uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9 with: release: "14.3" usesh: true @@ -318,7 +314,7 @@ jobs: bash packaging/freebsd/package.sh tar -tzf dist/release/filmcraft-*-freebsd-x86_64.tar.gz \ | grep -E '/bin/filmcraft$|/bin/filmcraft-cli$|/share/applications/ai\.storyteller\.filmcraft\.desktop$|/share/doc/filmcraft/LICENSE-MIT$' - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-freebsd-x86_64 path: dist/release/* @@ -334,17 +330,16 @@ jobs: FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} WASM_BINDGEN_VERSION: 0.2.129 steps: - - uses: actions/checkout@v4 - # Fonts (optional build input, required for releases): docs/contributing.md#building-with-craft-fonts - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - 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@stable + - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable with: targets: wasm32-unknown-unknown - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: key: release-web - name: Install wasm-bindgen CLI @@ -362,7 +357,7 @@ jobs: CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts CRAFT_FONTS_REQUIRED: "1" run: packaging/web/package.sh - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-web path: dist/release/* @@ -381,7 +376,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: pattern: release-* path: artifacts diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index 22a3a54b..ccb475e5 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -36,11 +36,11 @@ jobs: runs-on: windows-latest timeout-minutes: 90 steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable with: targets: aarch64-pc-windows-msvc - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: key: windows-arm64-package - name: Install WiX @@ -51,7 +51,7 @@ jobs: env: DIST: ${{ github.workspace }}\dist\arm64 run: ./packaging/windows/package.ps1 -Arch arm64 - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: filmcraft-windows-arm64 path: dist/arm64/* @@ -64,7 +64,7 @@ jobs: runs-on: windows-11-arm timeout-minutes: 30 steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: filmcraft-windows-arm64 path: dist From d55ebe94e5c608bbe29f3f26b8e45375d296984e Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 10 Oct 2026 12:05:03 +1100 Subject: [PATCH 33/64] Fill graphic shapes and text with a linear gradient. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Appearance › Fill Type paints a ramp across the layer bounds, and the ramp turns with the layer. A per-character fill colour still paints those characters as a solid. --- crates/engine/src/graphics.rs | 5 ++ crates/engine/src/graphics_tests.rs | 18 +++++ crates/project/src/graphic.rs | 37 +++++++++- crates/render/src/graphic_clip.rs | 100 +++++++++++++++++++++++++- crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/panels/graphics.rs | 16 ++++- docs/graphics.md | 12 +++- 7 files changed, 183 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/graphics.rs b/crates/engine/src/graphics.rs index fea4922a..4b102904 100644 --- a/crates/engine/src/graphics.rs +++ b/crates/engine/src/graphics.rs @@ -190,6 +190,10 @@ fn param_id(k: &str) -> &str { "fontSize" => "size", "fontStyle" | "style" => "font_style", "fillColor" | "color" => "fill_color", + "fillKind" => "fill_kind", + "gradientStart" => "gradient_start", + "gradientEnd" => "gradient_end", + "gradientAngle" => "gradient_angle", "strokeColor" => "stroke_color", "strokeWidth" => "stroke_width", "backgroundColor" => "background_color", @@ -212,6 +216,7 @@ pub(crate) fn to_param(template: &ParamValue, id: &str, v: &Value) -> Option graphic::ALIGN_OPTS, "caps" => graphic::CAPS_OPTS, "stroke_type" | "stroke2_type" => graphic::STROKE_OPTS, + "fill_kind" => graphic::FILL_KIND_OPTS, "shape" => SHAPE_OPTS, _ => &[], }; diff --git a/crates/engine/src/graphics_tests.rs b/crates/engine/src/graphics_tests.rs index 0c7cc4cb..e8d33d94 100644 --- a/crates/engine/src/graphics_tests.rs +++ b/crates/engine/src/graphics_tests.rs @@ -149,6 +149,24 @@ fn align_and_distribute_layers() { let _ = w; } +#[test] +fn linear_gradient_fill_sets_and_evaluates() { + let mut s = demo(); + let r = s.execute("graphics.newShape", json!({"shape": "rectangle", "position": [200, 200], "size": [80, 40]})).unwrap(); + let clip = ClipId(r["clip"].as_u64().unwrap()); + s.execute( + "graphics.set", + json!({"clip": clip.0, "props": {"fill_kind": "linear gradient", "gradient_start": "#ff0000", "gradient_end": "#0000ff", "gradient_angle": 0}}), + ) + .unwrap(); + let g = layers(&s, clip)[0].appearance.gradient.clone().expect("linear fill"); + assert_eq!(g.start[0], 1.0); + assert!(g.start[1] < 0.01 && g.end[2] > 0.9 && g.end[0] < 0.01, "{g:?}"); + assert_eq!(g.angle, 0.0); + s.execute("graphics.set", json!({"clip": clip.0, "props": {"fill_kind": "solid"}})).unwrap(); + assert!(layers(&s, clip)[0].appearance.gradient.is_none()); +} + #[test] fn graphic_renders_in_the_program_and_survives_save() { let mut s = demo(); diff --git a/crates/project/src/graphic.rs b/crates/project/src/graphic.rs index 5908d423..f92ebbb9 100644 --- a/crates/project/src/graphic.rs +++ b/crates/project/src/graphic.rs @@ -24,6 +24,7 @@ pub const ALIGN_OPTS: &[&str] = &["Left", "Center", "Right", "Justify"]; pub const CAPS_OPTS: &[&str] = &["Normal", "All Caps", "Small Caps"]; pub const STROKE_OPTS: &[&str] = &["Outer", "Center", "Inner"]; pub const SHAPE_OPTS: &[&str] = &["Rectangle", "Ellipse", "Polygon", "Path"]; +pub const FILL_KIND_OPTS: &[&str] = &["Solid", "Linear Gradient"]; /// Whether an effect instance is a graphic layer. pub fn is_layer_id(id: &str) -> bool { @@ -58,6 +59,10 @@ fn appearance() -> Vec { vec![ bo("fill", "Fill", true, A), co("fill_color", "Fill Color", [1.0, 1.0, 1.0, 1.0], A), + chc("fill_kind", "Fill Type", FILL_KIND_OPTS, 0, A), + co("gradient_start", "Gradient Start", [1.0, 1.0, 1.0, 1.0], A), + co("gradient_end", "Gradient End", [0.15, 0.15, 0.15, 1.0], A), + p("gradient_angle", "Gradient Angle", ParamKind::Angle, ParamValue::Float(0.0), true, A), bo("stroke", "Stroke", false, A), co("stroke_color", "Stroke Color", [0.0, 0.0, 0.0, 1.0], A), fl("stroke_width", "Stroke Width", 4.0, (0.0, 1000.0), (0.0, 100.0), "", A), @@ -144,10 +149,21 @@ pub(crate) fn layer_defs() -> Vec { ] } +/// A linear fill across the layer's local bounds. Colours are sRGB-encoded straight RGBA. +/// Angle is degrees clockwise on screen (y down); 0 runs left to right. +#[derive(Clone, Debug, PartialEq)] +pub struct LinearGradient { + pub start: [f32; 4], + pub end: [f32; 4], + pub angle: f32, +} + /// Fill / strokes / background / shadow of a layer (colours are sRGB-encoded straight RGBA). #[derive(Clone, Debug, PartialEq)] pub struct Appearance { pub fill: Option<[f32; 4]>, + /// Set when Fill Type is Linear Gradient. Replaces the solid fill for the whole layer. + pub gradient: Option, /// (colour, width px, type: 0 outer, 1 centre, 2 inner), outermost last. pub strokes: Vec<([f32; 4], f32, u32)>, /// (colour with opacity in alpha, padding px, corner radius px). @@ -334,7 +350,17 @@ pub fn eval_layer(e: &EffectInstance, t: Tick, frame: (u32, u32)) -> Option Option { + let det = m[0] * m[3] - m[1] * m[2]; + if det.abs() < 1e-8 { + return None; + } + let inv = 1.0 / det; + let a = m[3] * inv; + let b = -m[1] * inv; + let c = -m[2] * inv; + let d = m[0] * inv; + Some([a, b, c, d, -(a * m[4] + c * m[5]), -(b * m[4] + d * m[5])]) +} + +/// Paint `grad` through `msk`. 0° runs left to right across `lb` in layer space; the angle is +/// clockwise on screen and turns with `inv` (the device→layer transform). +fn over_linear_gradient(px: &mut [f32], msk: &Mask, grad: &LinearGradient, lb: [f32; 4], inv: &Xform, origin: (i32, i32)) { + if msk.w == 0 { + return; + } + let (cx, cy) = ((lb[0] + lb[2]) * 0.5, (lb[1] + lb[3]) * 0.5); + let (sin, cos) = grad.angle.to_radians().sin_cos(); + let (ax, ay) = (cos, sin); + let corners = [(lb[0], lb[1]), (lb[2], lb[1]), (lb[0], lb[3]), (lb[2], lb[3])]; + let half = corners.iter().map(|(x, y)| ((x - cx) * ax + (y - cy) * ay).abs()).fold(1e-3_f32, f32::max); + let w = msk.w; + let (start, end) = (grad.start, grad.end); + px.par_chunks_mut(4 * w).enumerate().for_each(|(row, dest)| { + let py = origin.1 as f32 + row as f32 + 0.5; + let mrow = &msk.a[row * w..(row + 1) * w]; + for (col, (p, &cov)) in dest.as_chunks_mut::<4>().0.iter_mut().zip(mrow).enumerate() { + if cov <= 0.0 { + continue; + } + let (lx, ly) = apply(inv, origin.0 as f32 + col as f32 + 0.5, py); + let t = (((((lx - cx) * ax + (ly - cy) * ay) / half) + 1.0) * 0.5).clamp(0.0, 1.0); + let c = premul_linear([ + start[0] * (1.0 - t) + end[0] * t, + start[1] * (1.0 - t) + end[1] * t, + start[2] * (1.0 - t) + end[2] * t, + start[3] * (1.0 - t) + end[3] * t, + ]); + let k = 1.0 - c[3] * cov; + for i in 0..4 { + p[i] = c[i] * cov + p[i] * k; + } + } + }); +} + /// Rasterise one layer with layer→device transform `m`, clipped to a `cw`×`ch` canvas. pub fn raster_layer(spec: &LayerSpec, m: &Xform, cw: usize, ch: usize) -> Option { if !spec.enabled || spec.transform.opacity <= 0.0 { @@ -294,7 +343,15 @@ pub fn raster_layer(spec: &LayerSpec, m: &Xform, cw: usize, ch: usize) -> Option over_mask(&mut px, sm, premul_linear(*c)); } } - if let Some(c) = ap.fill { + if let Some(grad) = &ap.gradient + && !matches!(&spec.content, LayerContent::Text(t) if t.runs.iter().any(|(_, s)| s.fill.is_some())) + { + if let Some(inv) = invert_xform(m) { + over_linear_gradient(&mut px, &cover, grad, lb, &inv, (x0, y0)); + } else if let Some(c) = ap.fill { + over_mask(&mut px, &cover, premul_linear(c)); + } + } else if let Some(c) = ap.fill { // per-character fills: each style run with its own colour is drawn on its own let run_fills: Vec> = match &spec.content { LayerContent::Text(t) => t.runs.iter().map(|(_, s)| s.fill).collect(), @@ -702,6 +759,45 @@ mod tests { assert_eq!(img.get(20, 20)[3], 0.0); } + #[test] + fn linear_gradient_runs_left_to_right_on_a_shape_and_on_text() { + let mut shape = new_shape_layer(0, Vec2::new(100.0, 100.0), Vec2::new(80.0, 40.0), vec![]); + set(&mut shape, "fill_kind", ParamValue::Choice(1)); + set(&mut shape, "gradient_start", ParamValue::Color([1.0, 0.0, 0.0, 1.0])); + set(&mut shape, "gradient_end", ParamValue::Color([0.0, 0.0, 1.0, 1.0])); + set(&mut shape, "gradient_angle", ParamValue::Float(0.0)); + let mut img = Image::new(200, 200); + render_graphic_layers(&[shape], Tick::ZERO, (200, 200), &Affine::IDENTITY, &mut img); + let left = img.get(70, 100); + let right = img.get(130, 100); + assert!(left[3] > 0.9 && left[0] > left[2], "redder on the left {left:?}"); + assert!(right[3] > 0.9 && right[2] > right[0], "bluer on the right {right:?}"); + + let mut text = new_text_layer("HHHHHHHH", Vec2::new(16.0, 90.0), 64.0); + set(&mut text, "fill_kind", ParamValue::Choice(1)); + set(&mut text, "gradient_start", ParamValue::Color([1.0, 0.0, 0.0, 1.0])); + set(&mut text, "gradient_end", ParamValue::Color([0.0, 0.0, 1.0, 1.0])); + let mut img = Image::new(500, 180); + render_graphic_layers(&[text], Tick::ZERO, (500, 180), &Affine::IDENTITY, &mut img); + let ink: Vec<(usize, usize)> = (0..img.h).flat_map(|y| (0..img.w).map(move |x| (x, y))).filter(|&(x, y)| img.get(x, y)[3] > 0.8).collect(); + let minx = ink.iter().map(|p| p.0).min().unwrap(); + let maxx = ink.iter().map(|p| p.0).max().unwrap(); + let span = (maxx - minx).max(1); + let (mut ln, mut rn, mut ls, mut rs) = (0.0_f32, 0.0, 0.0, 0.0); + for &(x, y) in &ink { + let p = img.get(x, y)[0]; + if x < minx + span / 3 { + ls += p; + ln += 1.0; + } else if x > maxx - span / 3 { + rs += p; + rn += 1.0; + } + } + assert!(ln > 30.0 && rn > 30.0, "ink on both ends of the title: {ln} {rn} x {minx}..{maxx}"); + assert!(ls / ln > rs / rn + 0.15, "text ramps from red to blue: {} {}", ls / ln, rs / rn); + } + #[test] fn inner_and_centre_strokes() { for (kind, inside, outside) in [(2u32, true, false), (1, true, true)] { diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index a39dc82e..1939bc41 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1031,6 +1031,7 @@ Corner Radius Radio de esquina Polygon Sides Lados del polígono Fill Relleno + Gradient Degradado Stroke Trazo Stroke 2 Trazo 2 Background Fondo diff --git a/crates/ui-egui/src/panels/graphics.rs b/crates/ui-egui/src/panels/graphics.rs index 46feeb4c..1c7c6330 100644 --- a/crates/ui-egui/src/panels/graphics.rs +++ b/crates/ui-egui/src/panels/graphics.rs @@ -1536,8 +1536,22 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { // ---- appearance if section(ui, app, "Appearance", &t) { let (_, mut vui) = cx.row(ui, tl!("Fill")); - cx.check(&mut vui, "fill", ""); + let fill_on = cx.check(&mut vui, "fill", ""); cx.color(&mut vui, "fill_color"); + if fill_on { + vui.add_space(6.0); + cx.choice(&mut vui, "fill_kind", graphic::FILL_KIND_OPTS, 130.0); + } + let fill_kind = match pv(e, "fill_kind", mt) { + ParamValue::Choice(c) => c, + _ => 0, + }; + if fill_on && fill_kind == 1 { + let (_, mut vui) = cx.row(ui, tl!("Gradient")); + cx.color(&mut vui, "gradient_start"); + cx.color(&mut vui, "gradient_end"); + cx.number(ui, tl!(" Angle"), "gradient_angle", 0.5, (-3600.0, 3600.0), 0, " °"); + } for (on, col, w, kind, label) in [ ("stroke", "stroke_color", "stroke_width", "stroke_type", tl!("Stroke")), ("stroke2", "stroke2_color", "stroke2_width", "stroke2_type", tl!("Stroke 2")), diff --git a/docs/graphics.md b/docs/graphics.md index 6d935e82..8f888b38 100644 --- a/docs/graphics.md +++ b/docs/graphics.md @@ -248,7 +248,15 @@ panel state and the Type-tool selection. | `file.replaceFonts` | Graphics and Titles ▸ Replace Fonts in Projects… | `from` (family or {family, style}), `to`, `toStyle`: graphic layers, character styles, source graphics and caption tracks | | `graphics.fonts.used` | (query) | fonts in use with counts and whether they are missing | +## Gradient fills + +Appearance › Fill Type › Linear Gradient paints the shape or the text with a ramp from Gradient +Start to Gradient End. The angle is degrees clockwise on screen; 0° runs left to right across the +layer's own bounds, and the ramp turns with the layer. `graphics.set` accepts `fill_kind` +(`solid` or `linear gradient`), `gradient_start`, `gradient_end` and `gradient_angle`. A +per-character fill colour still paints those characters as a solid. + ## Not yet -Mask-with-text, gradient fills, per-layer blend modes, Bézier curves in the pen tool (paths are -polygons), media layers inside graphics, and template controls grouped into folders. +Mask-with-text, per-layer blend modes, Bézier curves in the pen tool (paths are polygons), media +layers inside graphics, and template controls grouped into folders. From fcaa6bbdf45528d305fda9b904e66d2ca54d80e7 Mon Sep 17 00:00:00 2001 From: liangforstudy <36067395+liangforstudy@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:23:38 +0800 Subject: [PATCH 34/64] #484: Enable flips each selected clip on its own With one enabled and one disabled clip selected, Clip > Enable (Shift+E) set every selected clip to the same state: the first press enabled both, the next disabled both. Premiere flips each clip separately (confirmed by the reporter in #484), so a mixed selection swaps. Each clip in the selection (with its linked clips) now flips on its own; a clip named twice in the parameters still flips once. Test: enable_flips_each_selected_clip (fails before the fix: the mixed press left both clips enabled). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qq8BK1LmohzE1ga4NgAdWM --- crates/engine/src/commands.rs | 10 +++++++--- crates/engine/src/tests.rs | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index b5e4c847..435193a4 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1325,13 +1325,17 @@ fn build() -> Vec { Ok(Value::Null) } ), + // Each clip flips on its own, as in Premiere (#484): with one enabled and one disabled clip + // selected, Enable swaps them rather than first making both the same. cmd!("clip.enable", "Enable", ["Clip"], Some("Shift+E"), r#"{"clips":[id]?}"#, has_selection, |s, p| { - let sel = with_links(s, &clips_p(s, p)); + let mut sel = with_links(s, &clips_p(s, p)); + // a clip named twice must still flip once + sel.sort(); + sel.dedup(); s.edit_sequence("Enable", |q, _, _| { - let target = !sel.iter().all(|c| q.find_item(*c).is_some_and(|(_, i)| i.enabled)); for c in &sel { if let Some((_, i)) = q.find_item_mut(*c) { - i.enabled = target; + i.enabled = !i.enabled; } } Ok(()) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 01d5b813..34e05061 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -641,3 +641,37 @@ fn dragging_an_effect_parameter_is_one_undo_step() { s.undo(); assert_eq!(opacity(&s), start, "and then the whole first one"); } + +/// #484: Enable flips each selected clip on its own, as in Premiere. With one enabled and one +/// disabled clip selected it swaps them, instead of first making both the same. +#[test] +fn enable_flips_each_selected_clip() { + let mut s = demo(); + let enabled = |s: &Session, c: u64| s.active_sequence().unwrap().find_item(ClipId(c)).unwrap().1.enabled; + let partner = |s: &Session, c: u64| { + let q = s.active_sequence().unwrap(); + let link = q.find_item(ClipId(c)).unwrap().1.link?; + q.all_tracks().flat_map(|t| t.items.iter()).find(|i| i.link == Some(link) && i.id != ClipId(c)).map(|i| i.id.0) + }; + let (a, b) = (v1(&s)[0].0, v1(&s)[1].0); + s.execute("clip.enable", json!({"clips": [a]})).unwrap(); + assert_eq!((enabled(&s, a), enabled(&s, b)), (false, true)); + // mixed selection: each flips + s.execute("clip.enable", json!({"clips": [a, b]})).unwrap(); + assert_eq!((enabled(&s, a), enabled(&s, b)), (true, false)); + s.execute("clip.enable", json!({"clips": [a, b]})).unwrap(); + assert_eq!((enabled(&s, a), enabled(&s, b)), (false, true)); + // a clip named twice still flips once, and one press is one undo step + s.execute("clip.enable", json!({"clips": [a, a]})).unwrap(); + assert!(enabled(&s, a)); + s.undo(); + assert_eq!((enabled(&s, a), enabled(&s, b)), (false, true)); + // with linked selection on, a clip's linked audio flips with it + if let Some(au) = partner(&s, a) { + assert_eq!(enabled(&s, au), enabled(&s, a)); + } + // a uniform selection still toggles as before + s.execute("clip.enable", json!({"clips": [b]})).unwrap(); + s.execute("clip.enable", json!({"clips": [a, b]})).unwrap(); + assert_eq!((enabled(&s, a), enabled(&s, b)), (true, true)); +} From ac83bae223cf3245dde628de740866c6c79ae957 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 10 Oct 2026 13:18:45 +1100 Subject: [PATCH 35/64] Blend graphic gradients in linear light and keep per-character fills solid. Stops were mixed in encoded sRGB, and any run with its own fill turned the whole layer into a solid colour. The ramp now blends in linear light, turns with the layer, and leaves a per-character fill solid. --- crates/project/src/graphic.rs | 3 +- crates/render/src/graphic_clip.rs | 166 +++++++++++++++++++++++------- crates/render/src/graphics.rs | 9 ++ docs/graphics.md | 10 +- 4 files changed, 147 insertions(+), 41 deletions(-) diff --git a/crates/project/src/graphic.rs b/crates/project/src/graphic.rs index f92ebbb9..978917e3 100644 --- a/crates/project/src/graphic.rs +++ b/crates/project/src/graphic.rs @@ -162,7 +162,8 @@ pub struct LinearGradient { #[derive(Clone, Debug, PartialEq)] pub struct Appearance { pub fill: Option<[f32; 4]>, - /// Set when Fill Type is Linear Gradient. Replaces the solid fill for the whole layer. + /// Set when Fill Type is Linear Gradient. Replaces the solid fill. A text run with its own + /// fill colour stays solid; the other characters keep the ramp. pub gradient: Option, /// (colour, width px, type: 0 outer, 1 centre, 2 inner), outermost last. pub strokes: Vec<([f32; 4], f32, u32)>, diff --git a/crates/render/src/graphic_clip.rs b/crates/render/src/graphic_clip.rs index 6f81c010..eaf1f6b9 100644 --- a/crates/render/src/graphic_clip.rs +++ b/crates/render/src/graphic_clip.rs @@ -20,7 +20,7 @@ use filmcraft_text::{Align, Caps, Layout, Mask, ParagraphStyle, StrokeKind, Styl use filmcraft_time::Tick; use rayon::prelude::*; -use crate::graphics::premul_linear; +use crate::graphics::{premul_linear, premul_linear_lerp}; use crate::image::Image; /// f64 affine → the text crate's f32 transform. @@ -226,12 +226,7 @@ fn over_linear_gradient(px: &mut [f32], msk: &Mask, grad: &LinearGradient, lb: [ } let (lx, ly) = apply(inv, origin.0 as f32 + col as f32 + 0.5, py); let t = (((((lx - cx) * ax + (ly - cy) * ay) / half) + 1.0) * 0.5).clamp(0.0, 1.0); - let c = premul_linear([ - start[0] * (1.0 - t) + end[0] * t, - start[1] * (1.0 - t) + end[1] * t, - start[2] * (1.0 - t) + end[2] * t, - start[3] * (1.0 - t) + end[3] * t, - ]); + let c = premul_linear_lerp(start, end, t); let k = 1.0 - c[3] * cov; for i in 0..4 { p[i] = c[i] * cov + p[i] * k; @@ -240,6 +235,65 @@ fn over_linear_gradient(px: &mut [f32], msk: &Mask, grad: &LinearGradient, lb: [ }); } +/// Solid fill, one mask per distinct colour when text runs carry their own. +fn paint_solid(px: &mut [f32], cover: &Mask, color: [f32; 4], text: Option<&Layout>, m: &Xform, run_fills: &[Option<[f32; 4]>], origin: (i32, i32)) { + let Some(layout) = text.filter(|_| run_fills.iter().any(Option::is_some)) else { + over_mask(px, cover, premul_linear(color)); + return; + }; + let mut groups: Vec<([f32; 4], Vec)> = Vec::new(); + for r in 0..=run_fills.len() { + let col = if r == 0 { color } else { run_fills[r - 1].unwrap_or(color) }; + match groups.iter_mut().find(|g| g.0 == col) { + Some(g) => g.1.push(r as u16), + None => groups.push((col, vec![r as u16])), + } + } + for (col, runs) in groups { + let mut m_run = Mask::new(cover.w, cover.h); + for r in runs { + render::draw_run(layout, m, &mut m_run, origin, Some(r)); + } + over_mask(px, &m_run, premul_linear(col)); + } +} + +/// Gradient for runs that have no fill of their own; runs with a fill stay that solid colour. +fn paint_mixed_gradient( + px: &mut [f32], + cover: &Mask, + grad: &LinearGradient, + lb: [f32; 4], + inv: &Xform, + origin: (i32, i32), + layout: &Layout, + m: &Xform, + run_fills: &[Option<[f32; 4]>], +) { + let mut grad_mask = Mask::new(cover.w, cover.h); + render::draw_run(layout, m, &mut grad_mask, origin, Some(0)); + let mut solids: Vec<([f32; 4], Vec)> = Vec::new(); + for (i, fill) in run_fills.iter().enumerate() { + let run = (i + 1) as u16; + if let Some(col) = fill { + match solids.iter_mut().find(|g| g.0 == *col) { + Some(g) => g.1.push(run), + None => solids.push((*col, vec![run])), + } + } else { + render::draw_run(layout, m, &mut grad_mask, origin, Some(run)); + } + } + over_linear_gradient(px, &grad_mask, grad, lb, inv, origin); + for (col, runs) in solids { + let mut m_run = Mask::new(cover.w, cover.h); + for r in runs { + render::draw_run(layout, m, &mut m_run, origin, Some(r)); + } + over_mask(px, &m_run, premul_linear(col)); + } +} + /// Rasterise one layer with layer→device transform `m`, clipped to a `cw`×`ch` canvas. pub fn raster_layer(spec: &LayerSpec, m: &Xform, cw: usize, ch: usize) -> Option { if !spec.enabled || spec.transform.opacity <= 0.0 { @@ -343,40 +397,22 @@ pub fn raster_layer(spec: &LayerSpec, m: &Xform, cw: usize, ch: usize) -> Option over_mask(&mut px, sm, premul_linear(*c)); } } + let run_fills: Vec> = match &spec.content { + LayerContent::Text(t) => t.runs.iter().map(|(_, s)| s.fill).collect(), + _ => Vec::new(), + }; if let Some(grad) = &ap.gradient - && !matches!(&spec.content, LayerContent::Text(t) if t.runs.iter().any(|(_, s)| s.fill.is_some())) + && let Some(inv) = invert_xform(m) { - if let Some(inv) = invert_xform(m) { + if run_fills.iter().any(Option::is_some) + && let Some(layout) = &text + { + paint_mixed_gradient(&mut px, &cover, grad, lb, &inv, (x0, y0), layout, m, &run_fills); + } else { over_linear_gradient(&mut px, &cover, grad, lb, &inv, (x0, y0)); - } else if let Some(c) = ap.fill { - over_mask(&mut px, &cover, premul_linear(c)); } } else if let Some(c) = ap.fill { - // per-character fills: each style run with its own colour is drawn on its own - let run_fills: Vec> = match &spec.content { - LayerContent::Text(t) => t.runs.iter().map(|(_, s)| s.fill).collect(), - _ => Vec::new(), - }; - match &text { - Some(l) if run_fills.iter().any(Option::is_some) => { - let mut groups: Vec<([f32; 4], Vec)> = Vec::new(); - for r in 0..=run_fills.len() { - let col = if r == 0 { c } else { run_fills[r - 1].unwrap_or(c) }; - match groups.iter_mut().find(|g| g.0 == col) { - Some(g) => g.1.push(r as u16), - None => groups.push((col, vec![r as u16])), - } - } - for (col, runs) in groups { - let mut m_run = Mask::new(w, h); - for r in runs { - render::draw_run(l, m, &mut m_run, (x0, y0), Some(r)); - } - over_mask(&mut px, &m_run, premul_linear(col)); - } - } - _ => over_mask(&mut px, &cover, premul_linear(c)), - } + paint_solid(&mut px, &cover, c, text.as_deref(), m, &run_fills, (x0, y0)); } for ((sm, c), (_, _, k)) in strokes.iter().zip(&ap.strokes).rev() { if !outer(*k) { @@ -798,6 +834,64 @@ mod tests { assert!(ls / ln > rs / rn + 0.15, "text ramps from red to blue: {} {}", ls / ln, rs / rn); } + #[test] + fn linear_gradient_blends_in_linear_light_and_turns_with_the_layer() { + let mut shape = new_shape_layer(0, Vec2::new(100.0, 100.0), Vec2::new(80.0, 40.0), vec![]); + set(&mut shape, "fill_kind", ParamValue::Choice(1)); + set(&mut shape, "gradient_start", ParamValue::Color([0.0, 0.0, 0.0, 1.0])); + set(&mut shape, "gradient_end", ParamValue::Color([1.0, 1.0, 1.0, 1.0])); + let mut img = Image::new(200, 200); + render_graphic_layers(&[shape.clone()], Tick::ZERO, (200, 200), &Affine::IDENTITY, &mut img); + let mid = img.get(100, 100)[0]; + // Linear-light midpoint is ~0.5. An sRGB midpoint converted to linear is ~0.21. + assert!(mid > 0.4 && mid < 0.6, "centre of black→white is linear 0.5, got {mid}"); + assert!(img.get(70, 100)[0] < mid && mid < img.get(130, 100)[0]); + + set(&mut shape, "gradient_start", ParamValue::Color([1.0, 0.0, 0.0, 1.0])); + set(&mut shape, "gradient_end", ParamValue::Color([0.0, 0.0, 1.0, 1.0])); + set(&mut shape, "rotation", ParamValue::Float(90.0)); + let mut turned = Image::new(200, 200); + render_graphic_layers(&[shape], Tick::ZERO, (200, 200), &Affine::IDENTITY, &mut turned); + let top = turned.get(100, 70); + let bot = turned.get(100, 130); + assert!(top[3] > 0.9 && bot[3] > 0.9, "the rotated ramp is inside the shape: {top:?} {bot:?}"); + assert!(top[0] > top[2], "clockwise 90° carries the start colour to the top {top:?}"); + assert!(bot[2] > bot[0], "and the end colour to the bottom {bot:?}"); + } + + #[test] + fn linear_gradient_keeps_a_per_character_fill_solid() { + use filmcraft_project::graphic_design::{CharStyle, LayerExtra, StyleRun}; + let mut e = new_text_layer("AAAA", Vec2::new(20.0, 100.0), 60.0); + set(&mut e, "fill_kind", ParamValue::Choice(1)); + set(&mut e, "gradient_start", ParamValue::Color([0.0, 1.0, 0.0, 1.0])); + set(&mut e, "gradient_end", ParamValue::Color([0.0, 0.0, 1.0, 1.0])); + let red = CharStyle { fill: Some([1.0, 0.0, 0.0, 1.0]), ..Default::default() }; + e.layer = Some(Box::new(LayerExtra { runs: vec![StyleRun { start: 2, end: 4, style: red }], ..Default::default() })); + let mut img = Image::new(400, 200); + render_graphic_layers(&[e.clone()], Tick::ZERO, (400, 200), &Affine::IDENTITY, &mut img); + let l = text_layout(match &eval_layer(&e, Tick::ZERO, (400, 200)).unwrap().content { + LayerContent::Text(t) => t, + _ => unreachable!(), + }); + let split = 20.0 + l.caret(2).0; + let (mut green, mut red_px) = (0, 0); + for y in 0..200 { + for x in 0..400 { + let p = img.get(x, y); + if p[3] < 0.8 { + continue; + } + if (x as f32) < split - 1.0 && p[1] > p[0] && p[1] > p[2] { + green += 1; + } else if (x as f32) > split + 1.0 && p[0] > 0.9 && p[1] < 0.05 { + red_px += 1; + } + } + } + assert!(green > 80 && red_px > 80, "gradient on the plain letters, solid red on the styled ones: {green} {red_px}"); + } + #[test] fn inner_and_centre_strokes() { for (kind, inside, outside) in [(2u32, true, false), (1, true, true)] { diff --git a/crates/render/src/graphics.rs b/crates/render/src/graphics.rs index 3664aa01..dd98fe74 100644 --- a/crates/render/src/graphics.rs +++ b/crates/render/src/graphics.rs @@ -14,6 +14,15 @@ pub fn premul_linear(c: [f32; 4]) -> [f32; 4] { [srgb_to_linear(c[0]) * a, srgb_to_linear(c[1]) * a, srgb_to_linear(c[2]) * a, a] } +/// Blend two sRGB straight colours in linear light, then premultiply. `t` is 0 at `start`. +pub fn premul_linear_lerp(start: [f32; 4], end: [f32; 4], t: f32) -> [f32; 4] { + let t = t.clamp(0.0, 1.0); + let s = [srgb_to_linear(start[0]), srgb_to_linear(start[1]), srgb_to_linear(start[2]), start[3].clamp(0.0, 1.0)]; + let e = [srgb_to_linear(end[0]), srgb_to_linear(end[1]), srgb_to_linear(end[2]), end[3].clamp(0.0, 1.0)]; + let a = s[3] * (1.0 - t) + e[3] * t; + [(s[0] * (1.0 - t) + e[0] * t) * a, (s[1] * (1.0 - t) + e[1] * t) * a, (s[2] * (1.0 - t) + e[2] * t) * a, a] +} + /// Composite `color` (premultiplied linear) through coverage `m` placed at (`x0`, `y0`), scaled by /// `opacity`, over `img`. pub fn paint_mask(img: &mut Image, m: &Mask, x0: i32, y0: i32, color: [f32; 4], opacity: f32) { diff --git a/docs/graphics.md b/docs/graphics.md index 8f888b38..0f14cdea 100644 --- a/docs/graphics.md +++ b/docs/graphics.md @@ -251,10 +251,12 @@ panel state and the Type-tool selection. ## Gradient fills Appearance › Fill Type › Linear Gradient paints the shape or the text with a ramp from Gradient -Start to Gradient End. The angle is degrees clockwise on screen; 0° runs left to right across the -layer's own bounds, and the ramp turns with the layer. `graphics.set` accepts `fill_kind` -(`solid` or `linear gradient`), `gradient_start`, `gradient_end` and `gradient_angle`. A -per-character fill colour still paints those characters as a solid. +Start to Gradient End. Stops are blended in linear light. The angle is degrees clockwise on +screen; 0° runs left to right across the layer's own bounds, and the ramp turns with the layer. +`graphics.set` accepts `fill_kind` (`solid` or `linear gradient`), `gradient_start`, +`gradient_end` and `gradient_angle`. A per-character fill colour still paints those characters +as a solid; the rest of the text keeps the ramp. A singular layer transform falls back to the +solid fill colour. ## Not yet From f2c42f9864a10400667d85269c5adb0f8e7642ab Mon Sep 17 00:00:00 2001 From: Chuy Garcia <75956027+chuygarcia92@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:20:17 -0600 Subject: [PATCH 36/64] Implement scrollable areas for media dialog Added vertical scroll areas for media rows and candidates to improve UI usability. --- crates/ui-egui/src/panels/media_dialogs.rs | 71 ++++++++++++---------- 1 file changed, 38 insertions(+), 33 deletions(-) diff --git a/crates/ui-egui/src/panels/media_dialogs.rs b/crates/ui-egui/src/panels/media_dialogs.rs index e4cd2e93..de3370d2 100644 --- a/crates/ui-egui/src/panels/media_dialogs.rs +++ b/crates/ui-egui/src/panels/media_dialogs.rs @@ -161,6 +161,7 @@ fn link_media(app: &mut FilmcraftApp, ctx: &egui::Context) { let accent = app.tokens.accent; let sel_path = d.candidate.and_then(|c| d.candidates.get(c)).map(|c| c.0.clone()); let preview = sel_path.as_deref().and_then(|p| preview_texture(app, ctx, p)); + let rows_max_h = (ctx.content_rect().height() * 0.3).clamp(90.0, 320.0); egui::Window::new(tl!("Link Media")) .id(egui::Id::new("Link Media")) .collapsible(false) @@ -170,24 +171,26 @@ fn link_media(app: &mut FilmcraftApp, ctx: &egui::Context) { .show(ctx, |ui| { ui.label(tlf!("{n} clip(s) can't find their media. Locate them, search a folder, or leave them offline.", n = rows.len())); ui.add_space(6.0); - egui::Grid::new("link-media-rows").num_columns(4).striped(true).spacing([14.0, 4.0]).show(ui, |ui| { - for h in [tl!("Clip Name"), tl!("File Name"), tl!("File Path"), tl!("Status")] { - ui.label(RichText::new(h).strong()); - } - ui.end_row(); - for (k, (_, name, fname, path, status)) in rows.iter().enumerate() { - let r = ui.selectable_label(d.row == k, name); - push(&mut elems, format!("linkMedia.row.{k}"), &r, name.clone()); - if r.clicked() && d.row != k { - d.row = k; - d.candidates.clear(); - d.candidate = None; + egui::ScrollArea::vertical().id_salt("link-media-rows-scroll").max_height(rows_max_h).auto_shrink([false, true]).show(ui, |ui| { + egui::Grid::new("link-media-rows").num_columns(4).striped(true).spacing([14.0, 4.0]).show(ui, |ui| { + for h in [tl!("Clip Name"), tl!("File Name"), tl!("File Path"), tl!("Status")] { + ui.label(RichText::new(h).strong()); } - ui.label(fname); - ui.label(RichText::new(path).small()); - ui.label(RichText::new(*status).color(Color32::from_rgb(0xe0, 0x5a, 0x5a))); ui.end_row(); - } + for (k, (_, name, fname, path, status)) in rows.iter().enumerate() { + let r = ui.selectable_label(d.row == k, name); + push(&mut elems, format!("linkMedia.row.{k}"), &r, name.clone()); + if r.clicked() && d.row != k { + d.row = k; + d.candidates.clear(); + d.candidate = None; + } + ui.label(fname); + ui.label(RichText::new(path).small()); + ui.label(RichText::new(*status).color(Color32::from_rgb(0xe0, 0x5a, 0x5a))); + ui.end_row(); + } + }); }); ui.separator(); ui.horizontal(|ui| { @@ -234,24 +237,26 @@ fn link_media(app: &mut FilmcraftApp, ctx: &egui::Context) { if d.candidates.is_empty() { ui.label(RichText::new(tl!("No candidates yet — Search a folder or Locate the file.")).weak()); } - for (k, (path, ok, idm, problems)) in d.candidates.iter().enumerate() { - let mark = match (ok, idm) { - (true, Some(true)) => tl!("✔ same file"), - (true, _) => tl!("✔ matches"), - (false, Some(false)) => tl!("✖ different file"), - _ => "✖", - }; - let text = format!("{mark} {path}"); - let r = ui.selectable_label( - d.candidate == Some(k), - RichText::new(&text).color(if *ok { Color32::LIGHT_GREEN } else { Color32::from_rgb(0xe0, 0x8a, 0x6a) }), - ); - let r = if problems.is_empty() { r } else { r.on_hover_text(problems) }; - push(&mut elems, format!("linkMedia.candidate.{k}"), &r, text); - if r.clicked() { - d.candidate = Some(k); + egui::ScrollArea::vertical().id_salt("link-media-candidates-scroll").max_height(108.0).auto_shrink([false, true]).show(ui, |ui| { + for (k, (path, ok, idm, problems)) in d.candidates.iter().enumerate() { + let mark = match (ok, idm) { + (true, Some(true)) => tl!("✔ same file"), + (true, _) => tl!("✔ matches"), + (false, Some(false)) => tl!("✖ different file"), + _ => "✖", + }; + let text = format!("{mark} {path}"); + let r = ui.selectable_label( + d.candidate == Some(k), + RichText::new(&text).color(if *ok { Color32::LIGHT_GREEN } else { Color32::from_rgb(0xe0, 0x8a, 0x6a) }), + ); + let r = if problems.is_empty() { r } else { r.on_hover_text(problems) }; + push(&mut elems, format!("linkMedia.candidate.{k}"), &r, text); + if r.clicked() { + d.candidate = Some(k); + } } - } + }); }); let (rect, _) = ui.allocate_exact_size(egui::vec2(192.0, 108.0), egui::Sense::hover()); ui.painter().rect_filled(rect, 3.0, Color32::from_rgb(12, 12, 12)); From c2bde2e6fd47ee0a02f0ff1b4e635143650d9a50 Mon Sep 17 00:00:00 2001 From: Guilhermeb-Ferrarezi <61890289+Guilhermeb-Ferrarezi@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:43:20 -0300 Subject: [PATCH 37/64] ui: add an eyedropper to colour parameters Colour parameters in Effect Controls (the Ultra Key key colour, Color Key, mattes...) get a pipette button. Armed, a click in the Program monitor fills the parameter with the colour under the pointer (5x5 average, so a noisy green screen gives its true green); Esc disarms. The colour is read from a render of the sequence at the playhead with the armed effect switched off, so an Ultra Key that already removed the green can still pick it. The value is stored the way the colour picker beside it stores it, through effects.setParam (one undo step). --- crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/icons.rs | 10 ++ crates/ui-egui/src/panels/effect_controls.rs | 8 ++ crates/ui-egui/src/panels/monitor.rs | 124 +++++++++++++++++++ crates/ui-egui/src/state.rs | 14 +++ 5 files changed, 158 insertions(+) diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index a39dc82e..9cef2af0 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -973,6 +973,8 @@ Edit… Editar… Show graphs Mostrar gráficos Toggle animation Activar/desactivar animación + Eyedropper (pick a color from the Program monitor) Cuentagotas (tomar un color del monitor de programa) + Eyedropper: no picture to sample Cuentagotas: no hay imagen de la que tomar el color Add/Remove Keyframe Añadir/quitar fotograma clave Temporal Interpolation Interpolación temporal Linear Lineal diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 32cfa3e4..d302d2f4 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -114,6 +114,8 @@ pub enum Icon { Network, /// Media Browser: recent directories (a clock face). Clock, + /// Colour parameters: pick a colour from the Program monitor (a pipette). + Eyedropper, } pub struct Pen16<'a> { @@ -725,6 +727,13 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) { pen.circle(8.0, 8.0, 6.0); pen.line(&[(8.0, 4.5), (8.0, 8.0), (10.5, 9.5)]); } + Eyedropper => { + // a pipette: bulb at the top right, glass tube running to a tip at the bottom left + pen.closed(&[(10.0, 4.5), (11.5, 3.0), (13.0, 3.0), (13.0, 4.5), (11.5, 6.0)]); + pen.line(&[(8.2, 6.2), (10.8, 8.8)]); + pen.line(&[(9.5, 7.5), (3.5, 12.5)]); + pen.line(&[(3.5, 12.5), (2.5, 13.5)]); + } } } @@ -853,6 +862,7 @@ mod tests { Drive, Network, Clock, + Eyedropper, ]; /// Paint `icon` at `ppp` device pixels per point into a rect of `size` points whose corner is diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index e3ae049c..8b87b652 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -466,6 +466,14 @@ pub(crate) fn param_row( if egui::color_picker::color_edit_button_rgba(&mut vui, &mut rgba, egui::color_picker::Alpha::Opaque).changed() { set = Some(json!([rgba.r(), rgba.g(), rgba.b(), rgba.a()])); } + // Eyedropper: arm it, then click a pixel in the Program monitor (Esc cancels) + let target = crate::state::Eyedropper { clip: clip.0, effect: idx, param: pd.id.to_string(), mask }; + let armed = app.ui.eyedropper.as_ref() == Some(&target); + let drop = icons::button(&mut vui, Icon::Eyedropper, 18.0, armed, &t, tl!("Eyedropper (pick a color from the Program monitor)")); + app.auto.add(&format!("effectControls.{}.{}.eyedropper", e.effect, pkey), drop.rect, "Eyedropper"); + if drop.clicked() { + app.ui.eyedropper = if armed { None } else { Some(target) }; + } } (ParamKind::Path, _) => { crate::panels::masks::path_value(app, &mut vui, clip, idx, mask, actions); diff --git a/crates/ui-egui/src/panels/monitor.rs b/crates/ui-egui/src/panels/monitor.rs index 39034bfa..475bf9b6 100644 --- a/crates/ui-egui/src/panels/monitor.rs +++ b/crates/ui-egui/src/panels/monitor.rs @@ -284,6 +284,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) monitor_view::pan_input(app, ui, which, video_area, pic); } if which == Which::Program && show_picture { + eyedropper(app, ui, &pic_resp, pic, target, rate, time); crate::panels::graphics::monitor_overlay(app, ui, pic, frame_size); crate::panels::masks::monitor_overlay(app, ui, pic, frame_size); } @@ -455,6 +456,97 @@ fn cpu_texture( } } +/// A click of an armed eyedropper waiting for its frame to render. +#[derive(Clone)] +struct EyedropperPick { + /// The clicked point in the picture, 0..1 across and down. + uv: (f32, f32), + key: FrameKey, + time: Tick, + /// The project with the armed effect switched off, so a colour that effect keys out can be picked. + project: std::sync::Arc, +} + +/// Output scale of the frame an eyedropper samples (half size is plenty and quick to render). +const EYEDROPPER_SCALE: f32 = 0.5; + +/// Eyedropper: while a colour parameter's eyedropper is armed (Effect Controls), the Program picture +/// takes a crosshair and a click fills that parameter with the colour under the pointer. The colour is +/// read from a render of the sequence at the playhead with the armed effect switched off (an Ultra Key +/// has already removed the green it is asked to key); Esc disarms. +fn eyedropper(app: &mut FilmcraftApp, ui: &mut egui::Ui, resp: &egui::Response, pic: Rect, target: Target, rate: filmcraft_time::FrameRate, time: Tick) { + let Some(armed) = app.ui.eyedropper.clone() else { return }; + let ctx = ui.ctx().clone(); + let pending_id = egui::Id::new("eyedropper-pending"); + if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + app.ui.eyedropper = None; + ctx.data_mut(|d| d.remove::(pending_id)); + return; + } + if resp.hovered() { + ctx.set_cursor_icon(egui::CursorIcon::Crosshair); + } + if resp.clicked() + && let (Some(pos), Target::Sequence(seq)) = (resp.interact_pointer_pos(), target) + && pic.width() > 0.0 + && pic.height() > 0.0 + { + let uv = (((pos.x - pic.min.x) / pic.width()).clamp(0.0, 1.0), ((pos.y - pic.min.y) / pic.height()).clamp(0.0, 1.0)); + let mut project = (*app.session.project).clone(); + if let Some(e) = project.sequence_mut(seq).and_then(|q| q.find_item_mut(filmcraft_project::ClipId(armed.clip))).and_then(|(_, it)| it.effects.get_mut(armed.effect)) { + e.enabled = false; + } + let frame = rate.frame_at(time); + // a revision of its own, so this render never replaces what the monitor caches + let revision = app.session.revision ^ 0x5eed_d70b_0000_0000 ^ armed.clip.rotate_left(17) ^ armed.effect as u64; + let key = FrameKey { target, frame, size: (EYEDROPPER_SCALE * 1000.0) as u32, revision, draft: false }; + ctx.data_mut(|d| d.insert_temp(pending_id, EyedropperPick { uv, key, time: rate.tick_of(frame), project: std::sync::Arc::new(project) })); + } + let Some(pick) = ctx.data(|d| d.get_temp::(pending_id)) else { return }; + app.frames.request(pick.key, pick.time, EYEDROPPER_SCALE, &pick.project, 0); + let Some(img) = app.frames.get(&pick.key) else { + ctx.request_repaint(); + return; + }; + ctx.data_mut(|d| d.remove::(pending_id)); + app.ui.eyedropper = None; + let Some(rgb) = sample_rgb(&img, pick.uv) else { + app.ui.status = tl!("Eyedropper: no picture to sample").to_string(); + return; + }; + // the parameter stores linear values, like the colour picker beside it + let lin = egui::Rgba::from(Color32::from_rgb(rgb[0], rgb[1], rgb[2])); + let mut params = json!({"clip": armed.clip, "effect": armed.effect, "param": armed.param, "value": [lin.r(), lin.g(), lin.b(), 1.0]}); + if let Some(k) = armed.mask { + params["mask"] = json!(k); + } + if let Err(e) = app.session.execute("effects.setParam", params) { + app.ui.status = e.to_string(); + } +} + +/// The average colour of the 5×5 pixels around `uv` (0..1 across and down) of a frame, so a noisy +/// green screen gives its true green. `None` for an empty frame. +fn sample_rgb(img: &crate::frames::Rgba, uv: (f32, f32)) -> Option<[u8; 3]> { + if img.w == 0 || img.h == 0 || img.px.len() < img.w.saturating_mul(img.h).saturating_mul(4) { + return None; + } + let cx = ((uv.0 * img.w as f32) as usize).min(img.w - 1); + let cy = ((uv.1 * img.h as f32) as usize).min(img.h - 1); + let (mut sum, mut n) = ([0u32; 3], 0u32); + for y in cy.saturating_sub(2)..=(cy + 2).min(img.h - 1) { + for x in cx.saturating_sub(2)..=(cx + 2).min(img.w - 1) { + let i = (y * img.w + x) * 4; + for (c, s) in sum.iter_mut().enumerate() { + *s += u32::from(*img.px.get(i + c)?); + } + n += 1; + } + } + let avg = |c: usize| u8::try_from(sum[c].checked_div(n)?).ok(); + Some([avg(0)?, avg(1)?, avg(2)?]) +} + pub fn quantize_scale(s: f32) -> f32 { // Buckets keep the cache effective while resizing the panel. let buckets = [1.0 / 32.0, 1.0 / 16.0, 1.0 / 8.0, 0.1875, 0.25, 0.375, 0.5, 0.75, 1.0]; @@ -679,3 +771,35 @@ fn source_nav(app: &mut FilmcraftApp, cmd: &str) { }; let _ = app.session.execute("source.setPlayhead", json!({"time": t.0})); } + +#[cfg(test)] +mod tests { + use super::*; + use crate::frames::Rgba; + + fn flat(w: usize, h: usize, rgba: [u8; 4]) -> Rgba { + Rgba { w, h, px: rgba.iter().copied().cycle().take(w * h * 4).collect() } + } + + #[test] + fn eyedropper_averages_the_pixels_around_the_point() { + let mut img = flat(8, 8, [0, 200, 0, 255]); + // one noisy pixel next to the centre is averaged away, not picked + img.px[(4 * 8 + 5) * 4 + 1] = 0; + assert_eq!(sample_rgb(&img, (0.5, 0.5)), Some([0, 192, 0])); + } + + #[test] + fn eyedropper_clamps_to_the_picture_edges() { + let img = flat(4, 3, [10, 20, 30, 255]); + for uv in [(0.0, 0.0), (1.0, 1.0), (0.999, 0.0), (0.0, 0.999)] { + assert_eq!(sample_rgb(&img, uv), Some([10, 20, 30])); + } + } + + #[test] + fn eyedropper_rejects_empty_and_truncated_frames() { + assert_eq!(sample_rgb(&Rgba { w: 0, h: 0, px: vec![] }, (0.5, 0.5)), None); + assert_eq!(sample_rgb(&Rgba { w: 4, h: 4, px: vec![0; 10] }, (0.5, 0.5)), None); + } +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 140d60cc..a8f12b8a 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -359,6 +359,15 @@ pub enum GuideDialog { }, } +/// The colour parameter an armed eyedropper fills (`effects.setParam` arguments). +#[derive(Clone, Debug, PartialEq)] +pub struct Eyedropper { + pub clip: u64, + pub effect: usize, + pub param: String, + pub mask: Option, +} + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct UiState { #[serde(default)] @@ -395,6 +404,10 @@ pub struct UiState { pub show_scopes: bool, /// Transient status line shown in the footer. pub status: String, + /// The colour parameter an armed eyedropper will fill with the next pixel clicked in the Program + /// monitor (Esc or a click elsewhere disarms it). Never saved. + #[serde(skip)] + pub eyedropper: Option, /// Essential Sound sub-tab: "Edit" or "Browse". #[serde(default)] pub essential_sound_tab: String, @@ -875,6 +888,7 @@ impl Default for UiState { dark: true, show_scopes: false, status: String::new(), + eyedropper: None, essential_sound_tab: "Edit".into(), export: Default::default(), text_tab: captions_tab(), From 3180e3d2f29a55cae68d6815cc69dbc2d9b963e2 Mon Sep 17 00:00:00 2001 From: Guilhermeb-Ferrarezi <61890289+Guilhermeb-Ferrarezi@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:48:16 -0300 Subject: [PATCH 38/64] ui: format the eyedropper --- crates/ui-egui/src/panels/monitor.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/monitor.rs b/crates/ui-egui/src/panels/monitor.rs index 475bf9b6..78e23362 100644 --- a/crates/ui-egui/src/panels/monitor.rs +++ b/crates/ui-egui/src/panels/monitor.rs @@ -493,7 +493,9 @@ fn eyedropper(app: &mut FilmcraftApp, ui: &mut egui::Ui, resp: &egui::Response, { let uv = (((pos.x - pic.min.x) / pic.width()).clamp(0.0, 1.0), ((pos.y - pic.min.y) / pic.height()).clamp(0.0, 1.0)); let mut project = (*app.session.project).clone(); - if let Some(e) = project.sequence_mut(seq).and_then(|q| q.find_item_mut(filmcraft_project::ClipId(armed.clip))).and_then(|(_, it)| it.effects.get_mut(armed.effect)) { + if let Some(e) = + project.sequence_mut(seq).and_then(|q| q.find_item_mut(filmcraft_project::ClipId(armed.clip))).and_then(|(_, it)| it.effects.get_mut(armed.effect)) + { e.enabled = false; } let frame = rate.frame_at(time); From 28cde3168d891db0bae1a49f71a435095fea136c Mon Sep 17 00:00:00 2001 From: Tommaso Maccagni Date: Sat, 10 Oct 2026 01:00:14 +0200 Subject: [PATCH 39/64] graphics: document shape placement params; placement errors name their command graphics.newShape, newRectangle, newEllipse and newPolygon already take `seconds`, `track` and `time` through place_video_clip, as graphics.newText does. Their parameter docs (what command_list shows) and the table in docs/graphics.md listed only position, size and clip; they now list all of them and say that position is the shape's centre. place_video_clip takes the id of the command that called it for its errors. It named graphics.newText in every placement error (V3 is not free here), whether a shape, a file, a template or an upgraded caption had asked. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/graphic_templates.rs | 4 ++-- crates/engine/src/graphics.rs | 26 +++++++++++----------- crates/engine/src/graphics_tests.rs | 30 ++++++++++++++++++++++++++ docs/graphics.md | 4 ++-- 4 files changed, 48 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/graphic_templates.rs b/crates/engine/src/graphic_templates.rs index db00d6ee..0b9941e2 100644 --- a/crates/engine/src/graphic_templates.rs +++ b/crates/engine/src/graphic_templates.rs @@ -316,7 +316,7 @@ fn apply_template(s: &mut Session, p: &Value) -> Result { let (layers, meta) = t.instantiate((w, h)); let before = s.history.undo.len(); let dur = t.duration; - let clip = place_video_clip(s, &t.name, p, "Apply Graphics Template", layers, move |pr, (w, h, rate)| { + let clip = place_video_clip(s, "graphics.template.apply", &t.name, p, "Apply Graphics Template", layers, move |pr, (w, h, rate)| { let src = crate::graphics::graphic_source(pr, w, h, rate); (src, rate.snap_nearest(dur)) })?; @@ -911,7 +911,7 @@ fn upgrade_caption(s: &mut Session, p: &Value) -> Result { let name = text.lines().next().unwrap_or("Caption").chars().take(40).collect::(); let dur = c.duration; let q = json!({"time": c.start.0}); - let clip = place_video_clip(s, &name, &q, "Upgrade Caption to Graphic", vec![layer], move |pr, (w, h, rate)| { + let clip = place_video_clip(s, "graphics.upgradeCaption", &name, &q, "Upgrade Caption to Graphic", vec![layer], move |pr, (w, h, rate)| { (crate::graphics::graphic_source(pr, w, h, rate), dur) })?; clips.push(clip.0); diff --git a/crates/engine/src/graphics.rs b/crates/engine/src/graphics.rs index fea4922a..d3ad695a 100644 --- a/crates/engine/src/graphics.rs +++ b/crates/engine/src/graphics.rs @@ -96,23 +96,25 @@ pub(crate) fn graphic_source(p: &mut filmcraft_project::Project, w: u32, h: u32, /// Place a new graphic clip holding `layer` at the playhead: on the first video track above the /// topmost clip at the playhead that is free for the duration (a track is added if needed). -fn new_graphic_clip(s: &mut Session, layer: filmcraft_project::EffectInstance, name: &str, p: &Value) -> Result { +fn new_graphic_clip(s: &mut Session, cmd: &str, layer: filmcraft_project::EffectInstance, name: &str, p: &Value) -> Result { let seconds = f64_p(p, "seconds").unwrap_or(5.0).max(0.01); // Here `seconds` is duration, not the generic time parser's placement alias. let mut placement = p.clone(); if let Some(params) = placement.as_object_mut() { params.remove("seconds"); } - place_video_clip(s, name, &placement, "New Graphic", vec![layer], move |pr, (w, h, rate)| { + place_video_clip(s, cmd, name, &placement, "New Graphic", vec![layer], move |pr, (w, h, rate)| { let src = graphic_source(pr, w, h, rate); (src, rate.snap_nearest(Tick::from_seconds_f64(seconds)).max(rate.frame_duration())) }) } /// Place a video clip of the item `source` returns (with its duration) at the playhead (or -/// `time`), above the clips there, as one undo step; selects it. +/// `time`), above the clips there, as one undo step; selects it. `cmd` is the command that asked, +/// for its errors. pub(crate) fn place_video_clip( s: &mut Session, + cmd: &str, name: &str, p: &Value, label: &str, @@ -133,7 +135,7 @@ pub(crate) fn place_video_clip( let src_size = pr.source_size(src).unwrap_or((w, h)); let t = rate.snap(t); let dur = dur.max(rate.frame_duration()); - let mut ti = pr.make_track_item(src, TrackKind::Video, t, TimeRange::new(Tick::ZERO, dur), rate).ok_or_else(|| bad("graphics.newText", "bad item"))?; + let mut ti = pr.make_track_item(src, TrackKind::Video, t, TimeRange::new(Tick::ZERO, dur), rate).ok_or_else(|| bad(cmd, "bad item"))?; ti.name = name; ti.effects.extend(extra); for e in &mut ti.effects { @@ -146,7 +148,7 @@ pub(crate) fn place_video_clip( let free = |tr: &filmcraft_project::Track| !tr.items.iter().any(|i| i.range().overlaps(&range)); let idx = match want_track { Some(i) if i < q.video_tracks.len() && free(&q.video_tracks[i]) => i, - Some(i) if i < q.video_tracks.len() => return Err(bad("graphics.newText", format!("V{} is not free here", i + 1))), + Some(i) if i < q.video_tracks.len() => return Err(bad(cmd, format!("V{} is not free here", i + 1))), _ => { let top = q.video_tracks.iter().rposition(|tr| tr.item_at(t).is_some()).map_or(0, |k| k + 1); match (top..q.video_tracks.len()).find(|&i| free(&q.video_tracks[i]) && !q.video_tracks[i].locked) { @@ -703,7 +705,7 @@ fn menu_commands() -> Vec { "Rectangle", &["Graphics and Titles", "New Layer"], Some("Cmd+Alt+R"), - r#"{"position":[x,y]?,"size":[w,h]=[400,200],"clip":id?}"#, + r#"{"position":[x,y]?,"size":[w,h]=[400,200],"clip":id?,"seconds":f64=5,"track":index?,"time":ticks?}"#, has_seq, |s, p| new_shape_cmd(s, p, "rectangle"), ), @@ -712,7 +714,7 @@ fn menu_commands() -> Vec { "Ellipse", &["Graphics and Titles", "New Layer"], Some("Cmd+Alt+E"), - r#"{"position":[x,y]?,"size":[w,h]=[400,200],"clip":id?}"#, + r#"{"position":[x,y]?,"size":[w,h]=[400,200],"clip":id?,"seconds":f64=5,"track":index?,"time":ticks?}"#, has_seq, |s, p| new_shape_cmd(s, p, "ellipse"), ), @@ -721,7 +723,7 @@ fn menu_commands() -> Vec { "Polygon", &["Graphics and Titles", "New Layer"], None, - r#"{"position":[x,y]?,"size":[w,h]=[300,300],"sides":n=6,"clip":id?}"#, + r#"{"position":[x,y]?,"size":[w,h]=[300,300],"sides":n=6,"clip":id?,"seconds":f64=5,"track":index?,"time":ticks?}"#, has_seq, |s, p| { let mut q = if p.is_object() { p.clone() } else { json!({}) }; @@ -766,7 +768,7 @@ fn menu_commands() -> Vec { return Err(bad("graphics.newFromFile", "the file has no picture")); } let (name, dur) = (pi.name.clone(), pi.duration()); - let clip = place_video_clip(s, &name, p, "New Layer from File", Vec::new(), move |_, _| (item, dur))?; + let clip = place_video_clip(s, "graphics.newFromFile", &name, p, "New Layer from File", Vec::new(), move |_, _| (item, dur))?; s.state.graphic_layers.clear(); Ok(json!({"clip": clip.0, "item": item.0})) }, @@ -902,7 +904,7 @@ pub fn commands() -> Vec { Some(c) => (c, add_layer(s, c, layer)?), None => { let name = text.lines().next().filter(|l| !l.trim().is_empty()).unwrap_or("Graphic").to_string(); - (new_graphic_clip(s, layer, &name, p)?, 0) + (new_graphic_clip(s, "graphics.newText", layer, &name, p)?, 0) } }; Ok(json!({"clip": clip.0, "layer": layer_i})) @@ -913,7 +915,7 @@ pub fn commands() -> Vec { "Shape", &[], None, - r#"{"shape":"rectangle|ellipse|polygon|path","position":[x,y]?,"size":[w,h]=[400,200],"points":[[x,y],…]?,"clip":id?,"seconds":f64=5}"#, + r#"{"shape":"rectangle|ellipse|polygon|path","position":[x,y]? (the shape's centre),"size":[w,h]=[400,200],"points":[[x,y],…]?,"clip":id? (add the shape to this graphic),"seconds":f64=5,"track":index?,"time":ticks?}"#, has_seq, |s, p| { let shape = str_p(p, "shape").unwrap_or("rectangle").to_ascii_lowercase(); @@ -936,7 +938,7 @@ pub fn commands() -> Vec { let into = u64_p(p, "clip").map(ClipId).filter(|c| s.active_sequence().is_some_and(|q| is_graphic(s, q, *c))); let (clip, layer_i) = match into { Some(c) => (c, add_layer(s, c, layer)?), - None => (new_graphic_clip(s, layer, "Shape", p)?, 0), + None => (new_graphic_clip(s, "graphics.newShape", layer, "Shape", p)?, 0), }; Ok(json!({"clip": clip.0, "layer": layer_i})) }, diff --git a/crates/engine/src/graphics_tests.rs b/crates/engine/src/graphics_tests.rs index 0c7cc4cb..e907bf34 100644 --- a/crates/engine/src/graphics_tests.rs +++ b/crates/engine/src/graphics_tests.rs @@ -65,6 +65,36 @@ fn graphic_duration_keeps_explicit_time_frame_and_timecode_placement() { } } +/// A new graphic goes on the track and at the time it is given, for a shape as for text; a track +/// that is not free there is an error named after the command that asked. +#[test] +fn graphic_placement_takes_a_track_and_a_time() { + let commands = + [("graphics.newText", json!({})), ("graphics.newShape", json!({})), ("graphics.newRectangle", json!({})), ("graphics.newPolygon", json!({"sides": 5}))]; + for (command, mut params) in commands { + let mut s = Session::default(); + s.execute("file.newSequence", json!({"name": "Placement", "fps": 24, "width": 128, "height": 128, "video": 3, "audio": 1})).unwrap(); + s.execute("playhead.set", json!({"frame": 72})).unwrap(); + params["track"] = json!(2); + params["frame"] = json!(24); + params["seconds"] = json!(1); + let r = s.execute(command, params.clone()).unwrap(); + let clip = ClipId(r["clip"].as_u64().unwrap()); + let q = s.active_sequence().unwrap(); + let (track, it) = q.find_item(clip).unwrap(); + assert_eq!(track, q.video_tracks[2].id, "{command}"); + assert_eq!((it.start, it.duration), (Tick::from_seconds_f64(1.0), Tick::from_seconds_f64(1.0)), "{command}"); + // the same place again is taken + let e = s.execute(command, params).unwrap_err().to_string(); + assert!(e.contains("V3 is not free here"), "{command}: {e}"); + let named = if command == "graphics.newText" { "graphics.newText" } else { "graphics.newShape" }; + assert!(e.contains(named), "{command}: {e}"); + assert_eq!(s.active_sequence().unwrap().video_tracks[2].items.len(), 1, "{command}"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.active_sequence().unwrap().find_item(clip).is_none(), "{command}"); + } +} + #[test] fn new_text_makes_a_graphic_clip_above_the_footage() { let mut s = demo(); diff --git a/docs/graphics.md b/docs/graphics.md index 6d935e82..6bdf9f36 100644 --- a/docs/graphics.md +++ b/docs/graphics.md @@ -84,9 +84,9 @@ Timecode and Clip Name effects also draw with the text engine. |---|---|---| | `graphics.newText` | Graphics and Titles ▸ New Layer ▸ Text (⌘T) | `text`, `position`, `box` (`[w, h]`: paragraph text in a box, `position` is its top-left corner), `clip` (add to this graphic), `vertical`, `size`, `font`, `fontStyle`, `seconds` (5), `track`, `time` | | `graphics.newVerticalText` | New Layer ▸ Vertical Text | as `graphics.newText`; characters stack top to bottom, paragraphs are columns right to left | -| `graphics.newRectangle`, `graphics.newEllipse`, `graphics.newPolygon` | New Layer ▸ Rectangle (⌥⌘R), Ellipse (⌥⌘E), Polygon | `position`, `size`, `clip`; polygon `sides` (6) | +| `graphics.newRectangle`, `graphics.newEllipse`, `graphics.newPolygon` | New Layer ▸ Rectangle (⌥⌘R), Ellipse (⌥⌘E), Polygon | `position` (the shape's centre), `size`, `clip`, `seconds` (5), `track`, `time`; polygon `sides` (6) | | `graphics.newFromFile` | New Layer ▸ From file… | `path` — imports the image or video and places it above the clips at the playhead (a separate clip; graphics have no media layers yet) | -| `graphics.newShape` | (agents) | `shape` (rectangle/ellipse/polygon/path), `position`, `size`, `points`, `clip` | +| `graphics.newShape` | (agents) | `shape` (rectangle/ellipse/polygon/path), `position` (the shape's centre), `size`, `points`, `clip` (add to this graphic), `seconds` (5), `track`, `time` | | `graphics.setTextType` | Text Properties ▸ Text Layer Type | `clip`, `layer`, `type` (`point` / `paragraph`); the text stays where it is | | `graphics.setText` | typing on the monitor | `clip`, `layer`, `text`, `merge` (coalesce one typing session into one undo step) | | `graphics.set` | Properties panel | `clip`, `layer`, `props` {parameter id or camelCase alias: value; choices by index or name}, `time` | From 918a64317d2c1b467a7c5b6039455ea8b7f5cd51 Mon Sep 17 00:00:00 2001 From: Sawizzle <156952830+sawizzle@users.noreply.github.com> Date: Fri, 9 Oct 2026 21:50:49 -0700 Subject: [PATCH 40/64] docs: drop the third-party link; the Windows path is the default install folder Co-Authored-By: Claude Opus 5.5 --- docs/agents.md | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/docs/agents.md b/docs/agents.md index 2dbad304..80b2ffb8 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -39,20 +39,17 @@ The release packages ship `filmcraft-cli` alongside the desktop app, so no build | Install | CLI | |---|---| -| Windows (MSI) | `C:\Program Files\FilmCraft\filmcraft-cli.exe`, not added to `PATH` | +| Windows (MSI) | `C:\Program Files\FilmCraft\filmcraft-cli.exe` by default (wherever you installed it otherwise), not on `PATH` | | Linux (deb, rpm) | `/usr/bin/filmcraft-cli` | -| macOS | the separate `filmcraft-cli--macos-universal.zip` release asset (the `.app` holds only the desktop app) | +| macOS | the separate `filmcraft-cli--macos-.zip` release asset (the `.app` holds only the desktop app) | ```sh -# Windows +# Windows, default install folder claude mcp add filmcraft -- "C:\Program Files\FilmCraft\filmcraft-cli.exe" mcp # Linux, or macOS with the CLI unzipped onto PATH claude mcp add filmcraft -- filmcraft-cli mcp ``` -An unofficial community plugin, [artcraft-claude-plugin](https://github.com/sawizzle/artcraft-claude-plugin), -registers the installed ArtCraft apps in Claude Code in one step and adds usage notes for agents. - ### Tools | Tool | Modes | Purpose | From 8175b16a47528d382a273b5433c9ed2342dd33f9 Mon Sep 17 00:00:00 2001 From: lloyd Date: Sat, 10 Oct 2026 09:02:57 +0200 Subject: [PATCH 41/64] captions: captions.list with a track that names no caption track is an error `captions.list` resolved `track` with track_param and treated an unresolved selector the same as an omitted one, so a missing numeric id (or a label past the last track, such as `C3` with two tracks) returned every caption track with success. It now fails with "no such caption track", as captions.export, setTrack, setStyle, deleteTrack and add do. Omitting `track` still lists every track, and valid ids and C-labels still return just their track. Test: two caption tracks; each id and each label returns only its own track; a missing id, `C3`, `C0` and a non-label string are errors. Fixes #545 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/captions.rs | 6 +++++- crates/engine/src/captions_tests.rs | 21 +++++++++++++++++++++ docs/captions.md | 2 +- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/captions.rs b/crates/engine/src/captions.rs index 64428276..1566a596 100644 --- a/crates/engine/src/captions.rs +++ b/crates/engine/src/captions.rs @@ -513,7 +513,11 @@ pub fn commands() -> Vec { enabled: always, run: |s, p| { let Some(seq) = s.active_sequence() else { return Ok(json!({"tracks": []})) }; - let only = p.get("track").and_then(|_| track_param(s, p)); + // a `track` that names no caption track is an error, never every track + let only = match p.get("track") { + Some(_) => Some(track_param(s, p).ok_or_else(|| bad("captions.list", "no such caption track"))?), + None => None, + }; let tracks: Vec = seq.caption_tracks.iter().enumerate().filter(|(_, t)| only.is_none_or(|o| o == t.id)).map(|(i, t)| track_json(s, t, i)).collect(); Ok(json!({"tracks": tracks, "selection": s.state.caption_selection.iter().map(|c| c.0).collect::>()})) diff --git a/crates/engine/src/captions_tests.rs b/crates/engine/src/captions_tests.rs index 2167d4b7..feb65c17 100644 --- a/crates/engine/src/captions_tests.rs +++ b/crates/engine/src/captions_tests.rs @@ -137,3 +137,24 @@ fn caption_commands_need_tracks_and_navigate() { s.execute("captions.deleteTrack", json!({"track": "C1"})).unwrap(); assert!(s.active_sequence().unwrap().caption_tracks.is_empty()); } + +#[test] +fn caption_list_track_filter_never_falls_back_to_every_track() { + let mut s = demo(); + s.execute("captions.newTrack", json!({})).unwrap(); + s.execute("captions.newTrack", json!({})).unwrap(); + let all = s.execute("captions.list", json!({})).unwrap(); + let ids: Vec = all["tracks"].as_array().unwrap().iter().map(|t| t["id"].as_u64().unwrap()).collect(); + assert_eq!(ids.len(), 2, "{all}"); + for (i, id) in ids.iter().enumerate() { + for sel in [json!(id), json!(format!("C{}", i + 1))] { + let r = s.execute("captions.list", json!({"track": sel})).unwrap(); + let got: Vec = r["tracks"].as_array().unwrap().iter().map(|t| t["id"].as_u64().unwrap()).collect(); + assert_eq!(got, vec![*id], "{sel}"); + } + } + let missing = ids.iter().max().unwrap() + 999; + for sel in [json!(missing), json!("C3"), json!("C0"), json!("nope")] { + assert!(s.execute("captions.list", json!({"track": sel})).is_err(), "{sel} names no caption track"); + } +} diff --git a/docs/captions.md b/docs/captions.md index 12564ddd..fd76483e 100644 --- a/docs/captions.md +++ b/docs/captions.md @@ -52,7 +52,7 @@ Details of each format and the SCC encoder are in [`crates/captions/README.md`]( | `captions.setStyle` | Text panel style strip | `track`, `size`, `color`, `background`, `backgroundColor`, `outline`, `outlineColor`, `align`, `anchor`, `margin`, `lineSpacing`, `reset` | | `captions.deleteTrack` | | `track` | | `captions.import` / `captions.export` | File ▸ Export ▸ Captions… | `path`, `format`, `track`, `dropFrame` | -| `captions.list` | (query) | `track` — tracks, styles and captions with timecodes | +| `captions.list` | (query) | `track` — tracks, styles and captions with timecodes (a `track` that names no caption track is an error) | Tracks are addressed by id or `"C1"`, `"C2"`… (top first). Edits are limited by neighbouring captions and a one-frame minimum. Sync-locked caption tracks follow insert and extract edits on From 2c049fe89330f436fdb09caceaaff8431f7ffa05 Mon Sep 17 00:00:00 2001 From: Austin <20157814+adurr21@users.noreply.github.com> Date: Sat, 10 Oct 2026 03:03:03 -0400 Subject: [PATCH 42/64] ui-egui: fix Light theme chrome inconsistencies (#150) Route audio controls, meters, effect graphs, timeline controls, caption lanes, and effect badges through theme tokens so Light mode no longer retains dark-only surfaces. Add palette regression coverage and a three-theme mixer harness test while preserving Dark and Medium colors. Co-Authored-By: Codex --- crates/ui-egui/src/panels/audio_fx_editor.rs | 27 +-- crates/ui-egui/src/panels/effect_controls.rs | 18 +- crates/ui-egui/src/panels/effects.rs | 2 +- crates/ui-egui/src/panels/essential_sound.rs | 24 +-- crates/ui-egui/src/panels/meters.rs | 4 +- crates/ui-egui/src/panels/mixer.rs | 63 ++++-- crates/ui-egui/src/panels/timeline.rs | 15 +- .../ui-egui/src/panels/timeline_captions.rs | 5 +- crates/ui-egui/src/theme.rs | 184 ++++++++++++++++++ crates/ui-egui/tests/mixer_ui.rs | 13 ++ 10 files changed, 287 insertions(+), 68 deletions(-) diff --git a/crates/ui-egui/src/panels/audio_fx_editor.rs b/crates/ui-egui/src/panels/audio_fx_editor.rs index fbbde839..bccdd0ad 100644 --- a/crates/ui-egui/src/panels/audio_fx_editor.rs +++ b/crates/ui-egui/src/panels/audio_fx_editor.rs @@ -253,10 +253,10 @@ fn response_plot(ui: &mut egui::Ui, ed: &mut Ed, size: egui::Vec2, range: f64) - let (r, resp) = ui.allocate_exact_size(size, Sense::click_and_drag()); let p = ui.painter_at(r); let t = &ed.t; - p.rect_filled(r, 2.0, Color32::from_gray(0x18)); + p.rect_filled(r, 2.0, ed.t.plot_bg); for f in [50.0, 100.0, 200.0, 500.0, 1000.0, 2000.0, 5000.0, 10000.0] { let x = x_of(f, r); - p.line_segment([pos2(x, r.min.y), pos2(x, r.max.y)], Stroke::new(1.0, Color32::from_gray(0x2c))); + p.line_segment([pos2(x, r.min.y), pos2(x, r.max.y)], Stroke::new(1.0, ed.t.plot_grid)); let label = if f >= 1000.0 { format!("{}k", f / 1000.0) } else { format!("{f}") }; p.text(pos2(x + 2.0, r.max.y - 2.0), Align2::LEFT_BOTTOM, label, Tokens::ui(9.0), t.text_faint); } @@ -264,7 +264,7 @@ fn response_plot(ui: &mut egui::Ui, ed: &mut Ed, size: egui::Vec2, range: f64) - let mut d = -range; while d <= range + 1e-9 { let y = y_of(d, r, range); - let col = if d.abs() < 1e-9 { Color32::from_gray(0x44) } else { Color32::from_gray(0x2c) }; + let col = if d.abs() < 1e-9 { ed.t.plot_axis } else { ed.t.plot_grid }; p.line_segment([pos2(r.min.x, y), pos2(r.max.x, y)], Stroke::new(1.0, col)); p.text(pos2(r.min.x + 2.0, y - 1.0), Align2::LEFT_BOTTOM, format!("{d:+.0}"), Tokens::ui(9.0), t.text_faint); d += step; @@ -310,7 +310,7 @@ fn parametric(ui: &mut egui::Ui, ed: &mut Ed) { let id = egui::Id::new(("peq-node", ed.target.clone(), pre)); let resp = ui.interact(nr, id, Sense::click_and_drag()); let on = ed.on(&on_id); - let col = if on { Color32::from_rgb(0xe8, 0xe8, 0xe8) } else { Color32::from_gray(0x70) }; + let col = if on { ed.t.eq_node } else { ed.t.eq_node_off }; ui.painter().circle_stroke(c, 7.0, Stroke::new(1.5, col)); ui.painter().text(c, Align2::CENTER_CENTER, label, Tokens::ui(8.5), col); ed.auto(format!("fxEditor.{}.node.{pre}", ed.fx), nr, label); @@ -374,14 +374,14 @@ fn graphic(ui: &mut egui::Ui, ed: &mut Ed) { let pid = format!("b{}", i + 1); let x = area.min.x + colw * (i as f32 + 0.5); let track = Rect::from_center_size(pos2(x, area.min.y + 70.0), vec2(6.0, 130.0)); - ui.painter().rect_filled(track, 2.0, Color32::from_gray(0x30)); - ui.painter().line_segment([pos2(x - 5.0, track.center().y), pos2(x + 5.0, track.center().y)], Stroke::new(1.0, Color32::from_gray(0x60))); + ui.painter().rect_filled(track, 2.0, ed.t.eq_track); + ui.painter().line_segment([pos2(x - 5.0, track.center().y), pos2(x + 5.0, track.center().y)], Stroke::new(1.0, ed.t.eq_tick)); let v = ed.v(&pid); let ky = track.center().y - (v / 24.0) as f32 * track.height() * 0.5; let knob = Rect::from_center_size(pos2(x, ky), vec2(colw.min(18.0), 8.0)); let id = egui::Id::new(("geq-band", ed.target.clone(), i)); let resp = ui.interact(knob.union(track).expand(3.0), id, Sense::click_and_drag()); - ui.painter().rect_filled(knob, 2.0, if resp.hovered() || resp.dragged() { Color32::from_gray(0xf0) } else { Color32::from_gray(0xc0) }); + ui.painter().rect_filled(knob, 2.0, if resp.hovered() || resp.dragged() { ed.t.eq_knob_active } else { ed.t.eq_knob }); ui.painter().text(pos2(x, area.max.y - 22.0), Align2::CENTER_CENTER, format!("{v:+.0}"), Tokens::ui(8.5), ed.t.text_dim); let short = label.replace(" Hz", "").replace(" kHz", "k"); ui.painter().text(pos2(x, area.max.y - 8.0), Align2::CENTER_CENTER, short, Tokens::ui(8.5), ed.t.text_faint); @@ -420,14 +420,14 @@ fn graphic(ui: &mut egui::Ui, ed: &mut Ed) { fn transfer_plot(ui: &mut egui::Ui, ed: &mut Ed, size: f32, band: usize, auto: String) { let (r, _) = ui.allocate_exact_size(vec2(size, size), Sense::hover()); let p = ui.painter_at(r); - p.rect_filled(r, 2.0, Color32::from_gray(0x18)); + p.rect_filled(r, 2.0, ed.t.plot_bg); let lo = -60.0; let map = |x: f64, y: f64| pos2(r.min.x + ((x - lo) / -lo) as f32 * r.width(), r.max.y - (((y - lo) / -lo).clamp(-0.05, 1.05)) as f32 * r.height()); for d in [-48.0, -36.0, -24.0, -12.0] { - p.line_segment([map(d, lo), map(d, 0.0)], Stroke::new(1.0, Color32::from_gray(0x2c))); - p.line_segment([map(lo, d), map(0.0, d)], Stroke::new(1.0, Color32::from_gray(0x2c))); + p.line_segment([map(d, lo), map(d, 0.0)], Stroke::new(1.0, ed.t.plot_grid)); + p.line_segment([map(lo, d), map(0.0, d)], Stroke::new(1.0, ed.t.plot_grid)); } - p.line_segment([map(lo, lo), map(0.0, 0.0)], Stroke::new(1.0, Color32::from_gray(0x44))); + p.line_segment([map(lo, lo), map(0.0, 0.0)], Stroke::new(1.0, ed.t.plot_axis)); if let Some(dsp) = filmcraft_render::audio_fx::configured(&ed.preview(), ed.mt, 48000) { let pts: Vec = (0..=120) .filter_map(|i| { @@ -447,7 +447,7 @@ fn transfer_plot(ui: &mut egui::Ui, ed: &mut Ed, size: f32, band: usize, auto: S fn multiband(ui: &mut egui::Ui, ed: &mut Ed) { // Spectrum strip with draggable crossover handles. let (r, _) = ui.allocate_exact_size(vec2(640.0, 70.0), Sense::hover()); - ui.painter().rect_filled(r, 2.0, Color32::from_gray(0x18)); + ui.painter().rect_filled(r, 2.0, ed.t.plot_bg); let cols = [Color32::from_rgb(0x3d, 0x6e, 0xb4), Color32::from_rgb(0x3d, 0xa0, 0x6e), Color32::from_rgb(0xb4, 0x9a, 0x3d), Color32::from_rgb(0xb4, 0x4f, 0x3d)]; let xo = [ed.v("xo1"), ed.v("xo2"), ed.v("xo3")]; @@ -461,7 +461,8 @@ fn multiband(ui: &mut egui::Ui, ed: &mut Ed) { let x = edges[k + 1]; let hr = Rect::from_center_size(pos2(x, r.center().y), vec2(10.0, r.height())); let resp = ui.interact(hr, egui::Id::new(("mb-xo", ed.target.clone(), k)), Sense::drag()); - ui.painter().line_segment([pos2(x, r.min.y), pos2(x, r.max.y)], Stroke::new(if resp.hovered() || resp.dragged() { 2.5 } else { 1.5 }, Color32::WHITE)); + ui.painter() + .line_segment([pos2(x, r.min.y), pos2(x, r.max.y)], Stroke::new(if resp.hovered() || resp.dragged() { 2.5 } else { 1.5 }, ed.t.crossover_handle)); ui.painter().text(pos2(x + 3.0, r.min.y + 2.0), Align2::LEFT_TOP, format!("{:.0} Hz", xo[k]), Tokens::ui(9.0), ed.t.text_dim); ed.auto(format!("fxEditor.{}.{pid}", ed.fx), hr, "Crossover"); if resp.dragged() diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index e3ae049c..9de1dc52 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -57,7 +57,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let head = Rect::from_min_size(rect.min + vec2(8.0, 4.0), vec2(split - rect.min.x - 12.0, 24.0)); // Premiere: two pill tabs — "Source · clip" and "Sequence · clip" (active) let pill = |ui: &mut egui::Ui, r: Rect, text: &str, active: bool| { - ui.painter().rect_filled(r, 4.0, if active { Color32::from_rgb(0x3a, 0x3a, 0x3a) } else { t.panel_bg }); + ui.painter().rect_filled(r, 4.0, if active { t.pill_active_bg } else { t.panel_bg }); if !active { ui.painter().rect_stroke(r, 4.0, Stroke::new(1.0, t.separator), egui::StrokeKind::Inside); } @@ -78,7 +78,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let ruler = Rect::from_min_max(lane.min, pos2(lane.max.x, lane.min.y + RULER_H)); paint_ruler(ui.painter(), ruler, &it, rate, seq.settings.drop_frame, &t); let bar = Rect::from_min_max(pos2(lane.min.x, ruler.max.y + 4.0), pos2(lane.max.x, ruler.max.y + 4.0 + CLIP_BAR_H)); - ui.painter().rect_filled(bar.shrink2(vec2(0.0, 2.0)), 2.0, Color32::from_rgb(58, 58, 70)); + ui.painter().rect_filled(bar.shrink2(vec2(0.0, 2.0)), 2.0, t.clip_bar_bg); ui.painter().with_clip_rect(bar).text(pos2(bar.min.x + 4.0, bar.center().y), Align2::LEFT_CENTER, &it.name, Tokens::ui(10.0), t.text); // click or drag anywhere on the ruler (or the bar) to move the playhead let scrub = Rect::from_min_max(ruler.min, bar.max); @@ -892,10 +892,10 @@ pub(crate) fn graph_rows( let y_of = |v: f64| area.max.y - ((v - lo) / (hi - lo)) as f32 * area.height(); let v_of = |y: f32| lo + ((area.max.y - y) / area.height()) as f64 * (hi - lo); let p = ui.painter(); - p.rect_filled(area, 0.0, Color32::from_rgb(0x19, 0x19, 0x19)); + p.rect_filled(area, 0.0, t.keyframe_plot_bg); for g in 1..4 { let y = area.min.y + area.height() * g as f32 / 4.0; - p.line_segment([pos2(area.min.x, y), pos2(area.max.x, y)], Stroke::new(1.0, Color32::from_rgb(0x2a, 0x2a, 0x2a))); + p.line_segment([pos2(area.min.x, y), pos2(area.max.x, y)], Stroke::new(1.0, t.keyframe_plot_grid)); } // range labels in the property column let dec = if let ParamKind::Float { decimals, .. } = pd.kind { decimals as usize } else { 1 }; @@ -909,9 +909,9 @@ pub(crate) fn graph_rows( let vel: Vec = (0..n).map(|i| (vals[i + 1] - vals[i]) / secs.max(1e-9)).collect(); let vmax = vel.iter().fold(1e-6f64, |a, v| a.max(v.abs())) * 1.15; let varea = Rect::from_min_max(pos2(lane.min.x, velr.min.y + 2.0), pos2(lane.max.x, velr.max.y - 4.0)); - p.rect_filled(varea, 0.0, Color32::from_rgb(0x19, 0x19, 0x19)); + p.rect_filled(varea, 0.0, t.keyframe_plot_bg); let vy = |v: f64| varea.center().y - (v / vmax) as f32 * varea.height() / 2.0; - p.line_segment([pos2(varea.min.x, varea.center().y), pos2(varea.max.x, varea.center().y)], Stroke::new(1.0, Color32::from_rgb(0x33, 0x33, 0x33))); + p.line_segment([pos2(varea.min.x, varea.center().y), pos2(varea.max.x, varea.center().y)], Stroke::new(1.0, t.keyframe_plot_axis)); let vline: Vec = vel.iter().enumerate().map(|(i, v)| pos2(x_of((i as f32 + 0.5) / n as f32), vy(*v))).collect(); p.add(egui::Shape::line(vline, Stroke::new(1.2, Color32::from_rgb(0xd0, 0xa0, 0x40)))); p.text(pos2(velr.min.x + 44.0, varea.center().y), Align2::LEFT_CENTER, tl!("Velocity"), Tokens::ui(11.0), t.text_dim); @@ -944,8 +944,8 @@ pub(crate) fn graph_rows( let hdx: f32 = ui.data(|d| d.get_temp(hid)).unwrap_or(0.0); let hx = c.x + side * (infl * seg + hdx * side).clamp(seg * 0.01, seg); let hp = pos2(hx, c.y); - p.line_segment([c, hp], Stroke::new(1.0, Color32::from_rgb(0x90, 0x90, 0x90))); - p.circle_filled(hp, 3.5, Color32::from_rgb(0xd0, 0xd0, 0xd0)); + p.line_segment([c, hp], Stroke::new(1.0, t.plot_handle_dim)); + p.circle_filled(hp, 3.5, t.keyframe_handle); let hr = ui.interact(Rect::from_center_size(hp, vec2(10.0, 10.0)), hid.with("h"), Sense::drag()); if hr.dragged() { let nx = hdx + hr.drag_delta().x; @@ -958,7 +958,7 @@ pub(crate) fn graph_rows( actions.push(("effects.setKeyframe".into(), with_mask(json!({"clip": clip.0, "effect": idx, "param": pd.id, "mediaTime": k.time.0, key: ni})))); } } - p.circle_filled(c, 4.5, if resp.dragged() { t.hot_text } else { Color32::from_rgb(0xe0, 0xe0, 0xe0) }); + p.circle_filled(c, 4.5, if resp.dragged() { t.hot_text } else { t.plot_handle }); if resp.dragged() { let ny = dy + resp.drag_delta().y; ui.data_mut(|d| d.insert_temp(id, ny)); diff --git a/crates/ui-egui/src/panels/effects.rs b/crates/ui-egui/src/panels/effects.rs index ef99ad1c..76f4f8c2 100644 --- a/crates/ui-egui/src/panels/effects.rs +++ b/crates/ui-egui/src/panels/effects.rs @@ -101,7 +101,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { for (on, label) in [(d.yuv, "YUV"), (d.float32, "32"), (d.accelerated, "⚡")] { if on { let br = Rect::from_min_size(pos2(bx - 22.0, r.min.y + 3.0), vec2(20.0, 14.0)); - ui.painter().rect_filled(br, 2.0, Color32::from_rgb(48, 48, 48)); + ui.painter().rect_filled(br, 2.0, t.field_border); ui.painter().text(br.center(), Align2::CENTER_CENTER, label, Tokens::ui(8.5), t.text_dim); bx -= 24.0; } diff --git a/crates/ui-egui/src/panels/essential_sound.rs b/crates/ui-egui/src/panels/essential_sound.rs index 934a638b..958594db 100644 --- a/crates/ui-egui/src/panels/essential_sound.rs +++ b/crates/ui-egui/src/panels/essential_sound.rs @@ -360,7 +360,7 @@ fn ducking(ui: &mut egui::Ui, cx: &mut Ctx, st: &EssentialSound, on: bool) { br, 4.0, if sel { - Color32::from_gray(0x4b) + cx.t.pressed } else if resp.hovered() { t.hover } else { @@ -387,7 +387,7 @@ fn ducking(ui: &mut egui::Ui, cx: &mut Ctx, st: &EssentialSound, on: bool) { br, 4.0, if sel { - Color32::from_gray(0x4b) + cx.t.pressed } else if resp.hovered() { t.hover } else { @@ -467,11 +467,11 @@ fn switch(ui: &mut egui::Ui, cx: &mut Ctx, r: Rect, on: bool, id: &str, label: & let p = ui.painter(); let rad = r.height() / 2.0; if on { - p.rect_filled(r, rad, Color32::from_gray(0xd4)); - p.circle_filled(pos2(r.max.x - rad, r.center().y), rad - 3.0, Color32::from_gray(0x1d)); + p.rect_filled(r, rad, cx.t.switch_fill); + p.circle_filled(pos2(r.max.x - rad, r.center().y), rad - 3.0, cx.t.switch_knob); } else { - p.rect_stroke(r, rad, Stroke::new(1.5, Color32::from_gray(if resp.hovered() { 0xb0 } else { 0x8a })), StrokeKind::Inside); - p.circle_stroke(pos2(r.min.x + rad, r.center().y), rad - 3.0, Stroke::new(1.5, Color32::from_gray(0xd4))); + p.rect_stroke(r, rad, Stroke::new(1.5, if resp.hovered() { cx.t.control_handle_dim } else { cx.t.switch_border }), StrokeKind::Inside); + p.circle_stroke(pos2(r.min.x + rad, r.center().y), rad - 3.0, Stroke::new(1.5, cx.t.switch_fill)); } cx.auto.add(id, r, label); resp.clicked() @@ -521,7 +521,7 @@ fn slider_track(ui: &mut egui::Ui, cx: &mut Ctx, track: Rect, key: &str, v: f64, let f = ((v - lo) / (hi - lo)).clamp(0.0, 1.0) as f32; let kx = track.min.x + f * track.width(); let resp = ui.interact(track.expand2(vec2(6.0, 4.0)), egui::Id::new(("es-slider", key)), Sense::click_and_drag()); - let line = if active { Color32::from_gray(0x8a) } else { Color32::from_gray(0x4a) }; + let line = if active { cx.t.switch_border } else { cx.t.slider_disabled_track }; ui.painter().line_segment([pos2(track.min.x, y), pos2(kx - 7.0, y)], Stroke::new(1.5, line)); ui.painter().line_segment([pos2(kx + 7.0, y), pos2(track.max.x, y)], Stroke::new(1.5, line)); ui.painter().circle_filled(pos2(kx, y), 6.0, t.panel_bg); @@ -533,9 +533,9 @@ fn slider_track(ui: &mut egui::Ui, cx: &mut Ctx, track: Rect, key: &str, v: f64, if resp.dragged() { t.hot_text } else if active { - Color32::from_gray(0xd4) + cx.t.switch_fill } else { - Color32::from_gray(0x6a) + cx.t.slider_disabled_knob }, ), ); @@ -587,7 +587,7 @@ fn choice_row(ui: &mut egui::Ui, cx: &mut Ctx, key: &str, opts: &[&str], sel: us br, 4.0, if on { - Color32::from_gray(0x4b) + cx.t.pressed } else if resp.hovered() && active { t.hover } else { @@ -605,8 +605,8 @@ fn choice_row(ui: &mut egui::Ui, cx: &mut Ctx, key: &str, opts: &[&str], sel: us fn button(ui: &mut egui::Ui, cx: &mut Ctx, r: Rect, text: &str, id: &str) -> bool { let t = cx.t; let resp = ui.interact(r, egui::Id::new(("es-btn", id)), Sense::click()); - ui.painter().rect_filled(r, 4.0, if resp.hovered() { Color32::from_gray(0x2a) } else { t.field_bg }); - ui.painter().rect_stroke(r, 4.0, Stroke::new(1.0, Color32::from_gray(0x4b)), StrokeKind::Inside); + ui.painter().rect_filled(r, 4.0, if resp.hovered() { cx.t.button_hover } else { t.field_bg }); + ui.painter().rect_stroke(r, 4.0, Stroke::new(1.0, cx.t.button_border), StrokeKind::Inside); ui.painter().text(r.center(), Align2::CENTER_CENTER, text, Tokens::ui(12.0), t.text); cx.auto.add(id, r, text); resp.clicked() diff --git a/crates/ui-egui/src/panels/meters.rs b/crates/ui-egui/src/panels/meters.rs index b542e469..03555f2d 100644 --- a/crates/ui-egui/src/panels/meters.rs +++ b/crates/ui-egui/src/panels/meters.rs @@ -61,7 +61,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { (l.momentary(), l.short_term(), l.integrated(), l.true_peak_dbtp()) }); let lr = Rect::from_min_max(pos2(rect.min.x + 4.0, rect.max.y - lufs_h), pos2(rect.max.x - 4.0, rect.max.y - 4.0)); - ui.painter().rect_filled(lr, 2.0, egui::Color32::from_rgb(0x14, 0x14, 0x14)); + ui.painter().rect_filled(lr, 2.0, t.meter_readout_bg); let rows = [("M", lufs_text(m)), ("S", lufs_text(s)), ("I", lufs_text(i)), ("TP", lufs_text(tp))]; for (k, (label, val)) in rows.iter().enumerate() { let y = lr.min.y + 8.0 + k as f32 * 14.0; @@ -84,7 +84,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } app.auto.add("audioMeters.loudness", lr, &format!("M {} S {} I {} LUFS TP {} dBTP", lufs_text(m), lufs_text(s), lufs_text(i), lufs_text(tp))); } - ui.painter().rect_filled(Rect::from_min_max(pos2(rect.min.x + 4.0, rect.min.y + 4.0), pos2(rect.max.x - 4.0, area.max.y + 4.0)), 0.0, egui::Color32::BLACK); + ui.painter().rect_filled(Rect::from_min_max(pos2(rect.min.x + 4.0, rect.min.y + 4.0), pos2(rect.max.x - 4.0, area.max.y + 4.0)), 0.0, t.meter_bg); let w = ((area.width() - 4.0 * (nch as f32 - 1.0)) / nch as f32).max(3.0); for c in 0..nch { let r = Rect::from_min_size(pos2(area.min.x + c as f32 * (w + 4.0), area.min.y), vec2(w, area.height())); diff --git a/crates/ui-egui/src/panels/mixer.rs b/crates/ui-egui/src/panels/mixer.rs index c5bfe64f..db2c92d7 100644 --- a/crates/ui-egui/src/panels/mixer.rs +++ b/crates/ui-egui/src/panels/mixer.rs @@ -128,7 +128,7 @@ pub fn poll_meters(app: &mut FilmcraftApp, ui: &egui::Ui) -> HashMap 2 { 1.0 } else { 2.0 }; let w = (r.width() - gap * (n as f32 - 1.0)) / n as f32; @@ -138,7 +138,7 @@ fn draw_meters(ui: &egui::Ui, r: Rect, m: &[[f32; 2]], channels: usize, t: &Toke crate::widgets::meter_bar(p, br, v[0], v[1], t); // clip light let lr = Rect::from_min_size(pos2(br.min.x, r.min.y), vec2(w, 4.0)); - p.rect_filled(lr, 0.0, if v[1] >= -0.1 { RED } else { Color32::from_gray(0x30) }); + p.rect_filled(lr, 0.0, if v[1] >= -0.1 { RED } else { t.meter_clip_off }); } } @@ -305,7 +305,7 @@ pub fn track_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let ky = bar.min.y + (bar.height() - kh) * voff / max_voff; let knob = Rect::from_min_size(pos2(bar.min.x, ky), vec2(bar.width(), kh)); let kr = ui.interact(knob, egui::Id::new("mixer-vscroll-knob"), Sense::drag()); - ui.painter().rect_filled(knob, 3.0, if kr.dragged() { t.accent } else { Color32::from_gray(0x5a) }); + ui.painter().rect_filled(knob, 3.0, if kr.dragged() { t.accent } else { t.fader_knob }); if kr.dragged() { voff = (voff + kr.drag_delta().y * (need + 4.0) / bar.height()).clamp(0.0, max_voff); } @@ -319,7 +319,7 @@ pub fn track_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let kx = bar.min.x + (bar.width() - kw) * off / max_off; let knob = Rect::from_min_size(pos2(kx, bar.min.y), vec2(kw, bar.height())); let kr = ui.interact(knob, egui::Id::new("mixer-hscroll-knob"), Sense::drag()); - ui.painter().rect_filled(knob, 4.0, if kr.dragged() { t.accent } else { Color32::from_gray(0x5a) }); + ui.painter().rect_filled(knob, 4.0, if kr.dragged() { t.accent } else { t.fader_knob }); if kr.dragged() { off = (off + kr.drag_delta().x * total / bar.width()).clamp(0.0, max_off); } @@ -369,7 +369,7 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s let ap = format!("mixer.{label}"); let is_master = id == MASTER_STRIP; let sid = json!(id.0); - ui.painter().rect_filled(sr, 3.0, Color32::from_gray(0x24)); + ui.painter().rect_filled(sr, 3.0, t.control_surface); ui.painter().line_segment([pos2(sr.max.x + 1.0, sr.min.y), pos2(sr.max.x + 1.0, sr.max.y)], Stroke::new(1.0, t.separator)); let x = sr.min.x; let w = sr.width(); @@ -581,7 +581,7 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s let kc = pos2(sr.center().x, y + 20.0); let kr = Rect::from_center_size(kc, vec2(38.0, 38.0)); let kresp = ui.interact(kr, egui::Id::new((&ap, "pan")), Sense::click_and_drag()).on_hover_text(tl!("Pan / balance (drag; double-click: centre)")); - let ring = if kresp.hovered() || kresp.dragged() { t.tab_text_active } else { Color32::from_gray(0xb0) }; + let ring = if kresp.hovered() || kresp.dragged() { t.tab_text_active } else { t.control_handle_dim }; ui.painter().circle_stroke(kc, 16.0, Stroke::new(2.0, ring)); let ang = (pan / 100.0) as f32 * 135f32.to_radians(); ui.painter().line_segment([kc, kc + vec2(ang.sin(), -ang.cos()) * 14.0], Stroke::new(2.0, ring)); @@ -690,16 +690,16 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s small_text(ui, pos2(x + 26.0, yy), Align2::RIGHT_CENTER, &s, 9.0, t.text_dim); ui.painter().line_segment([pos2(x + 28.0, yy), pos2(x + 31.0, yy)], Stroke::new(1.0, t.text_faint)); } - ui.painter().rect_filled(track, 2.0, Color32::from_gray(0x10)); - ui.painter().rect_stroke(track, 2.0, Stroke::new(1.0, Color32::from_gray(0x50)), StrokeKind::Inside); + ui.painter().rect_filled(track, 2.0, t.control_well); + ui.painter().rect_stroke(track, 2.0, Stroke::new(1.0, t.control_border), StrokeKind::Inside); let vol = shown(app, &tr, LANE_VOLUME, cx.now); let cy = ypos(vol); let cap = Rect::from_center_size(pos2(track.center().x, cy), vec2(18.0, 26.0)); let fresp = ui.interact(cap.expand(3.0), egui::Id::new((&ap, "fader")), Sense::click_and_drag()).on_hover_text(tl!("Volume (drag; double-click: 0 dB)")); let held = app.session.previews.live.get(id, LANE_VOLUME).is_some(); - let col = if fresp.dragged() || held { t.accent } else { Color32::from_gray(0xe0) }; - ui.painter().rect_filled(cap, 3.0, Color32::from_gray(0x2a)); + let col = if fresp.dragged() || held { t.accent } else { t.control_handle }; + ui.painter().rect_filled(cap, 3.0, t.control_handle_bg); ui.painter().rect_stroke(cap, 3.0, Stroke::new(2.0, col), StrokeKind::Inside); ui.painter().line_segment([pos2(cap.min.x + 5.0, cy), pos2(cap.max.x - 5.0, cy)], Stroke::new(2.0, col)); app.auto.add(&format!("{ap}.fader"), cap, &format!("Volume {}", db_text(vol))); @@ -769,14 +769,14 @@ fn pan51_panner(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, tr: &Tr let resp = ui.interact(sq, egui::Id::new((ap, "pan51")), Sense::click_and_drag()).on_hover_text(tl!("5.1 panner (drag the puck; double-click: front centre)")); let p = ui.painter(); - p.rect_filled(sq, 3.0, Color32::from_gray(0x16)); - p.rect_stroke(sq, 3.0, Stroke::new(1.0, if resp.hovered() || resp.dragged() { t.tab_text_active } else { Color32::from_gray(0x60) }), StrokeKind::Inside); + p.rect_filled(sq, 3.0, t.panner_bg); + p.rect_stroke(sq, 3.0, Stroke::new(1.0, if resp.hovered() || resp.dragged() { t.tab_text_active } else { t.panner_border }), StrokeKind::Inside); let inner = sq.shrink(5.0); let to_pos = |x: f64, y: f64| pos2(inner.center().x + (x / 100.0) as f32 * inner.width() / 2.0, inner.center().y - (y / 100.0) as f32 * inner.height() / 2.0); // speakers for (x, y) in [(-100.0, 100.0), (0.0, 100.0), (100.0, 100.0), (-100.0, -100.0), (100.0, -100.0)] { - p.rect_filled(Rect::from_center_size(to_pos(x, y), vec2(4.0, 4.0)), 1.0, Color32::from_gray(0x90)); + p.rect_filled(Rect::from_center_size(to_pos(x, y), vec2(4.0, 4.0)), 1.0, t.panner_speaker); } let puck = to_pos(px, py); p.circle_filled(puck, 4.0, if resp.dragged() { t.accent } else { t.hot_text }); @@ -900,7 +900,7 @@ pub fn clip_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let sr = Rect::from_min_max(pos2(x, rect.min.y + 4.0), pos2(x + STRIP_W, rect.max.y - 4.0)); let label = format!("A{}", i + 1); let ap = format!("clipMixer.{label}"); - ui.painter().rect_filled(sr, 3.0, Color32::from_gray(0x24)); + ui.painter().rect_filled(sr, 3.0, t.control_surface); let clip = tr.item_at(now).filter(|c| c.enabled); let active = clip.is_some(); let dimc = |c: Color32| if active { c } else { c.gamma_multiply(0.4) }; @@ -930,9 +930,9 @@ pub fn clip_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let kc = pos2(sr.center().x, sr.min.y + 36.0); let kr = Rect::from_center_size(kc, vec2(30.0, 30.0)); let kresp = ui.interact(kr, egui::Id::new((&ap, "pan")), Sense::click_and_drag()); - ui.painter().circle_stroke(kc, 14.0, Stroke::new(2.0, dimc(Color32::from_gray(0xb0)))); + ui.painter().circle_stroke(kc, 14.0, Stroke::new(2.0, dimc(t.control_handle_dim))); let ang = (pan / 100.0) as f32 * 135f32.to_radians(); - ui.painter().line_segment([kc, kc + vec2(ang.sin(), -ang.cos()) * 12.0], Stroke::new(2.0, dimc(Color32::from_gray(0xb0)))); + ui.painter().line_segment([kc, kc + vec2(ang.sin(), -ang.cos()) * 12.0], Stroke::new(2.0, dimc(t.control_handle_dim))); small_text(ui, pos2(kc.x - 18.0, kc.y + 16.0), Align2::CENTER_CENTER, "L", 11.0, dimc(t.hot_text)); small_text(ui, pos2(kc.x + 18.0, kc.y + 16.0), Align2::CENTER_CENTER, "R", 11.0, dimc(t.hot_text)); small_text(ui, pos2(kc.x, kc.y + 26.0), Align2::CENTER_CENTER, &format!("{pan:.1}"), 11.5, dimc(t.hot_text)); @@ -989,13 +989,13 @@ pub fn clip_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let s = if d <= FADER_MIN_DB { "-∞".to_string() } else { format!("{}", d as i32) }; small_text(ui, pos2(sr.min.x + 26.0, ypos(d)), Align2::RIGHT_CENTER, &s, 9.0, t.text_dim); } - ui.painter().rect_filled(track, 2.0, Color32::from_gray(0x10)); + ui.painter().rect_filled(track, 2.0, t.control_well); let cy = ypos(vol); let cap = Rect::from_center_size(pos2(track.center().x, cy), vec2(18.0, 26.0)); let fresp = ui.interact(cap.expand(3.0), egui::Id::new((&ap, "fader")), Sense::click_and_drag()); - ui.painter().rect_filled(cap, 3.0, Color32::from_gray(0x2a)); - ui.painter().rect_stroke(cap, 3.0, Stroke::new(2.0, dimc(Color32::from_gray(0xe0))), StrokeKind::Inside); - ui.painter().line_segment([pos2(cap.min.x + 5.0, cy), pos2(cap.max.x - 5.0, cy)], Stroke::new(2.0, dimc(Color32::from_gray(0xe0)))); + ui.painter().rect_filled(cap, 3.0, t.control_handle_bg); + ui.painter().rect_stroke(cap, 3.0, Stroke::new(2.0, dimc(t.control_handle)), StrokeKind::Inside); + ui.painter().line_segment([pos2(cap.min.x + 5.0, cy), pos2(cap.max.x - 5.0, cy)], Stroke::new(2.0, dimc(t.control_handle))); app.auto.add(&format!("{ap}.fader"), cap, &format!("Clip volume {}", db_text(vol))); if fresp.dragged() && active && fresp.drag_delta().y != 0.0 { set(&mut acts, "volume", "level", pos_to_db(db_to_pos(vol) - fresp.drag_delta().y / track.height()), fresp.drag_started()); @@ -1023,6 +1023,29 @@ pub fn clip_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { mod tests { use super::*; + #[test] + fn meter_well_and_inactive_clip_light_follow_theme() { + for kind in [crate::theme::ThemeKind::Dark, crate::theme::ThemeKind::Medium, crate::theme::ThemeKind::Light] { + let t = Tokens::for_kind(kind); + let ctx = egui::Context::default(); + let area = Rect::from_min_size(pos2(20.0, 20.0), vec2(40.0, 100.0)); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| draw_meters(ui, area, &[[-90.0; 2]], 1, &t)); + out.textures_delta.clear(); + let rectangles: Vec<_> = out + .shapes + .iter() + .filter_map(|s| match &s.shape { + egui::Shape::Rect(r) => Some(r), + _ => None, + }) + .collect(); + let well = rectangles.iter().find(|r| r.rect == area).expect("painted meter well"); + assert_eq!(well.fill, t.meter_bg, "meter well did not follow {kind:?}"); + let light = rectangles.iter().find(|r| r.rect.height() == 4.0).expect("painted clip light"); + assert_eq!(light.fill, t.meter_clip_off, "clip light did not follow {kind:?}"); + } + } + #[test] fn taper_roundtrips_and_is_monotonic() { let mut last = f64::NEG_INFINITY; diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 811a75ee..6f8793c4 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -586,7 +586,7 @@ fn draw_clip( let base = label_color(app, it.label); let inr = in_range(app, it); let fill = if !it.enabled { - Color32::from_rgb(0x2a, 0x2a, 0x2a) + t.disabled_clip_bg } else if inr { lighten(base, 0.12) } else if selected { @@ -981,7 +981,7 @@ fn draw_headers(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, rows: true, t, egui::Id::new(("locked", r.track.0)), - Some(if tr.locked { Color32::from_rgb(0xd1, 0xd1, 0xd1) } else { t.text_dim }), + Some(if tr.locked { t.icon_active } else { t.text_dim }), ); app.auto.add(&format!("timeline.track.{label}.locked"), lock_r, "Toggle Track Lock"); if lresp.clicked() { @@ -989,7 +989,7 @@ fn draw_headers(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, rows: } if tr.locked { // diagonal hatch over locked lanes is drawn by the lane painter; here a subtle tint - p.rect_filled(Rect::from_min_max(pos2(hrect.max.x - 4.0, hrect.min.y), hrect.max), 0.0, Color32::from_rgb(0x4b, 0x4b, 0x4b)); + p.rect_filled(Rect::from_min_max(pos2(hrect.max.x - 4.0, hrect.min.y), hrect.max), 0.0, t.pressed); } // 3. target let targeted = tg.targeted.contains(&r.track); @@ -1126,7 +1126,7 @@ fn draw_top(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq: &Sequenc let resp = ui.interact(r, egui::Id::new(("tl-toggle", key)), Sense::click()).on_hover_text(tip); app.auto.add(&format!("timeline.toggle.{key}"), r, tip); if toggle && on { - p.rect_filled(r, 4.0, Color32::from_rgb(0x4b, 0x4b, 0x4b)); + p.rect_filled(r, 4.0, t.pressed); } else if resp.hovered() { p.rect_filled(r, 4.0, t.hover); } @@ -1305,10 +1305,10 @@ fn zoom_scrollbar(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, seq_seco let (left, w, per_point) = zoom_thumb(bar, seq_seconds, v.scroll, v.pps); let thumb = Rect::from_min_max(pos2(left, bar.min.y), pos2(left + w, bar.max.y)); let hover = ui.rect_contains_pointer(thumb); - p.rect_filled(thumb, thumb.height() / 2.0, if hover { Color32::from_rgb(0x6a, 0x6a, 0x6a) } else { Color32::from_rgb(0x4b, 0x4b, 0x4b) }); + p.rect_filled(thumb, thumb.height() / 2.0, if hover { t.scroll_thumb_hover } else { t.scroll_thumb }); for x in [thumb.min.x + thumb.height() / 2.0, thumb.max.x - thumb.height() / 2.0] { p.circle_filled(pos2(x, thumb.center().y), 4.5, t.panel_bg); - p.circle_stroke(pos2(x, thumb.center().y), 4.5, Stroke::new(1.5, Color32::from_rgb(0xd1, 0xd1, 0xd1))); + p.circle_stroke(pos2(x, thumb.center().y), 4.5, Stroke::new(1.5, t.icon_active)); } app.auto.add("timeline.zoomBar", thumb, "zoom scroll bar"); app.auto.add("timeline.zoomBar.track", bar, "zoom scroll bar track"); @@ -1388,13 +1388,12 @@ fn vertical_scrollbar(ui: &mut egui::Ui, bar: Rect, value: &mut f32, max: f32, i let pos = if invert { 1.0 - *value / max } else { *value / max }; let y = bar.min.y + (bar.height() - h) * pos; let thumb = Rect::from_min_size(pos2(bar.min.x + 1.0, y), vec2(bar.width() - 2.0, h)); - p.rect_filled(thumb, 3.0, Color32::from_rgb(80, 80, 80)); + p.rect_filled(thumb, 3.0, t.vertical_scroll_thumb); let resp = ui.interact(bar, egui::Id::new(("tl-vs", id)), Sense::drag()); if resp.dragged() { let d = resp.drag_delta().y / (bar.height() - h).max(1.0) * max; *value = (*value + if invert { -d } else { d }).clamp(0.0, max); } - let _ = t; } /// Snap `t` to nearby candidates (edits, playhead, markers, in/out). Returns snapped tick. diff --git a/crates/ui-egui/src/panels/timeline_captions.rs b/crates/ui-egui/src/panels/timeline_captions.rs index a9a770f6..bc5b6898 100644 --- a/crates/ui-egui/src/panels/timeline_captions.rs +++ b/crates/ui-egui/src/panels/timeline_captions.rs @@ -21,7 +21,6 @@ pub const ROW_H: f32 = 28.0; /// Caption block colours (fill, selected fill). const FILL: Color32 = Color32::from_rgb(0x6b, 0x4f, 0x8f); const FILL_SEL: Color32 = Color32::from_rgb(0x8c, 0x6c, 0xb4); -const ROW_BG: Color32 = Color32::from_rgb(0x23, 0x20, 0x2a); /// Row rects (full width incl. header) for each caption track, top first. pub fn rows(area: Rect, n: usize) -> Vec { @@ -73,7 +72,7 @@ pub fn paint(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, area: Re let mut actions: Vec<(String, Value)> = Vec::new(); for (i, (tr, row)) in seq.caption_tracks.iter().zip(rows(area, seq.caption_tracks.len())).enumerate() { let lane = Rect::from_min_max(pos2(content.min.x, row.min.y), pos2(content.max.x, row.max.y)); - painter.rect_filled(lane, 0.0, ROW_BG); + painter.rect_filled(lane, 0.0, t.caption_track_bg); painter.line_segment([pos2(row.min.x, row.max.y - 0.5), pos2(row.max.x, row.max.y - 0.5)], Stroke::new(1.0, t.tl_bg)); // header let label = format!("C{}", i + 1); @@ -92,7 +91,7 @@ pub fn paint(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, area: Re true, t, egui::Id::new(("cap-lock", tr.id.0)), - Some(if tr.locked { Color32::from_rgb(0xd1, 0xd1, 0xd1) } else { t.text_dim }), + Some(if tr.locked { t.icon_active } else { t.text_dim }), ); app.auto.add(&format!("timeline.captionTrack.{label}.locked"), lock_r, "Toggle Caption Track Lock"); if lresp.clicked() { diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 3a81ae12..ae46ddba 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -75,6 +75,55 @@ pub struct Tokens { pub row_selected: Color32, /// Scrubby (hot-text) numeric value colour. pub hot_text: Color32, + /// Selected pills and compact toggle backgrounds. + pub pill_active_bg: Color32, + /// The selected clip bar in Effect Controls. + pub clip_bar_bg: Color32, + /// Raised audio control surfaces, such as mixer channel strips. + pub control_surface: Color32, + /// Recessed audio control tracks and panner wells. + pub control_well: Color32, + pub control_border: Color32, + pub control_handle_bg: Color32, + pub control_handle: Color32, + pub control_handle_dim: Color32, + /// Data-plot surfaces (keyframe, EQ and dynamics graphs), distinct from media canvases. + pub plot_bg: Color32, + pub plot_grid: Color32, + pub plot_axis: Color32, + pub plot_handle: Color32, + pub plot_handle_dim: Color32, + /// Meter wells and the separate loudness readout surface. + pub meter_bg: Color32, + pub meter_readout_bg: Color32, + pub meter_clip_off: Color32, + pub panner_bg: Color32, + pub panner_border: Color32, + pub panner_speaker: Color32, + pub fader_knob: Color32, + pub eq_track: Color32, + pub eq_tick: Color32, + pub eq_knob: Color32, + pub eq_knob_active: Color32, + pub eq_node: Color32, + pub eq_node_off: Color32, + pub crossover_handle: Color32, + pub keyframe_plot_bg: Color32, + pub keyframe_plot_grid: Color32, + pub keyframe_plot_axis: Color32, + pub keyframe_handle: Color32, + pub switch_fill: Color32, + pub switch_knob: Color32, + pub switch_border: Color32, + pub slider_disabled_track: Color32, + pub slider_disabled_knob: Color32, + pub button_hover: Color32, + pub button_border: Color32, + pub caption_track_bg: Color32, + pub scroll_thumb: Color32, + pub scroll_thumb_hover: Color32, + pub vertical_scroll_thumb: Color32, + pub disabled_clip_bg: Color32, // timeline pub tl_bg: Color32, pub tl_track_bg: Color32, @@ -125,6 +174,49 @@ impl Tokens { row_alt: Color32::from_rgb(0x21, 0x21, 0x21), row_selected: Color32::from_rgb(0x33, 0x33, 0x33), hot_text: Color32::from_rgb(0x40, 0x96, 0xf3), + pill_active_bg: Color32::from_rgb(0x3a, 0x3a, 0x3a), + clip_bar_bg: Color32::from_rgb(58, 58, 70), + control_surface: Color32::from_rgb(0x24, 0x24, 0x24), + control_well: Color32::from_rgb(0x10, 0x10, 0x10), + control_border: Color32::from_rgb(0x50, 0x50, 0x50), + control_handle_bg: Color32::from_rgb(0x2a, 0x2a, 0x2a), + control_handle: Color32::from_rgb(0xe0, 0xe0, 0xe0), + control_handle_dim: Color32::from_rgb(0xb0, 0xb0, 0xb0), + plot_bg: Color32::from_rgb(0x18, 0x18, 0x18), + plot_grid: Color32::from_rgb(0x2c, 0x2c, 0x2c), + plot_axis: Color32::from_rgb(0x44, 0x44, 0x44), + plot_handle: Color32::from_rgb(0xe0, 0xe0, 0xe0), + plot_handle_dim: Color32::from_rgb(0x90, 0x90, 0x90), + meter_bg: Color32::BLACK, + meter_readout_bg: Color32::from_rgb(0x14, 0x14, 0x14), + meter_clip_off: Color32::from_rgb(0x30, 0x30, 0x30), + panner_bg: Color32::from_gray(22), + panner_border: Color32::from_gray(96), + panner_speaker: Color32::from_gray(144), + fader_knob: Color32::from_gray(90), + eq_track: Color32::from_gray(48), + eq_tick: Color32::from_gray(96), + eq_knob: Color32::from_gray(192), + eq_knob_active: Color32::from_gray(240), + eq_node: Color32::from_gray(232), + eq_node_off: Color32::from_gray(112), + crossover_handle: Color32::from_gray(255), + keyframe_plot_bg: Color32::from_gray(25), + keyframe_plot_grid: Color32::from_gray(42), + keyframe_plot_axis: Color32::from_gray(51), + keyframe_handle: Color32::from_gray(208), + switch_fill: Color32::from_gray(212), + switch_knob: Color32::from_gray(29), + switch_border: Color32::from_gray(138), + slider_disabled_track: Color32::from_gray(74), + slider_disabled_knob: Color32::from_gray(106), + button_hover: Color32::from_gray(42), + button_border: Color32::from_gray(75), + caption_track_bg: Color32::from_rgb(0x23, 0x20, 0x2a), + scroll_thumb: Color32::from_gray(0x4b), + scroll_thumb_hover: Color32::from_gray(0x6a), + vertical_scroll_thumb: Color32::from_gray(80), + disabled_clip_bg: Color32::from_gray(0x2a), tl_bg: Color32::from_rgb(0x1d, 0x1d, 0x1d), tl_track_bg: Color32::from_rgb(0x1d, 0x1d, 0x1d), tl_track_bg_alt: Color32::from_rgb(0x1d, 0x1d, 0x1d), @@ -182,6 +274,51 @@ impl Tokens { separator: Color32::from_rgb(200, 200, 200), row_alt: Color32::from_rgb(224, 224, 224), row_selected: Color32::from_rgb(170, 200, 240), + pill_active_bg: Color32::from_rgb(196, 196, 196), + clip_bar_bg: Color32::from_rgb(190, 203, 224), + control_surface: Color32::from_rgb(218, 218, 218), + control_well: Color32::from_rgb(196, 196, 196), + control_border: Color32::from_rgb(150, 150, 150), + control_handle_bg: Color32::from_rgb(205, 205, 205), + control_handle: Color32::from_rgb(50, 50, 50), + control_handle_dim: Color32::from_rgb(85, 85, 85), + plot_bg: Color32::from_rgb(245, 245, 245), + plot_grid: Color32::from_rgb(210, 210, 210), + plot_axis: Color32::from_rgb(170, 170, 170), + plot_handle: Color32::from_rgb(50, 50, 50), + plot_handle_dim: Color32::from_rgb(100, 100, 100), + meter_bg: Color32::from_rgb(205, 205, 205), + meter_readout_bg: Color32::from_rgb(218, 218, 218), + meter_clip_off: Color32::from_rgb(155, 155, 155), + panner_bg: Color32::from_gray(196), + panner_border: Color32::from_gray(150), + panner_speaker: Color32::from_gray(100), + fader_knob: Color32::from_gray(140), + eq_track: Color32::from_gray(196), + eq_tick: Color32::from_gray(150), + eq_knob: Color32::from_gray(85), + eq_knob_active: Color32::from_gray(34), + eq_node: Color32::from_gray(50), + eq_node_off: Color32::from_gray(140), + crossover_handle: Color32::from_gray(50), + keyframe_plot_bg: Color32::from_gray(245), + keyframe_plot_grid: Color32::from_gray(210), + keyframe_plot_axis: Color32::from_gray(170), + keyframe_handle: Color32::from_gray(50), + switch_fill: Color32::from_gray(60), + switch_knob: Color32::from_gray(232), + switch_border: Color32::from_gray(110), + slider_disabled_track: Color32::from_gray(190), + slider_disabled_knob: Color32::from_gray(140), + button_hover: Color32::from_gray(210), + button_border: Color32::from_gray(170), + icon_active: Color32::from_rgb(34, 34, 34), + caption_track_bg: Color32::from_rgb(222, 215, 233), + scroll_thumb: Color32::from_gray(150), + scroll_thumb_hover: Color32::from_gray(130), + vertical_scroll_thumb: Color32::from_gray(150), + disabled_clip_bg: Color32::from_gray(190), + in_out_shade: Color32::from_gray(185), tl_bg: Color32::from_rgb(210, 210, 210), tl_track_bg: Color32::from_rgb(222, 222, 222), tl_track_bg_alt: Color32::from_rgb(228, 228, 228), @@ -362,6 +499,53 @@ mod tests { ctx } + #[test] + fn light_theme_has_light_chrome_and_plot_surfaces() { + let dark = Tokens::for_kind(ThemeKind::Dark); + let light = Tokens::for_kind(ThemeKind::Light); + let luminance = |c: Color32| u16::from(c.r()) + u16::from(c.g()) + u16::from(c.b()); + + for (name, dark_surface, light_surface) in [ + ("active pill", dark.pill_active_bg, light.pill_active_bg), + ("clip bar", dark.clip_bar_bg, light.clip_bar_bg), + ("control surface", dark.control_surface, light.control_surface), + ("control well", dark.control_well, light.control_well), + ("plot", dark.plot_bg, light.plot_bg), + ("keyframe plot", dark.keyframe_plot_bg, light.keyframe_plot_bg), + ("panner", dark.panner_bg, light.panner_bg), + ("caption lane", dark.caption_track_bg, light.caption_track_bg), + ("active toggle", dark.pressed, light.pressed), + ("meter", dark.meter_bg, light.meter_bg), + ("meter readout", dark.meter_readout_bg, light.meter_readout_bg), + ] { + assert!(luminance(light_surface) > luminance(dark_surface), "{name} did not adapt to Light"); + } + assert!(luminance(light.control_handle) < luminance(light.control_handle_bg)); + assert!(luminance(light.plot_handle) < luminance(light.plot_bg)); + assert!(luminance(light.icon_active) < luminance(light.pressed)); + assert_eq!(Tokens::for_kind(ThemeKind::Medium).plot_bg, dark.plot_bg, "Medium keeps the established dark plot treatment"); + } + + #[test] + fn dark_and_medium_keep_existing_audio_and_effect_palettes() { + for kind in [ThemeKind::Dark, ThemeKind::Medium] { + let t = Tokens::for_kind(kind); + // Existing painted colors, recorded independently of the Light palette. + assert_eq!(t.pill_active_bg, Color32::from_gray(0x3a)); + assert_eq!(t.clip_bar_bg, Color32::from_rgb(58, 58, 70)); + assert_eq!(t.control_surface, Color32::from_gray(0x24)); + assert_eq!(t.control_well, Color32::from_gray(0x10)); + assert_eq!(t.control_handle, Color32::from_gray(0xe0)); + assert_eq!(t.meter_bg, Color32::BLACK); + assert_eq!(t.plot_bg, Color32::from_gray(0x18)); + assert_eq!(t.plot_grid, Color32::from_gray(0x2c)); + assert_eq!(t.keyframe_plot_bg, Color32::from_gray(0x19)); + assert_eq!(t.keyframe_plot_grid, Color32::from_gray(0x2a)); + assert_eq!(t.keyframe_plot_axis, Color32::from_gray(0x33)); + assert_eq!(t.keyframe_handle, Color32::from_gray(0xd0)); + } + } + /// Chinese media and track names must render even when the interface stays in English. /// Compare the rasterized glyphs with the missing-glyph box, including custom weight families. #[test] diff --git a/crates/ui-egui/tests/mixer_ui.rs b/crates/ui-egui/tests/mixer_ui.rs index 7c72924d..0d4b6f28 100644 --- a/crates/ui-egui/tests/mixer_ui.rs +++ b/crates/ui-egui/tests/mixer_ui.rs @@ -150,6 +150,19 @@ impl Driver { } } +#[test] +fn audio_chrome_switches_between_themes() { + let mut d = Driver::demo(); + d.ok("ui.set", json!({"workspace": "Audio"})); + for theme in ["light", "dark", "medium"] { + d.ok("ui.set", json!({"theme": theme})); + d.frames(3); + assert!(d.element("mixer.A1.fader").is_some()); + assert!(d.element("mixer.A1.pan").is_some()); + d.snapshot(&format!("mixer-theme-{theme}"), None); + } +} + #[test] fn track_mixer_controls_by_id_and_by_drag() { let mut d = Driver::demo(); From 8ee37150e4d984e30a2297e9befd770055a80a4c Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:13:54 +0200 Subject: [PATCH 43/64] CLI export: forward a lone --start or --end so the engine reports the missing bound (#538) --- apps/filmcraft-cli/src/main.rs | 38 +++++++++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/apps/filmcraft-cli/src/main.rs b/apps/filmcraft-cli/src/main.rs index 03ff62f0..615bf2e2 100644 --- a/apps/filmcraft-cli/src/main.rs +++ b/apps/filmcraft-cli/src/main.rs @@ -373,11 +373,7 @@ async fn cli() { if let Some(r) = a.opt("--range") { p["range"] = json!(r); } - if let (Some(s0), Some(s1)) = (a.opt("--start"), a.opt("--end")) { - p["range"] = json!("custom"); - p["startSeconds"] = parse_value(s0); - p["endSeconds"] = parse_value(s1); - } + set_custom_bounds(&mut p, a.opt("--start"), a.opt("--end")); if let Some(js) = a.opt("--settings") { p["settings"] = serde_json::from_str(js).unwrap_or_else(|e| usage(format!("--settings: {e}"))); } @@ -454,6 +450,21 @@ fn written_paths(result: &Value, requested: &str) -> Vec { if paths.is_empty() { vec![requested.to_string()] } else { paths } } +/// `--start` / `--end` ask for a custom range. A lone bound is forwarded too, so the engine reports the missing one +/// instead of the export silently running over the whole sequence. +fn set_custom_bounds(p: &mut Value, start: Option<&str>, end: Option<&str>) { + if start.is_none() && end.is_none() { + return; + } + p["range"] = json!("custom"); + if let Some(s0) = start { + p["startSeconds"] = parse_value(s0); + } + if let Some(s1) = end { + p["endSeconds"] = parse_value(s1); + } +} + #[cfg(test)] mod format_tests { /// The CLI (and MCP / headless runs, which share its entry point) registers the hardware @@ -478,6 +489,23 @@ mod format_tests { assert_eq!(super::written_paths(&json!({"items": []}), "x.mov"), ["x.mov"]); } + #[test] + fn export_lone_bound_is_forwarded_as_custom_range() { + use serde_json::json; + let mut p = json!({}); + super::set_custom_bounds(&mut p, Some("0.25"), None); + assert_eq!(p["range"], "custom"); + assert_eq!(p["startSeconds"], 0.25); + assert!(p.get("endSeconds").is_none()); + let mut p = json!({}); + super::set_custom_bounds(&mut p, None, Some("0.25")); + assert_eq!(p["range"], "custom"); + assert_eq!(p["endSeconds"], 0.25); + let mut p = json!({}); + super::set_custom_bounds(&mut p, None, None); + assert!(p.get("range").is_none()); + } + #[test] fn export_format_from_extension() { assert_eq!(super::format_for("out/clip.MXF"), Some("mxf-op1a")); From 0c66f5ffd3ca2c98a33a99f4f3c5fc41ac42a85f Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:14:03 +0200 Subject: [PATCH 44/64] Roll: reversed clips take the cut from the opposite source end (#526) --- crates/edit/src/lib.rs | 21 +++++++++++++++++---- crates/edit/src/tests.rs | 18 ++++++++++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index ed66f951..af449032 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -751,10 +751,19 @@ pub fn roll(seq: &mut Sequence, left: ClipId, right: ClipId, delta: Tick, ctx: & let mut d = delta; // left out-point limits d = d.max(-(l.duration - ctx.min_duration)).min(r.duration - ctx.min_duration); - if let Some(m) = media_len(ctx, l) { + // a reversed clip consumes media from the opposite end, so its media limit is the other one + if l.reverse { + d = d.min(Tick((head(ctx, l).0 as f64 / l.speed.abs()).floor() as i64)); + } else if let Some(m) = media_len(ctx, l) { d = d.min(Tick(((m - l.source_out()).0 as f64 / l.speed.abs()).floor() as i64)); } - d = d.max(-Tick((head(ctx, r).0 as f64 / r.speed.abs()).floor() as i64)); + if r.reverse { + if let Some(m) = media_len(ctx, r) { + d = d.max(-Tick(((m - r.source_out()).0 as f64 / r.speed.abs()).floor() as i64)); + } + } else { + d = d.max(-Tick((head(ctx, r).0 as f64 / r.speed.abs()).floor() as i64)); + } if d == Tick::ZERO { return Ok(d); } @@ -762,15 +771,19 @@ pub fn roll(seq: &mut Sequence, left: ClipId, right: ClipId, delta: Tick, ctx: & let before = seq.clone(); { let (_, l) = seq.find_item_mut(left).ok_or(EditError::NoItem(left))?; + if l.reverse { + l.source_in -= src_of(d, ls); + } l.duration += d; } { let (_, r) = seq.find_item_mut(right).ok_or(EditError::NoItem(right))?; r.start += d; r.duration -= d; - r.source_in += src_of(d, rs); + if !r.reverse { + r.source_in += src_of(d, rs); + } } - let _ = ls; transitions_follow_cuts(&before, seq); Ok(d) } diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 558e1ebc..eb539193 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -673,3 +673,21 @@ fn shorter_head_closes_the_stretch_after_the_cut_on_sync_locked_tracks() { assert_eq!(fx.spans(v1), vec![(0, 10), (10, 10), (20, 10)], "unchanged on failure"); } } + +#[test] +fn roll_reversed_clips_keeps_media_mapping() { + let mut fx = Fx::new(); + let v = fx.v(0); + let mut a = fx.item(0, 30, 20); + a.reverse = true; + let mut b = fx.item(30, 30, 50); + b.reverse = true; + let (ia, ib) = (a.id, b.id); + fx.seq.track_mut(v).unwrap().items.extend([a, b]); + let mut n = 0; + roll(&mut fx.seq, ia, ib, f(10), &mut Fx::ctx(&mut n)).unwrap(); + let (_, l) = fx.seq.find_item(ia).unwrap(); + assert_eq!((l.start, l.duration, l.source_in), (f(0), f(40), f(10))); + let (_, r) = fx.seq.find_item(ib).unwrap(); + assert_eq!((r.start, r.duration, r.source_in), (f(40), f(20), f(50))); +} From aff59f15ec76c070c1aae83d83f2107b07d7dc42 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:14:21 +0200 Subject: [PATCH 45/64] Queue move: saturate a huge relative offset instead of overflowing (#532) --- crates/engine/src/export_tests.rs | 16 ++++++++++++++++ crates/engine/src/export_tools.rs | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/export_tests.rs b/crates/engine/src/export_tests.rs index a934969d..1f45d7fc 100644 --- a/crates/engine/src/export_tests.rs +++ b/crates/engine/src/export_tests.rs @@ -359,6 +359,22 @@ fn queue_orders_cancels_and_retries() { assert!(queue(&mut s).is_empty()); } +#[test] +fn queue_move_by_extreme_offsets_clamps_instead_of_overflowing() { + let mut s = demo(); + let ids: Vec = (0..3) + .map(|_| { + let r = s.execute("export.queue.add", json!({"preset": "Waveform Audio 48 kHz 16-bit", "path": "queued-output/"})).unwrap(); + r["added"][0].as_u64().unwrap() + }) + .collect(); + let order = |s: &mut Session| -> Vec { queue(s).iter().map(|i| i["id"].as_u64().unwrap()).collect() }; + s.execute("export.queue.move", json!({"id": ids[1], "by": i64::MAX})).unwrap(); + assert_eq!(order(&mut s), [ids[0], ids[2], ids[1]]); + s.execute("export.queue.move", json!({"id": ids[1], "by": i64::MIN})).unwrap(); + assert_eq!(order(&mut s), [ids[1], ids[0], ids[2]]); +} + #[test] fn queue_cancels_a_running_export_and_retries_a_failed_one() { let mut s = demo(); diff --git a/crates/engine/src/export_tools.rs b/crates/engine/src/export_tools.rs index 7d3073f2..1c893475 100644 --- a/crates/engine/src/export_tools.rs +++ b/crates/engine/src/export_tools.rs @@ -769,7 +769,7 @@ fn queue_move(s: &mut Session, p: &Value) -> Result { let n = s.export_queue.items.len() as i64; let to = match (p.get("to").and_then(Value::as_i64), p.get("by").and_then(Value::as_i64)) { (Some(t), _) => t, - (None, Some(b)) => i as i64 + b, + (None, Some(b)) => (i as i64).saturating_add(b), _ => return Err(bad(cmd, "need `to` (index) or `by` (±n)")), } .clamp(0, n - 1) as usize; From 1ccb51fb78f8c7ac8b8a4a323d96c33d708cc1fc Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:14:35 +0200 Subject: [PATCH 46/64] Slide: keep reversed neighbours' source ranges correct (#548) --- crates/edit/src/lib.rs | 21 ++++++++++++++++++--- crates/edit/src/tests.rs | 19 +++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index ed66f951..4dd3545a 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -799,7 +799,10 @@ pub fn slide(seq: &mut Sequence, clip: ClipId, delta: Tick, ctx: &mut EditCtx) - let mut d = delta; if let Some(p) = &prev { d = d.max(-(p.duration - ctx.min_duration)); - if let Some(m) = media_len(ctx, p) { + if p.reverse { + // a reversed neighbour grows by pulling earlier media in front of its source In + d = d.min(Tick((head(ctx, p).0 as f64 / p.speed.abs()).floor() as i64)); + } else if let Some(m) = media_len(ctx, p) { d = d.min(Tick(((m - p.source_out()).0 as f64 / p.speed.abs()).floor() as i64)); } } else { @@ -807,7 +810,14 @@ pub fn slide(seq: &mut Sequence, clip: ClipId, delta: Tick, ctx: &mut EditCtx) - } if let Some(n) = &next { d = d.min(n.duration - ctx.min_duration); - d = d.max(-Tick((head(ctx, n).0 as f64 / n.speed.abs()).floor() as i64)); + if n.reverse { + // a reversed neighbour grows leftwards by pulling later media behind its source Out + if let Some(m) = media_len(ctx, n) { + d = d.max(-Tick(((m - n.source_out()).0 as f64 / n.speed.abs()).floor() as i64)); + } + } else { + d = d.max(-Tick((head(ctx, n).0 as f64 / n.speed.abs()).floor() as i64)); + } } else { d = d.min(neighbours(tr, clip).1 - it.end()); } @@ -819,12 +829,17 @@ pub fn slide(seq: &mut Sequence, clip: ClipId, delta: Tick, ctx: &mut EditCtx) - if let Some(p) = prev { let pi = t.item_mut(p.id).ok_or(EditError::NoItem(p.id))?; pi.duration += d; + if p.reverse { + pi.source_in -= src_of(d, p.speed); + } } if let Some(n) = next { let ni = t.item_mut(n.id).ok_or(EditError::NoItem(n.id))?; ni.start += d; ni.duration -= d; - ni.source_in += src_of(d, n.speed); + if !n.reverse { + ni.source_in += src_of(d, n.speed); + } } let me = t.item_mut(clip).ok_or(EditError::NoItem(clip))?; me.start += d; diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 558e1ebc..6f6a6ea3 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -359,6 +359,25 @@ fn roll_slip_slide() { fx.seq.check().unwrap(); } +#[test] +fn slide_keeps_reversed_neighbours_content() { + let mut fx = Fx::new(); + let v1 = fx.v(0); + let a = fx.put(v1, 0, 30, 20); + let b = fx.put(v1, 30, 30, 50); + let c = fx.put(v1, 60, 30, 70); + for id in [a, c] { + fx.seq.find_item_mut(id).unwrap().1.reverse = true; + } + let mut n = fx.next; + slide(&mut fx.seq, b, f(10), &mut Fx::ctx(&mut n)).unwrap(); + assert_eq!(fx.spans(v1), vec![(0, 40), (40, 30), (70, 20)]); + assert_eq!(fx.seq.find_item(a).unwrap().1.source_in, f(10)); + assert_eq!(fx.seq.find_item(b).unwrap().1.source_in, f(50)); + assert_eq!(fx.seq.find_item(c).unwrap().1.source_in, f(70)); + fx.seq.check().unwrap(); +} + #[test] fn transitions_stay_on_their_cuts() { let mut fx = Fx::new(); From 74843430121db8a50f3c066c9e90676bed50fbf9 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:14:40 +0200 Subject: [PATCH 47/64] fix(edit): reversed trims use the media handle their playback direction consumes (#527) --- crates/edit/src/lib.rs | 13 +++++++++++-- crates/edit/src/tests.rs | 21 +++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index ed66f951..ea23190b 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -535,7 +535,13 @@ pub fn clamp_trim(seq: &Sequence, clip: ClipId, edge: Edge, mode: TrimMode, delt match edge { Edge::In => { // extending left (d<0) needs media before source_in; shortening needs min duration - let max_ext = Tick((head(ctx, it).0 as f64 / speed).floor() as i64); + // a reversed clip plays source_out down to source_in, so its In edge consumes media after source_out + let handle = match (it.reverse, media) { + (true, Some(m)) => (m - it.source_out()).max(Tick::ZERO), + (true, None) => Tick::MAX, + (false, _) => head(ctx, it), + }; + let max_ext = if handle == Tick::MAX { Tick::MAX } else { Tick((handle.0 as f64 / speed).floor() as i64) }; let lo = if mode == TrimMode::Regular { (-(it.start - prev_end)).max(-max_ext) } else { -max_ext }; let hi = it.duration - ctx.min_duration; d = d.clamp(if it.frame_hold.is_some() { Tick::MIN } else { lo }, hi); @@ -543,7 +549,10 @@ pub fn clamp_trim(seq: &Sequence, clip: ClipId, edge: Edge, mode: TrimMode, delt Edge::Out => { let lo = -(it.duration - ctx.min_duration); let mut hi = if mode == TrimMode::Regular { next_start - it.end() } else { Tick::MAX }; - if let Some(m) = media { + if it.reverse && it.frame_hold.is_none() { + // a reversed clip's Out edge consumes media before source_in + hi = hi.min(Tick((head(ctx, it).0 as f64 / speed).floor() as i64)); + } else if let Some(m) = media { let remain = Tick(((m - it.source_out()).0 as f64 / speed).floor() as i64); hi = hi.min(remain); } diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 558e1ebc..552c7638 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -234,6 +234,27 @@ fn close_gap_works() { assert_eq!(fx.spans(v1), vec![(0, 10), (10, 10)]); } +#[test] +fn reversed_trim_uses_the_handle_its_playback_direction_consumes() { + let mut fx = Fx::new(); + let v1 = fx.v(0); + // source frames [5, 15) played backwards; the media is 1000 frames long + let a = fx.put(v1, 30, 10, 5); + fx.seq.find_item_mut(a).unwrap().1.reverse = true; + let mut n = fx.next; + // out extends into the 5 frames before source_in, not into the 985 after source_out + let d = trim(&mut fx.seq, a, Edge::Out, TrimMode::Regular, f(20), &mut Fx::ctx(&mut n)).unwrap(); + assert_eq!(d, f(5)); + let it = fx.seq.find_item(a).unwrap().1; + assert_eq!((it.source_in, it.source_out()), (f(0), f(15))); + // in extends into the media after source_out + let d = trim(&mut fx.seq, a, Edge::In, TrimMode::Regular, -f(10), &mut Fx::ctx(&mut n)).unwrap(); + assert_eq!(d, -f(10)); + let it = fx.seq.find_item(a).unwrap().1; + assert_eq!((it.source_in, it.source_out()), (f(0), f(25))); + fx.seq.check().unwrap(); +} + #[test] fn regular_trim_respects_neighbours_and_handles() { let mut fx = Fx::new(); From d23b33a9fe04fd8af4a6ee357cb61ae9ce029a91 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:14:41 +0200 Subject: [PATCH 48/64] Frame-held Regular In trim stops at the previous clip instead of overlapping it (#530) --- crates/edit/src/lib.rs | 8 +++++++- crates/edit/src/tests.rs | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index ed66f951..a8c02741 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -538,7 +538,13 @@ pub fn clamp_trim(seq: &Sequence, clip: ClipId, edge: Edge, mode: TrimMode, delt let max_ext = Tick((head(ctx, it).0 as f64 / speed).floor() as i64); let lo = if mode == TrimMode::Regular { (-(it.start - prev_end)).max(-max_ext) } else { -max_ext }; let hi = it.duration - ctx.min_duration; - d = d.clamp(if it.frame_hold.is_some() { Tick::MIN } else { lo }, hi); + // a frame hold needs no source head, but a Regular trim still stops at the previous clip + let lo = match (it.frame_hold.is_some(), mode) { + (true, TrimMode::Regular) => -(it.start - prev_end), + (true, _) => Tick::MIN, + (false, _) => lo, + }; + d = d.clamp(lo, hi); } Edge::Out => { let lo = -(it.duration - ctx.min_duration); diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 558e1ebc..03558b28 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -253,6 +253,24 @@ fn regular_trim_respects_neighbours_and_handles() { fx.seq.check().unwrap(); } +#[test] +fn held_regular_in_trim_stops_at_previous_clip() { + let mut fx = Fx::new(); + let v1 = fx.v(0); + fx.put(v1, 0, 10, 0); + let b = fx.put(v1, 15, 10, 5); + fx.seq.find_item_mut(b).unwrap().1.frame_hold = Some(f(5)); + let mut n = fx.next; + // 5 frames of gap: a 20 frame extension is clamped to 5 instead of overlapping the previous clip + let d = trim(&mut fx.seq, b, Edge::In, TrimMode::Regular, -f(20), &mut Fx::ctx(&mut n)).unwrap(); + assert_eq!(d, -f(5)); + assert_eq!(fx.spans(v1), vec![(0, 10), (10, 15)]); + // no space left: nothing to do, and no error + let d = trim(&mut fx.seq, b, Edge::In, TrimMode::Regular, -f(3), &mut Fx::ctx(&mut n)).unwrap(); + assert_eq!(d, Tick::ZERO); + fx.seq.check().unwrap(); +} + #[test] fn ripple_trim_shifts_following() { let mut fx = Fx::new(); From 09f0cf1cc7ca9635cec13d7a81651c22acdbc1a9 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:14:46 +0200 Subject: [PATCH 49/64] Parametric Equalizer: use the full EQ so master gain, bands 1/2/4/5 and pass filters reach the audio (#540) --- crates/render/src/audio_fx.rs | 34 +++++++++++++++++++++------------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/crates/render/src/audio_fx.rs b/crates/render/src/audio_fx.rs index b88a5915..db84a1f6 100644 --- a/crates/render/src/audio_fx.rs +++ b/crates/render/src/audio_fx.rs @@ -66,19 +66,6 @@ pub(crate) fn mapping(id: &str) -> Option { d.set_param("mix", f(e, "mix", t)); }, }, - "parametric_eq" => Mapping { - dsp: "simple_eq", - preroll: |_, _| 0.05, - apply: |d, e, t| { - d.set_param("low_freq", f(e, "low_freq", t)); - d.set_param("low", f(e, "low_gain", t)); - d.set_param("mid_freq", f(e, "mid_freq", t)); - d.set_param("mid", f(e, "mid_gain", t)); - d.set_param("mid_q", f(e, "mid_q", t)); - d.set_param("high_freq", f(e, "high_freq", t)); - d.set_param("high", f(e, "high_gain", t)); - }, - }, // Single-band filters via band 1 of the parametric EQ (types: see FILTER_TYPE_NAMES). "highpass" => Mapping { dsp: "parametric_eq", preroll: |_, _| 0.05, apply: |d, e, t| band(d, 4.0, f(e, "cutoff", t), 0.707) }, "lowpass" => Mapping { dsp: "parametric_eq", preroll: |_, _| 0.05, apply: |d, e, t| band(d, 3.0, f(e, "cutoff", t), 0.707) }, @@ -474,4 +461,25 @@ mod tests { assert_eq!(latency("fft_filter", 48000), 2048); assert_eq!(latency("amplify", 48000), 0); } + + #[test] + fn parametric_eq_applies_every_control() { + let def = filmcraft_project::find_effect("parametric_eq").unwrap(); + let resp = |id: &str, v: filmcraft_project::ParamValue, extra: Option<(&str, filmcraft_project::ParamValue)>| { + let mut inst = def.instance(); + inst.param_mut(id).unwrap().value = v; + if let Some((k, x)) = extra { + inst.param_mut(k).unwrap().value = x; + } + configured(&inst, Tick::ZERO, 48000).unwrap().response_db(1000.0).unwrap() + }; + use filmcraft_project::ParamValue::{Bool, Float}; + assert!((resp("master_gain", Float(-30.0), None) + 30.0).abs() < 1.0); + for b in ["b1", "b2", "b4", "b5"] { + let g = resp(&format!("{b}_gain"), Float(-24.0), Some((&format!("{b}_freq"), Float(1000.0)))); + assert!((g + 24.0).abs() < 1.0, "{b}: {g}"); + } + assert!(resp("hp_on", Bool(true), Some(("hp_freq", Float(10000.0)))) < -20.0); + assert!(resp("lp_on", Bool(true), Some(("lp_freq", Float(100.0)))) < -20.0); + } } From 948c791db86bdbb2aeac49cf2770400fcbb9b909 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:14:47 +0200 Subject: [PATCH 50/64] Project Manager: keep every parent of a nested subclip when collecting (#535) --- crates/engine/src/project_manager.rs | 9 +++++++++ crates/engine/src/project_manager_tests.rs | 21 +++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/crates/engine/src/project_manager.rs b/crates/engine/src/project_manager.rs index aa426cc3..3a78fff1 100644 --- a/crates/engine/src/project_manager.rs +++ b/crates/engine/src/project_manager.rs @@ -44,6 +44,15 @@ pub fn used_ranges(p: &Project, seqs: &[ItemId]) -> (BTreeMap { used.insert(ti.item); target = *parent; + // Loaded projects can nest subclips; keep every link up to the media (bounded like `resolve_media`). + let mut up = *parent; + for _ in 0..16 { + used.insert(up); + match p.item(up).map(|i| &i.kind) { + Some(ItemKind::Subclip { parent, .. }) => up = *parent, + _ => break, + } + } } Some(_) => {} None => continue, diff --git a/crates/engine/src/project_manager_tests.rs b/crates/engine/src/project_manager_tests.rs index 3039cec1..3f7ce4fe 100644 --- a/crates/engine/src/project_manager_tests.rs +++ b/crates/engine/src/project_manager_tests.rs @@ -101,3 +101,24 @@ fn consolidate_trims_to_used_ranges_with_handles() { } let _ = std::fs::remove_dir_all(&root); } + +#[test] +fn nested_subclip_keeps_its_whole_parent_chain_used() { + use filmcraft_project::{ItemKind, Label}; + use filmcraft_time::{Tick, TimeRange}; + let root = tmp_dir("pm-nested-subclip"); + let (s, items, _) = project(&root); + let seq = s.state.active_sequence.unwrap(); + let mut p = (*s.project).clone(); + let range = TimeRange::new(Tick::ZERO, Tick(1000)); + let inner = p.add_item("Inner", Label::Iris, ItemKind::Subclip { parent: items[0], range, restrict_trims: false }, None); + let outer = p.add_item("Outer", Label::Iris, ItemKind::Subclip { parent: inner, range, restrict_trims: false }, None); + for t in p.sequence_mut(seq).unwrap().all_tracks_mut() { + for x in t.items.iter_mut().filter(|i| i.item == items[0]) { + x.item = outer; + } + } + let (_, used) = super::project_manager::used_ranges(&p, &[seq]); + assert!(used.contains(&outer) && used.contains(&inner) && used.contains(&items[0]), "{used:?}"); + let _ = std::fs::remove_dir_all(&root); +} From ffbe2622da86ecbdf4bc9192c6132eb593efaf4e Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:14:50 +0200 Subject: [PATCH 51/64] CLI render: report the real reason for an invalid scale instead of no sequence (#539) --- apps/filmcraft-cli/src/main.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/apps/filmcraft-cli/src/main.rs b/apps/filmcraft-cli/src/main.rs index 03ff62f0..49be8852 100644 --- a/apps/filmcraft-cli/src/main.rs +++ b/apps/filmcraft-cli/src/main.rs @@ -414,7 +414,7 @@ async fn cli() { let out = a.opt("--out").unwrap_or("frame.png"); s.set_playhead(filmcraft_time::Tick::from_seconds_f64(secs)); let t0 = std::time::Instant::now(); - let Some(img) = s.render_program(scale) else { fail("no sequence") }; + let img = render_at_playhead(&s, scale).unwrap_or_else(|e| fail(e)); let dt = t0.elapsed(); let png = filmcraft_automation::png_rgba(img.w as u32, img.h as u32, img.over_black_rgba8(), 0).unwrap_or_else(|e| fail(e)); std::fs::write(out, png).unwrap_or_else(|e| fail(format!("{out}: {e}"))); @@ -454,8 +454,25 @@ fn written_paths(result: &Value, requested: &str) -> Vec { if paths.is_empty() { vec![requested.to_string()] } else { paths } } +/// The active sequence at the playhead, or the reason it cannot be rendered (no sequence, bad scale). +fn render_at_playhead(s: &Session, scale: f32) -> Result { + s.try_render_program_at(scale, s.playhead()).map_err(|e| e.to_string()) +} + #[cfg(test)] mod format_tests { + #[test] + fn render_reports_the_real_reason() { + let mut s = filmcraft_engine::Session::default(); + assert!(super::render_at_playhead(&s, 0.5).unwrap_err().contains("sequence")); + s.execute("file.newSequence", serde_json::json!({"width": 16, "height": 16})).unwrap(); + for scale in [0.0, -1.0, f32::NAN] { + let e = super::render_at_playhead(&s, scale).unwrap_err(); + assert!(e.contains("finite and positive"), "{e}"); + } + assert_eq!(super::render_at_playhead(&s, 0.5).unwrap().w, 8); + } + /// The CLI (and MCP / headless runs, which share its entry point) registers the hardware /// decoders at start-up. #[test] From 544f561587ffe3babca10eb21e2c5680d9af5d4c Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:14:06 +0200 Subject: [PATCH 52/64] MCC export: end each ancillary packet with its checksum byte so FFmpeg reads the captions (#544) --- crates/captions/src/mcc.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/captions/src/mcc.rs b/crates/captions/src/mcc.rs index b5da4e29..3004872b 100644 --- a/crates/captions/src/mcc.rs +++ b/crates/captions/src/mcc.rs @@ -17,7 +17,8 @@ //! - **CDP (SMPTE ST 334-2):** identifier `96 69`, length, frame-rate code, flags, sequence //! counter, a `cc_data` section (`72`, `E0 | cc_count`, cc_count × (marker/valid/type, two //! bytes)), optional time code / service info sections, footer `74`, the counter again and a -//! checksum that makes the packet's byte sum 0 mod 256. +//! checksum that makes the packet's byte sum 0 mod 256. The ancillary packet then ends with one ST 291 checksum byte +//! (low 8 bits of DID + SDID + data count + CDP bytes). //! - **Writing** (29.97 fps, `30DF` or `30` timecode labels): every frame from the first caption //! data to one frame after the last carries a CDP with 20 cc_data triplets: field 1 CEA-608 //! pop-on data (the same schedule as [SCC](crate::scc)), a null field-2 pair, CEA-708 @@ -161,6 +162,9 @@ pub fn build_cdp(seq: u16, data: &FrameData) -> Vec { cdp.push(((256 - sum % 256) % 256) as u8); let mut out = vec![0x61, 0x01, cdp.len() as u8]; out.extend(cdp); + // SMPTE ST 291 ancillary checksum: low 8 bits of the sum of DID, SDID, data count and the user data words. + let anc_sum: u32 = out.iter().map(|&b| b as u32).sum(); + out.push((anc_sum & 0xff) as u8); out } @@ -661,7 +665,11 @@ mod tests { let d = FrameData { f1: Some((0x14, 0x20)), dtvcc: vec![(true, 0x02, 0x21), (false, 0x89, 0x01)] }; let b = build_cdp(7, &d); assert_eq!(&b[..3], &[0x61, 0x01, 73]); - let cdp = &b[3..]; + // prefix (3) + CDP (73) + ancillary checksum byte (1) + assert_eq!(b.len(), 77); + let sum: u32 = b[..76].iter().map(|&x| x as u32).sum(); + assert_eq!(b[76], (sum & 0xff) as u8, "ancillary checksum"); + let cdp = &b[3..76]; assert_eq!(cdp.len(), 73); assert_eq!(&cdp[..9], &[0x96, 0x69, 73, 0x4f, 0x43, 0, 7, 0x72, 0xf4]); assert_eq!(cdp.iter().map(|&x| x as u32).sum::() % 256, 0, "checksum"); From 64007e93b6db5aa9acd29fb870b264bb4a84f7db Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:13:56 +0200 Subject: [PATCH 53/64] CLI: a closed stderr no longer panics with exit 101 (#537) --- apps/filmcraft-cli/src/main.rs | 37 +++++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/apps/filmcraft-cli/src/main.rs b/apps/filmcraft-cli/src/main.rs index 03ff62f0..da842d07 100644 --- a/apps/filmcraft-cli/src/main.rs +++ b/apps/filmcraft-cli/src/main.rs @@ -56,7 +56,7 @@ fn stdout_failed(e: std::io::Error) { if STDOUT_GONE.swap(true, Ordering::Relaxed) || e.kind() == std::io::ErrorKind::BrokenPipe { return; } - eprintln!("filmcraft-cli: can't write to stdout: {e}"); + diag(format_args!("filmcraft-cli: can't write to stdout: {e}")); STDOUT_ERROR.store(true, Ordering::Relaxed); } @@ -115,13 +115,23 @@ EXIT STATUS 0 success · 1 a command failed · 2 usage error "; +/// Writes one diagnostic line to `w`, dropping any write error. `eprintln!` panics (exit 101) when stderr's +/// reader is gone, which would turn a usage error, a failure or a finished export into a crash. +fn write_diag(w: &mut dyn std::io::Write, msg: std::fmt::Arguments) { + let _ = writeln!(w, "{msg}"); +} + +fn diag(msg: std::fmt::Arguments) { + write_diag(&mut std::io::stderr(), msg); +} + fn usage(msg: impl std::fmt::Display) -> ! { - eprintln!("filmcraft-cli: {msg}\nRun `filmcraft-cli help` for usage."); + diag(format_args!("filmcraft-cli: {msg}\nRun `filmcraft-cli help` for usage.")); std::process::exit(2) } fn fail(msg: impl std::fmt::Display) -> ! { - eprintln!("filmcraft-cli: {msg}"); + diag(format_args!("filmcraft-cli: {msg}")); std::process::exit(1) } @@ -399,7 +409,7 @@ async fn cli() { match r { Ok(v) => { print(&a, &v); - eprintln!("exported {} in {:.1}s", written_paths(&v, out).join(", "), t0.elapsed().as_secs_f64()); + diag(format_args!("exported {} in {:.1}s", written_paths(&v, out).join(", "), t0.elapsed().as_secs_f64())); } Err(e) => fail(format!("export: {e}")), } @@ -418,7 +428,7 @@ async fn cli() { let dt = t0.elapsed(); let png = filmcraft_automation::png_rgba(img.w as u32, img.h as u32, img.over_black_rgba8(), 0).unwrap_or_else(|e| fail(e)); std::fs::write(out, png).unwrap_or_else(|e| fail(format!("{out}: {e}"))); - eprintln!("rendered {}x{} in {:.1} ms → {out}", img.w, img.h, dt.as_secs_f64() * 1000.0); + diag(format_args!("rendered {}x{} in {:.1} ms → {out}", img.w, img.h, dt.as_secs_f64() * 1000.0)); } "mcp" => { let server = match a.opt("--bridge") { @@ -467,6 +477,23 @@ mod format_tests { assert_eq!(filmcraft_platform::registered(), expected, "{hardware:?}"); } + #[test] + fn a_failing_diagnostic_stream_does_not_panic() { + struct Closed; + impl std::io::Write for Closed { + fn write(&mut self, _: &[u8]) -> std::io::Result { + Err(std::io::ErrorKind::BrokenPipe.into()) + } + fn flush(&mut self) -> std::io::Result<()> { + Err(std::io::ErrorKind::BrokenPipe.into()) + } + } + super::write_diag(&mut Closed, format_args!("exported {}", "a.wav")); + let mut ok = Vec::new(); + super::write_diag(&mut ok, format_args!("hi")); + assert_eq!(ok, b"hi\n"); + } + #[test] fn export_message_names_the_written_files() { use serde_json::json; From 09d5473645c4ad5891707791d0ae072b4f2a1d07 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:14:39 +0200 Subject: [PATCH 54/64] Forced relink and Replace Footage keep the new file's properties (#534) --- crates/engine/src/relink.rs | 12 +++++++++--- crates/engine/src/relink_tests.rs | 16 ++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/relink.rs b/crates/engine/src/relink.rs index d8f0a9aa..7037404a 100644 --- a/crates/engine/src/relink.rs +++ b/crates/engine/src/relink.rs @@ -407,12 +407,18 @@ pub fn relink(s: &mut Session, p: &Value) -> Result { Some(MediaRef::File { path }) => path, _ => return Err(bad("media.relink", format!("item {} is not file media", item.0))), }; - let c = check_candidate(s, item, &path, &o)?; + let mut c = check_candidate(s, item, &path, &o)?; if !c.ok && !force { return Err(EngineError::Other(format!("{} doesn't match {}: {}", file_name(&path), file_name(&old), c.problems.join("; ")))); } - if c.source.is_none() && s.media.open_file(&path, &*s.services).is_err() { - return Err(EngineError::Other(format!("{path} can't be opened"))); + if c.source.is_none() { + // Forced past a failed check: the file was never opened for its properties, so do it now + // and let the item take the new file's own info instead of keeping the old file's. + let Ok(src) = s.media.open_file(&path, &*s.services) else { + return Err(EngineError::Other(format!("{path} can't be opened"))); + }; + c.info = Some(src.info().clone()); + c.source = Some(src); } let mut plans = vec![Plan { item, cand: c }]; if o.relink_others { diff --git a/crates/engine/src/relink_tests.rs b/crates/engine/src/relink_tests.rs index 64efe4ba..b6475c0d 100644 --- a/crates/engine/src/relink_tests.rs +++ b/crates/engine/src/relink_tests.rs @@ -128,6 +128,22 @@ fn fingerprint_mismatch_is_refused_unless_forced() { let _ = std::fs::remove_dir_all(&root); } +#[test] +fn forced_relink_takes_the_new_files_properties() { + let root = tmp_dir("relink-forced-info"); + let (path, items) = saved_project(&root); + std::fs::remove_dir_all(root.join("Media")).unwrap(); + // same name, different pictures and a different size + let other = root.join("Other"); + std::fs::create_dir_all(&other).unwrap(); + make_movie(&other.join("a.mov"), DemoScene::Dunes, W * 2, H * 2, 12); + let mut s = open(&path); + s.execute("media.relink", json!({"item": items[0].0, "path": other.join("a.mov").to_string_lossy(), "force": true, "relinkOthers": false})).unwrap(); + let width = s.project.item(items[0]).and_then(|i| i.as_media()).and_then(|m| m.info.video.as_ref().map(|v| v.width)); + assert_eq!(width, Some(W * 2), "the item describes the replacement file"); + let _ = std::fs::remove_dir_all(&root); +} + #[test] fn make_offline_shows_the_slate_until_relinked() { let root = tmp_dir("relink-offline"); From 769ead74a897213c4f98f3590826f1d0b9cc2dd8 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:14:46 +0200 Subject: [PATCH 55/64] Audio Gain: measure the peak of the audio stream each clip plays (#541) --- crates/engine/src/audio_placement_tests.rs | 44 ++++++++++++++++++++++ crates/engine/src/mixer.rs | 4 +- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/audio_placement_tests.rs b/crates/engine/src/audio_placement_tests.rs index 541324ae..f765e055 100644 --- a/crates/engine/src/audio_placement_tests.rs +++ b/crates/engine/src/audio_placement_tests.rs @@ -94,6 +94,50 @@ fn placing_media_with_two_streams_expands_tracks_and_keeps_stream_indices() { assert_eq!(a.start, b.start); } +#[test] +fn clip_peaks_measure_the_stream_the_clip_plays() { + let mut s = Session::default(); + let mut p = (*s.project).clone(); + let rate = FrameRate::FPS_24; + let seq = p.new_sequence("Two streams", Default::default(), 1, 1, None); + let source = filmcraft_media::generators::GeneratorSource::new(filmcraft_media::Generator::BarsAndTone, 32, 32, rate, rate.tick_of(24)); + let mut info = source.info().clone(); + info.audio_streams.push(info.audio_streams[0].clone()); + let item = p.add_item( + "two streams", + filmcraft_project::Label::Iris, + filmcraft_project::ItemKind::Media(filmcraft_project::MediaClip { + media: filmcraft_project::MediaRef::Generator(filmcraft_media::Generator::BarsAndTone), + info, + interpret: Default::default(), + mark_in: None, + mark_out: None, + markers: vec![], + offline: false, + proxy: None, + identity: None, + }), + None, + ); + let (vdest, adest) = { + let q = p.sequence(seq).unwrap(); + (q.video_tracks[0].id, q.audio_tracks[0].id) + }; + s.project = std::sync::Arc::new(p); + s.state.active_sequence = Some(seq); + let ids = + crate::commands::place_item(&mut s, item, TimeRange::new(Tick::ZERO, rate.tick_of(24)), Tick::ZERO, Some(vdest), Some(adest), false, "test", None) + .unwrap(); + let q = s.project.sequence(seq).unwrap(); + let (a, b) = (q.audio_tracks[0].items[0].id, q.audio_tracks[1].items[0].id); + assert!(ids.contains(&a) && ids.contains(&b)); + let peaks = crate::mixer::clip_peaks(&s, &[a, b]); + let db = |c: filmcraft_project::ClipId| peaks.iter().find(|(id, _)| *id == c).map(|(_, d)| *d).unwrap(); + // The generator only has stream 0, so the clip playing stream 1 is silent, not a copy of stream 0. + assert!(db(a) > -30.0, "{peaks:?}"); + assert!(db(b) < -100.0, "{peaks:?}"); +} + #[test] fn missing_audio_tracks_are_cloned_from_the_destination() { let s = demo(); diff --git a/crates/engine/src/mixer.rs b/crates/engine/src/mixer.rs index 75c8042a..51400751 100644 --- a/crates/engine/src/mixer.rs +++ b/crates/engine/src/mixer.rs @@ -1048,7 +1048,7 @@ fn clip_set(s: &mut Session, p: &Value) -> Result { // ------------------------------------------------------------------------------------- audio gain /// Peak (dBFS, before clip gain) of each selected audio clip's source range. -fn clip_peaks(s: &Session, clips: &[filmcraft_project::ClipId]) -> Vec<(filmcraft_project::ClipId, f64)> { +pub(crate) fn clip_peaks(s: &Session, clips: &[filmcraft_project::ClipId]) -> Vec<(filmcraft_project::ClipId, f64)> { let Some(seq) = s.active_sequence() else { return Vec::new() }; let sr = seq.settings.sample_rate.max(1); let provider = s.media.provider(s.project.clone(), s.services.clone()); @@ -1065,7 +1065,7 @@ fn clip_peaks(s: &Session, clips: &[filmcraft_project::ClipId]) -> Vec<(filmcraf let mut pos = s0; while pos < s0 + len { let n = (s0 + len - pos).min(sr as i64) as usize; - if let Ok(b) = src.audio(pos, n, sr) { + if let Ok(b) = src.audio_stream(it.audio_stream, pos, n, sr) { peak = b.peaks().into_iter().fold(peak, f32::max); } pos += n as i64; From d1b83b9c9761b30bc135c6f49bac7c8c244ef1d8 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:14:36 +0200 Subject: [PATCH 56/64] Linked slip: clamp to one shared delta so picture and sound stay aligned (#529) --- crates/engine/src/clip_ops_tests.rs | 23 +++++++++++++++++++++++ crates/engine/src/commands.rs | 10 +++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/clip_ops_tests.rs b/crates/engine/src/clip_ops_tests.rs index 9fc46de9..0f53b880 100644 --- a/crates/engine/src/clip_ops_tests.rs +++ b/crates/engine/src/clip_ops_tests.rs @@ -901,3 +901,26 @@ fn add_edit_cuts_only_the_selected_clips() { let r = s.execute("sequence.addEdit", json!({"time": t.0})).unwrap(); assert!(r["cuts"].as_u64().unwrap() > 1, "{r}"); } + +#[test] +fn linked_slip_keeps_picture_and_sound_aligned_at_unequal_media_limits() { + let mut s = Session::default(); + s.execute("file.newProject", json!({"name": "Slip"})).unwrap(); + s.execute("file.newSequence", json!({"name": "Sequence", "fps": 25, "video": 1, "audio": 1, "width": 16, "height": 16})).unwrap(); + let r = s.execute("file.newOfflineFile", json!({"name": "Media", "seconds": 4, "fps": 25, "video": true, "audio": true})).unwrap(); + let item = r["item"].as_u64().unwrap(); + let fr = s.sequence_rate().tick_of(1); + let r = s + .execute("timeline.place", json!({"item": item, "track": "V1", "audioTrack": "A1", "time": 0, "sourceIn": fr.0 * 20, "duration": fr.0 * 60})) + .unwrap(); + let (v, a) = (r["clips"][0].as_u64().unwrap(), r["clips"][1].as_u64().unwrap()); + // lengthen only the picture so the pair has unequal tail handles + s.execute("sequence.linkedSelection", json!({"on": false})).unwrap(); + s.execute("timeline.trim", json!({"clip": v, "edge": "out", "deltaFrames": 20})).unwrap(); + s.execute("sequence.linkedSelection", json!({"on": true})).unwrap(); + s.execute("timeline.slip", json!({"clip": v, "deltaFrames": 30})).unwrap(); + assert_eq!(clip(&s, v).source_in, Tick(fr.0 * 20)); + assert_eq!(clip(&s, a).source_in, Tick(fr.0 * 20), "sound must not slip alone"); + s.execute("timeline.slip", json!({"clip": v, "deltaFrames": -10})).unwrap(); + assert_eq!((clip(&s, v).source_in, clip(&s, a).source_in), (Tick(fr.0 * 10), Tick(fr.0 * 10))); +} diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index b5e4c847..93c7cfb0 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -2203,8 +2203,16 @@ fn build() -> Vec { .unwrap_or_default(); let clips = with_links(s, &[c]); s.edit_sequence("Slip", |q, ctx, _| { + // clamp across all linked partners, then slip them by the common delta + let mut dd = d; for c in &clips { - edit::slip(q, *c, d, ctx)?; + let x = edit::slip(&mut q.clone(), *c, dd, ctx)?; + if x.abs() < dd.abs() { + dd = x; + } + } + for c in &clips { + edit::slip(q, *c, dd, ctx)?; } Ok(()) })?; From 96ae4c6244acd907b7e7e10d1eda420fccf6c4ec Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:14:54 +0200 Subject: [PATCH 57/64] Export settings: accept camelCase for nested snake_case fields (#531) --- crates/engine/src/export_tests.rs | 13 +++++++++++++ crates/engine/src/export_tools.rs | 20 +++++++++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/export_tests.rs b/crates/engine/src/export_tests.rs index a934969d..8974def2 100644 --- a/crates/engine/src/export_tests.rs +++ b/crates/engine/src/export_tests.rs @@ -64,6 +64,19 @@ fn export_integer_parameters_cannot_wrap_or_overflow() { assert!(s.execute("export.resolve", json!({"bitrateKbps":8000.0, "keyframeDistance":48.0})).is_ok(), "integer-valued floats are integers"); } +#[test] +fn nested_camel_case_settings_are_honoured() { + let mut s = demo(); + let camel = json!({"audio":{"sampleRate":96000}, "effects":{"loudness":{"enabled":true, "targetLufs":-16}}}); + let snake = json!({"audio":{"sample_rate":96000}, "effects":{"loudness":{"enabled":true, "target_lufs":-16}}}); + let camel = s.execute("export.resolve", json!({"format":"wav", "settings":camel})).unwrap(); + let snake = s.execute("export.resolve", json!({"format":"wav", "settings":snake})).unwrap(); + assert_eq!(camel["output"]["sampleRate"], 96000); + assert_eq!(camel["settings"]["audio"]["sample_rate"], 96000); + assert_eq!(camel["settings"]["effects"]["loudness"]["target_lufs"], -16.0); + assert_eq!(camel["settings"], snake["settings"]); +} + fn probe(path: &str) -> Option { let ffprobe = filmcraft_testkit::ffprobe_or_skip("export presets")?; let out = std::process::Command::new(ffprobe).args(["-v", "error", "-of", "json", "-show_format", "-show_streams", path]).output().unwrap(); diff --git a/crates/engine/src/export_tools.rs b/crates/engine/src/export_tools.rs index 7d3073f2..22dfabb7 100644 --- a/crates/engine/src/export_tools.rs +++ b/crates/engine/src/export_tools.rs @@ -129,12 +129,30 @@ fn parse_file(bytes: &[u8]) -> std::result::Result { // settings // --------------------------------------------------------------------------------------------- +/// `sampleRate` → `sample_rate`. +fn camel_to_snake(k: &str) -> String { + let mut out = String::with_capacity(k.len() + 2); + for c in k.chars() { + if c.is_ascii_uppercase() { + out.push('_'); + out.push(c.to_ascii_lowercase()); + } else { + out.push(c); + } + } + out +} + /// Deep-merge `patch` into `base` (objects merge, everything else replaces). fn merge(base: &mut Value, patch: &Value) { match (base, patch) { (Value::Object(b), Value::Object(p)) => { for (k, v) in p { - merge(b.entry(k.clone()).or_insert(Value::Null), v); + // Some nested settings structs serialize snake_case (`audio.sample_rate`); accept the documented camelCase + // spelling for them too instead of adding a second, ignored key next to the default. + let snake = camel_to_snake(k); + let key = if !b.contains_key(k) && b.contains_key(&snake) { snake } else { k.clone() }; + merge(b.entry(key).or_insert(Value::Null), v); } } (b, p) => *b = p.clone(), From 8337c1656ed9adc4df9abb93e7f9b713c5eb1a37 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:13:55 +0200 Subject: [PATCH 58/64] Text panel: word navigation ignores a selection the transcript no longer has (#546) --- crates/ui-egui/src/panels/keyboard.rs | 32 ++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/keyboard.rs b/crates/ui-egui/src/panels/keyboard.rs index 61332853..674769d6 100644 --- a/crates/ui-egui/src/panels/keyboard.rs +++ b/crates/ui-egui/src/panels/keyboard.rs @@ -478,7 +478,8 @@ fn text_panel(app: &mut FilmcraftApp, op: &str) -> Result { let n = words.len(); let paras = filmcraft_edit::transcript::paragraphs(&words, filmcraft_time::Tick::from_seconds_f64(1.5)); let para_of = |i: usize| paras.iter().position(|p| p.contains(&i)).unwrap_or(0); - let (anchor, cur) = app.ui.transcript_sel.unwrap_or_else(|| { + // a selection left over from a longer transcript (e.g. after `transcript.extract` over the control channel) counts as none, as when drawing + let (anchor, cur) = app.ui.transcript_sel.filter(|(a, b)| *a < n && *b < n).unwrap_or_else(|| { let i = filmcraft_edit::transcript::word_at(&words, app.session.playhead()).unwrap_or(0); (i, i) }); @@ -513,3 +514,32 @@ fn text_panel(app: &mut FilmcraftApp, op: &str) -> Result { app.session.set_playhead(words[to].start); Ok(json!({"selection": [anchor.min(to), anchor.max(to)], "word": to})) } + +#[cfg(test)] +mod transcript_selection_tests { + use serde_json::{Value, json}; + + #[test] + fn prev_word_after_the_transcript_shrank_under_the_selection_does_not_panic() { + let mut session = filmcraft_engine::Session::default(); + session.execute("file.openDemoProject", json!({})).unwrap(); + let a = session.active_sequence().unwrap().audio_tracks[0].items[0].clone(); + let tk = |s: f64| a.source_in.0 + (s * filmcraft_time::TICKS_PER_SECOND as f64) as i64; + let words: Vec = ["one", "two", "three", "four"] + .iter() + .enumerate() + .map(|(i, w)| json!({"text": w, "start": tk(0.5 + i as f64 * 0.5), "end": tk(0.9 + i as f64 * 0.5)})) + .collect(); + session.execute("transcript.set", json!({"item": a.item.0, "transcript": {"language": "en", "words": words}})).unwrap(); + let mut app = crate::FilmcraftApp::new(session); + app.ui.text_tab = "Transcript".into(); + app.ui.transcript_sel = Some((3, 3)); + // the transcript shrinks behind the panel's back, as with `transcript.extract` over the control channel + app.session.execute("transcript.extract", json!({"from": 0, "to": 1})).unwrap(); + assert_eq!(filmcraft_engine::transcript::sequence_words(&app.session).len(), 2); + let r = super::text_panel(&mut app, "prevWord").unwrap(); + let (sa, sb) = app.ui.transcript_sel.unwrap(); + assert!(sa < 2 && sb < 2, "selection {sa}..{sb} outside the 2-word transcript"); + assert_eq!(r["word"], json!(sb)); + } +} From b40964db91c9e4f4228726a67ba81b4a1c84806b Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:14:55 +0200 Subject: [PATCH 59/64] masks.addVertex: clamp oversized index on animated paths, reject empty paths instead of panicking (#543) --- crates/engine/src/masks.rs | 25 +++++++++++++++++------- crates/engine/src/masks_tests.rs | 33 ++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/masks.rs b/crates/engine/src/masks.rs index cc268ab2..2a53cfdf 100644 --- a/crates/engine/src/masks.rs +++ b/crates/engine/src/masks.rs @@ -366,23 +366,34 @@ fn add_vertex(s: &mut Session, p: &Value) -> Result { let after = u64_p(p, "after").ok_or_else(|| bad("masks.addVertex", "need `after`"))? as usize; let at = p.get("at").and_then(pt).ok_or_else(|| bad("masks.addVertex", "need `at`"))?; edit_mask(s, p, "Add Mask Vertex", move |m, t| { + let n = m.path_at(t).vertices.len(); + // clamp against the length before any insertion, so the index stays valid in the grown path + let i = (after + 1).min(n); if m.path.is_animated() && m.path.keyframes.len() > 1 { + if n == 0 { + return Err(bad("masks.addVertex", "the animated mask path has no vertices to insert after")); + } // keep every keyframe interpolable: insert the vertex into all of them for k in &mut m.path.keyframes { if let ParamValue::Path(path) = &mut k.value { - let i = (after + 1).min(path.vertices.len()); - let a = path.vertices[after.min(path.vertices.len() - 1)].p; - let b = path.vertices[i % path.vertices.len()].p; - path.vertices.insert(i, MaskVertex::corner(a.lerp(b, 0.5))); + let len = path.vertices.len(); + if len == 0 { + continue; + } + let at_i = i.min(len); + let a = path.vertices[after.min(len - 1)].p; + let b = path.vertices[at_i % len].p; + path.vertices.insert(at_i, MaskVertex::corner(a.lerp(b, 0.5))); } } } let mut path = m.path_at(t); - let i = (after + 1).min(path.vertices.len()); if m.path.keyframes.len() > 1 { - path.vertices[i] = MaskVertex::corner(at); + if let Some(v) = path.vertices.get_mut(i) { + *v = MaskVertex::corner(at); + } } else { - path.vertices.insert(i, MaskVertex::corner(at)); + path.vertices.insert(i.min(path.vertices.len()), MaskVertex::corner(at)); } set_path_at(m, t, path); Ok(()) diff --git a/crates/engine/src/masks_tests.rs b/crates/engine/src/masks_tests.rs index cce70f7f..549a64fc 100644 --- a/crates/engine/src/masks_tests.rs +++ b/crates/engine/src/masks_tests.rs @@ -106,6 +106,39 @@ fn keyframed_mask_path_interpolates() { assert_eq!(a["masks"][0]["path"]["vertices"].as_array().unwrap().len(), 4); } +#[test] +fn add_vertex_on_animated_path_clamps_oversized_index_and_rejects_empty_path() { + let (mut s, clip) = demo(); + let it = s.active_sequence().unwrap().find_item(clip).unwrap().1.clone(); + let rate = s.active_sequence().unwrap().settings.frame_rate; + 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(); + s.set_playhead(it.start + rate.tick_of(10)); + s.execute("masks.translate", json!({"delta": [5, 0]})).unwrap(); + // an index past the last vertex appends instead of panicking + s.execute("masks.addVertex", json!({"after": 100, "at": [1, 1]})).unwrap(); + let l = s.execute("masks.list", json!({})).unwrap(); + assert_eq!(l["masks"][0]["path"]["vertices"].as_array().unwrap().len(), 5); + + // an empty animated path is a parameter error and leaves the mask alone + s.set_playhead(it.start); + s.execute("masks.add", json!({"effect": "opacity", "path": {"vertices": [], "closed": false}})).unwrap(); + s.execute("effects.toggleAnimation", json!({"clip": clip.0, "effect": "opacity", "mask": 1, "param": "path"})).unwrap(); + s.set_playhead(it.start + rate.tick_of(10)); + s.execute("masks.translate", json!({"mask": 1, "delta": [5, 0]})).unwrap(); + let e = s.execute("masks.addVertex", json!({"mask": 1, "after": 0, "at": [1, 1]})).unwrap_err().to_string(); + assert!(e.contains("no vertices"), "{e}"); + let l = s.execute("masks.list", json!({})).unwrap(); + assert_eq!(l["masks"][1]["path"]["vertices"].as_array().unwrap().len(), 0); + + // a static empty path still takes its first vertex + s.execute("masks.add", json!({"effect": "opacity", "path": {"vertices": [], "closed": false}})).unwrap(); + s.execute("masks.addVertex", json!({"mask": 2, "after": 0, "at": [1, 1]})).unwrap(); + let l = s.execute("masks.list", json!({})).unwrap(); + assert_eq!(l["masks"][2]["path"]["vertices"].as_array().unwrap().len(), 1); +} + #[test] fn merged_drag_is_one_undo_step_and_project_round_trips() { let (mut s, _) = demo(); From f684440ef5751e1d83ea1743cbda613248c375f3 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:14:36 +0200 Subject: [PATCH 60/64] Trim: extend each selected edit point to the playhead by its own distance (#528) --- crates/engine/src/trim.rs | 17 ++++++++++------- crates/engine/src/trim_tests.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/trim.rs b/crates/engine/src/trim.rs index 5b73786e..dda7a4d1 100644 --- a/crates/engine/src/trim.rs +++ b/crates/engine/src/trim.rs @@ -169,7 +169,7 @@ pub fn toggle_type(s: &mut Session) -> Result { /// Trim every selected edit point by `delta` (edit point movement; positive = later). fn trim_all(s: &mut Session, delta: Tick) -> Result { - let applied = trim_all_raw(s, delta)?; + let applied = trim_all_raw(s, &|_, _| Some(delta))?; let first = applied.first().and_then(Value::as_i64).map(Tick).unwrap_or_default(); add_shift(s, first); Ok(json!({"applied": applied})) @@ -193,7 +193,8 @@ fn reset_shift(s: &mut Session) { s.state.trim_shift = TrimShift::default(); } -fn trim_all_raw(s: &mut Session, delta: Tick) -> Result> { +/// Trim every selected edit point; `delta_for` gives each point's own movement, read from the sequence as it is when that point is reached. +fn trim_all_raw(s: &mut Session, delta_for: &dyn Fn(&Sequence, &EditPoint) -> Option) -> Result> { if s.state.edit_points.is_empty() { return Err(EngineError::Other("no edit points selected".into())); } @@ -201,6 +202,7 @@ fn trim_all_raw(s: &mut Session, delta: Tick) -> Result> { let mut applied = Vec::new(); for ep in &pts { let seq = s.active_sequence().ok_or(EngineError::NoSequence)?; + let Some(delta) = delta_for(seq, ep) else { continue }; let r = match ep.kind { TrimKind::Roll => { let Some((l, r)) = roll_pair(seq, ep) else { continue }; @@ -226,9 +228,10 @@ pub fn nudge(s: &mut Session, p: &Value) -> Result { /// `trim.extendToPlayhead`: move each selected edit point to the playhead. pub fn extend_to_playhead(s: &mut Session) -> Result { let ph = s.playhead(); - let seq = s.active_sequence().ok_or(EngineError::NoSequence)?; - let first = s.state.edit_points.first().and_then(|e| edit_time(seq, e)).ok_or_else(|| EngineError::Other("no edit points selected".into()))?; - trim_all(s, ph - first) + let applied = trim_all_raw(s, &|seq, e| edit_time(seq, e).map(|t| ph - t))?; + let first = applied.first().and_then(Value::as_i64).map(Tick).unwrap_or_default(); + add_shift(s, first); + Ok(json!({"applied": applied})) } /// `trim.toPlayhead {side: "previous"|"next", ripple}` (Q/W, ⌥Q/⌥W): remove the material between @@ -502,14 +505,14 @@ fn apply_dynamic(s: &mut Session, offset: Tick) -> Result { let redo = std::mem::take(&mut s.history.redo); s.history.limit = usize::MAX; s.project = d.before.clone(); - let r = if offset == Tick::ZERO { Ok(vec![json!(0)]) } else { trim_all_raw(s, offset) }; + let r = if offset == Tick::ZERO { Ok(vec![json!(0)]) } else { trim_all_raw(s, &|_, _| Some(offset)) }; let applied = match r { Ok(a) => a.first().and_then(Value::as_i64).map(Tick).unwrap_or_default(), Err(_) => { // refused outright (e.g. it would break sync): stay where we were s.project = d.before.clone(); if d.offset != Tick::ZERO { - let _ = trim_all_raw(s, d.offset); + let _ = trim_all_raw(s, &|_, _| Some(d.offset)); } d.offset } diff --git a/crates/engine/src/trim_tests.rs b/crates/engine/src/trim_tests.rs index bbeb147f..25735712 100644 --- a/crates/engine/src/trim_tests.rs +++ b/crates/engine/src/trim_tests.rs @@ -210,3 +210,29 @@ fn trim_monitor_sides_and_shift_counters() { let seq = s.execute("sequence.inspect", json!({})).unwrap(); assert!(!seq["video"][0]["transitions"].as_array().unwrap().is_empty()); } + +#[test] +fn extend_to_playhead_moves_each_unequal_out_point_to_the_playhead() { + let mut s = demo(); + s.execute("sequence.linkedSelection", json!({"on": false})).unwrap(); + let rate = s.sequence_rate(); + let q = s.active_sequence().unwrap(); + // a clip on V1 and the overlay on V2 that overlap in time but end at different frames + let mut found = None; + for a in &q.video_tracks[0].items { + for b in &q.video_tracks[1].items { + let (lo, hi) = (a.start.max(b.start), a.end().min(b.end())); + if found.is_none() && a.end() != b.end() && hi - lo > rate.tick_of(2) { + found = Some((a.id, b.id, lo + rate.tick_of(1))); + } + } + } + let (a, b, ph) = found.expect("demo has overlapping clips on V1 and V2 with different ends"); + s.execute("playhead.set", json!({"time": ph.0})).unwrap(); + s.execute("trim.selectEditPoint", json!({"clip": a.0, "edge": "out", "kind": "trim"})).unwrap(); + s.execute("trim.selectEditPoint", json!({"clip": b.0, "edge": "out", "kind": "trim", "add": true})).unwrap(); + s.execute("trim.extendToPlayhead", json!({})).unwrap(); + let q = s.active_sequence().unwrap(); + assert_eq!(q.find_item(a).unwrap().1.end(), ph); + assert_eq!(q.find_item(b).unwrap().1.end(), ph); +} From 5b6e1ce720bd3918663d16a9006f6e4938212e27 Mon Sep 17 00:00:00 2001 From: Max Murphy Date: Sat, 10 Oct 2026 03:19:38 -0400 Subject: [PATCH 61/64] fix(export): Selection as Project exports a selected bin's contents Selecting a nonempty bin reported success with items:[bin] while writing an empty project: closure() kept the bin id (which names no item) and project_subset() then pruned everything. Expand a named non-root bin to the items it holds, as project.delete does, so only real items are kept and reported. An empty bin now returns an actionable error instead of a misleading empty destination. --- crates/engine/src/project_tools.rs | 23 +++++++++- crates/engine/src/project_tools_tests.rs | 53 ++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/project_tools.rs b/crates/engine/src/project_tools.rs index 0dce9fdc..c0390d9d 100644 --- a/crates/engine/src/project_tools.rs +++ b/crates/engine/src/project_tools.rs @@ -24,7 +24,7 @@ use std::collections::BTreeSet; -use filmcraft_project::{BinEntry, FindOp, FindQuery, FindRow, ItemId, ItemKind, MarkerKind, MediaRef, Project, SearchBin, TrackKind}; +use filmcraft_project::{BinEntry, BinId, FindOp, FindQuery, FindRow, ItemId, ItemKind, MarkerKind, MediaRef, Project, SearchBin, TrackKind}; use filmcraft_time::{Tick, TimeDisplay, TimeRange}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -699,14 +699,30 @@ fn new_from_template(s: &mut Session, p: &Value) -> Result { /// The selected items plus everything they need: sequence contents (recursively), subclip /// parents, multi-camera / merged-clip sources and graphic canvases. +/// +/// Bins and items share one id space, so a named bin contributes every item it holds (and +/// everything those items need), as `project.delete` does. The project's own top bin does not, +/// since no command acts on it (#244), and only ids that name a real item end up in the result. pub fn closure(p: &Project, roots: &[ItemId]) -> BTreeSet { let mut keep: BTreeSet = BTreeSet::new(); let mut todo: Vec = roots.to_vec(); + let mut seen_bins: BTreeSet = BTreeSet::new(); while let Some(id) = todo.pop() { - if !keep.insert(id) { + let bin = BinId(id.0); + if bin != p.root.id && p.root.find_bin(bin).is_some() { + if seen_bins.insert(id.0) + && let Some(b) = p.root.find_bin(bin) + { + let mut items = Vec::new(); + b.all_items(&mut items); + todo.extend(items); + } continue; } let Some(it) = p.item(id) else { continue }; + if !keep.insert(id) { + continue; + } match &it.kind { ItemKind::Subclip { parent, .. } => todo.push(*parent), ItemKind::Sequence(q) => { @@ -754,6 +770,9 @@ fn export_selection(s: &mut Session, p: &Value) -> Result { return Err(bad("file.exportSelectionProject", "select items in the Project panel")); } let keep = closure(&s.project, &roots); + if keep.is_empty() { + return Err(bad("file.exportSelectionProject", "the selection has no items to export")); + } let mut sub = project_subset(&s.project, &keep); let name = std::path::Path::new(&path).file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| sub.name.clone()); sub.name = name.clone(); diff --git a/crates/engine/src/project_tools_tests.rs b/crates/engine/src/project_tools_tests.rs index 11b7bd49..e00af732 100644 --- a/crates/engine/src/project_tools_tests.rs +++ b/crates/engine/src/project_tools_tests.rs @@ -220,6 +220,59 @@ fn export_selection_project_and_ale() { assert!(doc.rows.iter().any(|r| r[0] == "Ambient_Score.wav" && r[1] == "A1A2")); } +#[test] +fn export_selection_project_of_a_bin_exports_its_contents() { + let mut s = demo(); + let (footage_bin, bin_items) = s + .project + .root + .children + .iter() + .find_map(|c| match c { + filmcraft_project::BinEntry::Bin(b) if b.name == "Footage" => { + let mut items = Vec::new(); + b.all_items(&mut items); + Some((b.id.0, items)) + } + _ => None, + }) + .expect("Footage bin"); + assert_eq!(bin_items.len(), 6); + + // Selecting the bin (its id, not its items) must export the bin's contents, not an empty project. + s.execute("project.select", json!({"items": [footage_bin]})).unwrap(); + let dir = tmp_dir("export-bin"); + let path = dir.join("Footage.fcproj").to_string_lossy().to_string(); + let r = s.execute("file.exportSelectionProject", json!({"path": path})).unwrap(); + let p = filmcraft_format::decode(&std::fs::read(&path).unwrap()).unwrap().project; + assert_eq!(r["items"].as_array().unwrap().len(), p.items.len(), "reported items match the saved project"); + let reported: std::collections::BTreeSet = r["items"].as_array().unwrap().iter().map(|v| ItemId(v.as_u64().unwrap())).collect(); + assert_eq!(reported, bin_items.iter().copied().collect(), "the bin's items are the exported items"); + for n in ["Ocean_Sunset.mp4", "Neon_Loop.mov", "Misty_Forest.mp4"] { + assert!(p.items.values().any(|i| i.name == n), "{n}"); + } + assert!(!p.items.values().any(|i| i.name == "Ambient_Score.wav"), "the Audio bin stayed behind"); + let mut listed = Vec::new(); + p.root.all_items(&mut listed); + assert!(p.items.keys().all(|k| listed.contains(k) || matches!(p.item(*k).unwrap().kind, ItemKind::Graphic { .. }))); + + // A mixed bin + item request must not report the bin id, which names no item in the output. + let explicit = dir.join("Mixed.fcproj").to_string_lossy().to_string(); + let r = s.execute("file.exportSelectionProject", json!({"items": [footage_bin, bin_items[0].0], "path": explicit})).unwrap(); + let p = filmcraft_format::decode(&std::fs::read(&explicit).unwrap()).unwrap().project; + for v in r["items"].as_array().unwrap() { + let id = ItemId(v.as_u64().unwrap()); + assert!(p.item(id).is_some(), "reported item {id:?} is in the output"); + } + + // An empty bin has nothing to export: an actionable error, no misleading empty file. + let empty_bin = s.execute("file.newBin", json!({"name": "Empty"})).unwrap()["bin"].as_u64().unwrap(); + s.execute("project.select", json!({"items": [empty_bin]})).unwrap(); + let empty_path = dir.join("Empty.fcproj").to_string_lossy().to_string(); + assert!(s.execute("file.exportSelectionProject", json!({"path": empty_path})).is_err()); + assert!(!std::path::Path::new(&empty_path).exists(), "no empty project written"); +} + #[test] fn media_properties_settings_and_scratch_disks() { let dir = tmp_dir("props"); From ab9e4e6cebf1938d903fdff28161763a31d3ac40 Mon Sep 17 00:00:00 2001 From: ReReddo <249398616+ReReddo@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:35:44 +0300 Subject: [PATCH 62/64] timeline, monitors: drag the current-time timecode to scrub, click to type a time (#554) The Timeline's timecode and the Program and Source monitors' current-time timecodes were display-only. They now share one widget, widgets::timecode_field, which replaces the unused timecode_label: - Dragging scrubs that panel's playhead, 1 frame per point (Shift x10), and stops at the start (for the Source monitor, the clip's start). The pointer is hidden while dragging and put back where it was pressed. - A click opens a text field with the whole value selected. Enter or clicking away goes to the typed time through parse_timecode, so `1000` and `+15` work. Escape cancels. An invalid time is reported in the status bar. Left/Right on the selected value start from its last digit. The Timeline and Program monitor use playhead.set, the Source monitor source.setPlayhead. Tests: timecode_ui (5 tests). The ui-egui suite passes except the two Windows CRLF failures fixed in #488. --- crates/ui-egui/src/panels/monitor.rs | 15 ++- crates/ui-egui/src/panels/timeline.rs | 11 +- crates/ui-egui/src/widgets.rs | 108 +++++++++++++++- crates/ui-egui/tests/timecode_ui.rs | 176 ++++++++++++++++++++++++++ 4 files changed, 302 insertions(+), 8 deletions(-) create mode 100644 crates/ui-egui/tests/timecode_ui.rs diff --git a/crates/ui-egui/src/panels/monitor.rs b/crates/ui-egui/src/panels/monitor.rs index 39034bfa..42e8a6e6 100644 --- a/crates/ui-egui/src/panels/monitor.rs +++ b/crates/ui-egui/src/panels/monitor.rs @@ -305,8 +305,19 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) // ---- controls row: timecode | zoom | res | wrench | duration let row1 = Rect::from_min_size(pos2(rect.min.x + 14.0, rect.max.y - controls_h + 2.0), vec2(rect.width() - 28.0, 26.0)); let tc = format_time(time, rate, drop_frame, TimeDisplay::Timecode, 48000); - ui.painter().text(pos2(row1.min.x, row1.center().y), Align2::LEFT_CENTER, &tc, Tokens::timecode(), t.timecode); - app.auto.add(&format!("{prefix}.timecode"), Rect::from_min_size(row1.min, vec2(110.0, row1.height())), &tc); + let tc_rect = Rect::from_min_size(row1.min, vec2(110.0, row1.height())); + let min = if which == Which::Program { Tick::ZERO } else { origin }; + match crate::widgets::timecode_field(ui, egui::Id::new((prefix, "timecode")), tc_rect, &tc, time, min, rate, drop_frame, t.timecode) { + Some(Ok(to)) => { + let cmd = if which == Which::Program { "playhead.set" } else { "source.setPlayhead" }; + if let Err(e) = app.session.execute(cmd, json!({"time": to.0})) { + app.ui.status = e.to_string(); + } + } + Some(Err(e)) => app.ui.status = e, + None => {} + } + app.auto.add(&format!("{prefix}.timecode"), tc_rect, &tc); let dur_tc = format_time(mark_out.map(|o| o + rate.frame_duration()).unwrap_or(duration) - range_start, rate, drop_frame, TimeDisplay::Timecode, 48000); ui.painter().text(pos2(row1.max.x, row1.center().y), Align2::RIGHT_CENTER, &dur_tc, Tokens::timecode(), t.text_dim); // zoom + resolution dropdowns centred-ish diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 811a75ee..8e186ac1 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1108,7 +1108,16 @@ fn draw_top(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq: &Sequenc // current timecode: Premiere's big blue timecode let tc = format_time(app.session.playhead(), rate, seq.settings.drop_frame, TimeDisplay::Timecode, seq.settings.sample_rate as i64); let tc_rect = Rect::from_min_size(pos2(rect.min.x + 14.0, rect.min.y + 4.0), vec2(hw - 20.0, 20.0)); - p.text(pos2(tc_rect.min.x, tc_rect.center().y), Align2::LEFT_CENTER, &tc, Tokens::timecode(), t.timecode); + let tc_id = egui::Id::new(("timeline-tc", seq_id)); + match crate::widgets::timecode_field(ui, tc_id, tc_rect, &tc, app.session.playhead(), Tick::ZERO, rate, seq.settings.drop_frame, t.timecode) { + Some(Ok(time)) => { + if let Err(e) = app.session.execute("playhead.set", json!({"time": time.0})) { + app.ui.status = e.to_string(); + } + } + Some(Err(e)) => app.ui.status = e, + None => {} + } app.auto.add("timeline.timecode", tc_rect, &tc); // toolbar: 30 × 30 buttons, "on" = #4b4b4b fill let mut x = rect.min.x + 12.0; diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 26b5fe67..d57b01eb 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1,6 +1,7 @@ //! Small custom widgets in Premiere's visual language. use egui::{Align2, Color32, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use filmcraft_time::{FrameRate, Tick, parse_timecode}; use crate::icons::{self, Icon}; use crate::theme::Tokens; @@ -58,11 +59,108 @@ pub fn hot_number(ui: &mut Ui, id: egui::Id, value: f64, speed: f64, range: (f64 (resp, out) } -/// A large blue timecode readout (monitors/timeline). Click to type a new time. -pub fn timecode_label(ui: &mut Ui, rect: Rect, text: &str, size: f32, color: Color32, align: Align2) -> Response { - let resp = ui.interact(rect, ui.id().with(("tc", rect.min.x as i32, rect.min.y as i32)), Sense::click_and_drag()); - ui.painter().text(align.pos_in_rect(&rect), align, text, Tokens::mono(size), color); - resp +/// A large blue timecode readout (monitors/timeline) that moves its playhead: drag horizontally to +/// scrub (1 frame per point, Shift ×10), click to type a time (Enter or clicking away commits, +/// Escape cancels). Returns the time to go to (never before `min`), or why a typed time is invalid. +#[allow(clippy::too_many_arguments)] +pub fn timecode_field( + ui: &mut Ui, + id: egui::Id, + rect: Rect, + text: &str, + time: Tick, + min: Tick, + rate: FrameRate, + drop_frame: bool, + color: Color32, +) -> Option> { + let editing_id = id.with("editing"); + let edit_id = id.with("edit"); + let select_all_id = id.with("select-all"); + if let Some(mut buf) = ui.data(|d| d.get_temp::(editing_id)) { + // Left/Right on the whole selected value start from its last digit (egui would jump to the start) + let arrow = ui.input(|i| i.modifiers.is_none() && (i.key_pressed(egui::Key::ArrowLeft) || i.key_pressed(egui::Key::ArrowRight))); + if arrow && let Some(mut st) = egui::text_edit::TextEditState::load(ui.ctx(), edit_id) { + let len = buf.chars().count(); + let all = [egui::text::CCursor::new(0), egui::text::CCursor::new(len)]; + if len > 0 && st.cursor.char_range().is_some_and(|c| c.sorted_cursors() == all) { + st.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(len)))); + st.store(ui.ctx(), edit_id); + } + } + let r = ui.put(rect, egui::TextEdit::singleline(&mut buf).id(edit_id).font(Tokens::timecode()).desired_width(rect.width())); + if r.lost_focus() { + ui.data_mut(|d| d.remove::(editing_id)); + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + return None; + } + let typed = parse_timecode(&buf, rate, drop_frame, rate.frame_at(time)); + return Some(typed.map(|f| rate.tick_of(f).max(min)).map_err(|e| e.to_string())); + } + // select the whole value once the field has focus: egui collapses a selection to a caret + // in a field that does not have it yet + let selected_now = r.has_focus() && ui.data_mut(|d| d.remove_temp::(select_all_id)).is_some(); + if selected_now { + let mut st = egui::text_edit::TextEditState::load(ui.ctx(), edit_id).unwrap_or_default(); + st.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(buf.chars().count())))); + st.store(ui.ctx(), edit_id); + } + // the focus and the selection show on the next frame, which nothing else would trigger + if !r.has_focus() || selected_now { + ui.ctx().request_repaint(); + } + ui.data_mut(|d| d.insert_temp(editing_id, buf)); + // only until it has focus: `request_focus` resets the field's focus-lock filter, and the + // arrow keys would then move focus out of the field (committing it) instead of the caret + if !r.has_focus() { + r.request_focus(); + } + return None; + } + let resp = ui.interact(rect, id, Sense::click_and_drag()); + ui.painter().text(pos2(rect.min.x, rect.center().y), Align2::LEFT_CENTER, text, Tokens::timecode(), color); + // (frame at the press, frames dragged so far, where the pointer was pressed) + let drag_id = id.with("drag"); + let mult = if ui.input(|i| i.modifiers.shift) { 10.0 } else { 1.0 }; + if resp.drag_started() { + let origin = ui.input(|i| i.pointer.press_origin()).unwrap_or(rect.center()); + let now = resp.interact_pointer_pos().unwrap_or(origin); + // egui reports a drag only past its click threshold: count the movement before that, + // less this frame's delta, which the `dragged` branch below adds + let early = f64::from(now.x - origin.x - resp.drag_delta().x) * mult; + ui.data_mut(|d| d.insert_temp(drag_id, (rate.frame_at(time), early, origin))); + } + let mut out = None; + if resp.dragged() { + // hidden while scrubbing, put back where it was pressed on release (below) + ui.ctx().set_cursor_icon(egui::CursorIcon::None); + if let Some((start, acc, origin)) = ui.data(|d| d.get_temp::<(i64, f64, egui::Pos2)>(drag_id)) { + // held at `min` so dragging back moves again at once + let acc = (acc + f64::from(resp.drag_delta().x) * mult).max((rate.frame_at(min) - start) as f64); + ui.data_mut(|d| d.insert_temp(drag_id, (start, acc, origin))); + let target = rate.tick_of(start.saturating_add(acc.trunc() as i64)).max(min); + if target != time { + out = Some(Ok(target)); + } + } + } else if resp.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); + } + if resp.drag_stopped() + && let Some((_, _, origin)) = ui.data_mut(|d| d.remove_temp::<(i64, f64, egui::Pos2)>(drag_id)) + { + ui.ctx().send_viewport_cmd(egui::ViewportCommand::CursorPosition(origin)); + } + if resp.clicked() { + // the field asks for focus itself next frame: asking now, before it exists, leaves the + // accessibility tree focused on a node it does not have + ui.data_mut(|d| { + d.insert_temp(editing_id, text.to_string()); + d.insert_temp(select_all_id, true); + }); + ui.ctx().request_repaint(); + } + out } /// A twirl-down section header (Effect Controls / Lumetri style). Returns open state. diff --git a/crates/ui-egui/tests/timecode_ui.rs b/crates/ui-egui/tests/timecode_ui.rs new file mode 100644 index 00000000..badc3536 --- /dev/null +++ b/crates/ui-egui/tests/timecode_ui.rs @@ -0,0 +1,176 @@ +//! The Timeline's and monitors' current-time timecodes, driven headless through the control +//! channel: dragging scrubs the playhead (1 frame per point, never before the start), a click +//! turns the timecode into a field where Enter goes to the typed time and Escape cancels. + +use std::sync::mpsc::{Sender, channel}; + +use egui_kittest::Harness; +use filmcraft_engine::Session; +use filmcraft_time::{FrameRate, parse_timecode}; +use filmcraft_ui_egui::FilmcraftApp; +use filmcraft_ui_egui::control::ControlRequest; +use serde_json::{Value, json}; + +struct Driver { + harness: Harness<'static, FilmcraftApp>, + tx: Sender, +} + +impl Driver { + fn demo() -> Self { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).expect("demo project"); + session.execute("source.open", json!({"item": 5})).expect("source clip"); + let (tx, rx) = channel(); + let app = FilmcraftApp::new(session).with_control(rx); + let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000).build_eframe(move |_cc| app); + let mut d = Driver { harness, tx }; + d.frames(4); + d + } + fn frames(&mut self, n: usize) { + for _ in 0..n { + let ctx = self.harness.ctx.clone(); + let mut raw = std::mem::take(self.harness.input_mut()); + eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw); + *self.harness.input_mut() = raw; + self.harness.step(); + } + } + fn ok(&mut self, method: &str, params: Value) -> Value { + let (req, reply) = ControlRequest::new(method, params.clone()); + self.tx.send(req).unwrap(); + for _ in 0..600 { + self.frames(1); + if let Ok(r) = reply.try_recv() { + assert_eq!(r["ok"], true, "{method} {params}: {r}"); + self.frames(2); + return r["result"].clone(); + } + } + panic!("no reply to {method} {params}"); + } + /// Centre of an element, from `ui.elements`. + fn centre(&mut self, id: &str) -> (f64, f64) { + let v = self.ok("ui.elements", json!({"prefix": id})); + let e = v.as_array().unwrap().iter().find(|e| e["id"] == id).unwrap_or_else(|| panic!("no element {id}")).clone(); + let r: Vec = e["rect"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect(); + (r[0] + r[2] / 2.0, r[1] + r[3] / 2.0) + } + fn label(&mut self, id: &str) -> String { + let v = self.ok("ui.elements", json!({"prefix": id})); + v.as_array().unwrap().iter().find(|e| e["id"] == id).and_then(|e| e["label"].as_str()).unwrap_or_default().to_string() + } + fn drag_by(&mut self, id: &str, dx: f64) { + let (x, y) = self.centre(id); + self.ok("ui.drag", json!({"from": {"x": x, "y": y}, "to": {"x": x + dx, "y": y}})); + } + fn type_into(&mut self, id: &str, text: &str, key: &str) { + self.ok("ui.click", json!({"id": id})); + self.ok("ui.type", json!({"text": text})); + self.ok("ui.key", json!({"key": key})); + } + fn frame(&self) -> i64 { + let s = &self.harness.state().session; + s.sequence_rate().frame_at(s.playhead()) + } + fn source_rate(&self) -> FrameRate { + filmcraft_engine::clip_ops::source_view(&self.harness.state().session, filmcraft_project::ItemId(5)).unwrap().rate + } + fn source_frame(&self) -> i64 { + self.source_rate().frame_at(self.harness.state().session.state.source_playhead) + } + fn set_frame(&mut self, f: i64) { + self.harness.state_mut().session.execute("playhead.set", json!({"frame": f})).unwrap(); + self.frames(2); + } +} + +#[test] +fn dragging_the_timeline_timecode_scrubs_and_stops_at_the_start() { + let mut d = Driver::demo(); + let before = d.harness.state().session.project.to_json(); + d.set_frame(30); + d.drag_by("timeline.timecode", 20.0); + assert_eq!(d.frame(), 50, "1 frame per point"); + d.drag_by("timeline.timecode", -12.0); + assert_eq!(d.frame(), 38); + d.drag_by("timeline.timecode", -300.0); + assert_eq!(d.frame(), 0, "never before the start"); + assert_eq!(d.harness.state().session.project.to_json(), before, "scrubbing does not edit the project"); +} + +#[test] +fn typing_in_the_timeline_timecode_goes_to_that_time() { + let mut d = Driver::demo(); + d.set_frame(30); + d.type_into("timeline.timecode", "1000", "Enter"); + let (rate, df) = { + let s = &d.harness.state().session; + (s.sequence_rate(), s.active_sequence().unwrap().settings.drop_frame) + }; + assert_eq!(d.frame(), parse_timecode("1000", rate, df, 0).unwrap(), "the whole value was selected and replaced"); + d.type_into("timeline.timecode", "+15", "Enter"); + assert_eq!(d.frame(), parse_timecode("1000", rate, df, 0).unwrap() + 15, "relative to the current time"); +} + +#[test] +fn arrow_keys_move_the_caret_instead_of_committing() { + let mut d = Driver::demo(); + let (rate, df) = { + let s = &d.harness.state().session; + (s.sequence_rate(), s.active_sequence().unwrap().settings.drop_frame) + }; + // Left on the selected value lands before its last digit: Delete removes that digit + d.set_frame(30); + let tc = d.label("timeline.timecode"); + d.ok("ui.click", json!({"id": "timeline.timecode"})); + d.ok("ui.key", json!({"key": "Left"})); + assert_eq!(d.frame(), 30, "Left neither commits nor steps the playhead"); + d.ok("ui.key", json!({"key": "Delete"})); + d.ok("ui.key", json!({"key": "Enter"})); + let mut expect = tc.clone(); + expect.pop(); + assert_eq!(d.frame(), parse_timecode(&expect, rate, df, 0).unwrap(), "{tc} without its last digit"); + // Right goes to the end: Backspace removes the last digit, then a new one is typed + d.set_frame(30); + d.ok("ui.click", json!({"id": "timeline.timecode"})); + d.ok("ui.key", json!({"key": "Right"})); + d.ok("ui.key", json!({"key": "Backspace"})); + d.ok("ui.type", json!({"text": "9"})); + d.ok("ui.key", json!({"key": "Enter"})); + let mut expect = tc.clone(); + expect.pop(); + expect.push('9'); + assert_eq!(d.frame(), parse_timecode(&expect, rate, df, 0).unwrap(), "{tc} with its last digit replaced"); +} + +#[test] +fn escape_cancels_and_a_bad_time_is_reported_without_moving() { + let mut d = Driver::demo(); + d.set_frame(30); + d.type_into("timeline.timecode", "1000", "Escape"); + assert_eq!(d.frame(), 30, "Escape cancels"); + d.type_into("timeline.timecode", "abc", "Enter"); + assert_eq!(d.frame(), 30); + assert!(d.harness.state().ui.status.contains("timecode"), "status: {}", d.harness.state().ui.status); + // after a cancel or an error the readout scrubs again + d.drag_by("timeline.timecode", 10.0); + assert_eq!(d.frame(), 40); +} + +#[test] +fn the_program_and_source_timecodes_move_their_own_playheads() { + let mut d = Driver::demo(); + d.set_frame(30); + d.drag_by("program.timecode", 20.0); + assert_eq!(d.frame(), 50); + let program = d.harness.state().session.playhead(); + let source = d.source_frame(); + d.drag_by("source.timecode", 20.0); + assert_eq!(d.source_frame(), source + 20); + assert_eq!(d.harness.state().session.playhead(), program, "the Source timecode leaves the sequence alone"); + d.type_into("source.timecode", "100", "Enter"); + assert_eq!(d.source_frame(), parse_timecode("100", d.source_rate(), false, 0).unwrap(), "1 s at the clip's own rate"); + assert_eq!(d.harness.state().session.playhead(), program); +} From 0423916667336206908505f83392df65693835b4 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 04:51:01 -0400 Subject: [PATCH 63/64] Marker CSV export test: use egui_kittest's wgpu() builder (with_wgpu_render does not exist in our version) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/tests/export_menu_ui.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/tests/export_menu_ui.rs b/crates/ui-egui/tests/export_menu_ui.rs index 75122089..6fe109df 100644 --- a/crates/ui-egui/tests/export_menu_ui.rs +++ b/crates/ui-egui/tests/export_menu_ui.rs @@ -31,7 +31,7 @@ impl Driver { })); let mut builder = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000); if std::env::var_os("FILMCRAFT_UI_SNAPSHOT_DIR").is_some() { - builder = builder.with_wgpu_render(); + builder = builder.wgpu(); } let harness = builder.build_eframe(move |_cc| app); let mut d = Driver { harness, tx }; From df7cc97729b4dcccc341effc880ba0fa4fb5b42a Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 05:11:32 -0400 Subject: [PATCH 64/64] Format: rustfmt the clip_ops tests that landed with #561 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/clip_ops_tests.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/clip_ops_tests.rs b/crates/engine/src/clip_ops_tests.rs index 0f53b880..b6fcfe86 100644 --- a/crates/engine/src/clip_ops_tests.rs +++ b/crates/engine/src/clip_ops_tests.rs @@ -910,9 +910,8 @@ fn linked_slip_keeps_picture_and_sound_aligned_at_unequal_media_limits() { let r = s.execute("file.newOfflineFile", json!({"name": "Media", "seconds": 4, "fps": 25, "video": true, "audio": true})).unwrap(); let item = r["item"].as_u64().unwrap(); let fr = s.sequence_rate().tick_of(1); - let r = s - .execute("timeline.place", json!({"item": item, "track": "V1", "audioTrack": "A1", "time": 0, "sourceIn": fr.0 * 20, "duration": fr.0 * 60})) - .unwrap(); + let r = + s.execute("timeline.place", json!({"item": item, "track": "V1", "audioTrack": "A1", "time": 0, "sourceIn": fr.0 * 20, "duration": fr.0 * 60})).unwrap(); let (v, a) = (r["clips"][0].as_u64().unwrap(), r["clips"][1].as_u64().unwrap()); // lengthen only the picture so the pair has unequal tail handles s.execute("sequence.linkedSelection", json!({"on": false})).unwrap();