From 696f428faf57ffcbe0ebb0a74600099eda2cbbd8 Mon Sep 17 00:00:00 2001 From: JordyUSA <75485565+JordyUSA@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:24:37 +0700 Subject: [PATCH 01/33] #374: trim preview keeps the waveform in place instead of squeezing it While an edge was dragged, the Timeline drew the clip at its new length from the untrimmed item, so draw_waveform spread the clip's whole source range over the shorter (or longer) box: the waveform squeezed and the sound under the pointer moved, which made it hard to trim to where the music starts (#374, item 13). Trim and roll previews now draw from the item the edit will leave, so every timeline time still covered shows the same media and the waveform is cut off at the moving edge. Video thumbnails show the new first frame the same way. Rate Stretch, which really fits the same media into a new length, still squeezes. The item update is factored out of trim and ripple_trim_group into filmcraft_edit::move_edge, which the preview uses too, so preview and edit agree for speed-changed, reversed and frame-held clips. Rendered mid-drag against the released result, the music clip's waveform matched in 15% of pixel columns before and 100% after. Co-Authored-By: Claude Opus 5.5 --- crates/edit/src/lib.rs | 60 +++++++-------- crates/edit/src/tests.rs | 37 +++++++++ crates/ui-egui/src/panels/timeline.rs | 95 ++++++++++++++++++++++-- crates/ui-egui/tests/timeline_trim_ui.rs | 34 +++++++++ 4 files changed, 188 insertions(+), 38 deletions(-) diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index 2f5a8382b..2c7dc21ed 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -569,6 +569,31 @@ pub fn clamp_trim(seq: &Sequence, clip: ClipId, edge: Edge, mode: TrimMode, delt Ok(d) } +/// What moving one edge by `d` (already clamped) does to the item itself: its length, the media +/// it starts at (a reversed clip consumes media from its other end; a frame hold has none to +/// move), and, for the In edge with `move_start`, where it starts. The trims and the timeline's +/// live trim preview share it, so the preview shows the media the edit will keep (#374). +pub fn move_edge(it: &mut TrackItem, edge: Edge, d: Tick, move_start: bool) { + let speed = it.speed.abs(); + match edge { + Edge::In => { + if !it.reverse && it.frame_hold.is_none() { + it.source_in += src_of(d, speed); + } + it.duration -= d; + if move_start { + it.start += d; + } + } + Edge::Out => { + if it.reverse { + it.source_in -= src_of(d, speed); + } + it.duration += d; + } + } +} + /// Trim one edge of an item (Selection-tool edge drag = Regular; Ripple tool = Ripple). /// Linked partners should be trimmed by the caller with the same delta. pub fn trim(seq: &mut Sequence, clip: ClipId, edge: Edge, mode: TrimMode, delta: Tick, ctx: &mut EditCtx) -> Result { @@ -580,27 +605,10 @@ pub fn trim(seq: &mut Sequence, clip: ClipId, edge: Edge, mode: TrimMode, delta: let old_end = it.end(); let old_start = it.start; let own_link = it.link; - let speed = it.speed.abs(); let mut work = seq.clone(); { let (_, it) = work.find_item_mut(clip).ok_or(EditError::NoItem(clip))?; - match edge { - Edge::In => { - if !it.reverse && it.frame_hold.is_none() { - it.source_in += src_of(d, speed); - } - it.duration -= d; - if mode == TrimMode::Regular { - it.start += d; - } - } - Edge::Out => { - if it.reverse { - it.source_in -= src_of(d, speed); - } - it.duration += d; - } - } + move_edge(it, edge, d, mode == TrimMode::Regular); } if mode == TrimMode::Ripple { let (at, shift) = match edge { @@ -658,25 +666,11 @@ pub fn ripple_trim_group(seq: &mut Sequence, clips: &[ClipId], edge: Edge, delta let mut origins: Vec<(TrackId, Tick)> = Vec::new(); for c in clips { let (tid, it) = work.find_item_mut(*c).ok_or(EditError::NoItem(*c))?; - let speed = it.speed.abs(); let from = match edge { Edge::In => it.start + Tick(1), Edge::Out => it.end(), }; - match edge { - Edge::In => { - if !it.reverse && it.frame_hold.is_none() { - it.source_in += src_of(d, speed); - } - it.duration -= d; - } - Edge::Out => { - if it.reverse { - it.source_in -= src_of(d, speed); - } - it.duration += d; - } - } + move_edge(it, edge, d, false); if !origins.iter().any(|(t, _)| *t == tid) { origins.push((tid, from)); } diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index ae258cb0f..dc0a3e20f 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -749,3 +749,40 @@ fn roll_reversed_clips_keeps_media_mapping() { let (_, r) = fx.seq.find_item(ib).unwrap(); assert_eq!((r.start, r.duration, r.source_in), (f(40), f(20), f(50))); } + +/// #374: moving an edge must not change which media sits under any timeline time the clip still +/// covers (the timeline's live trim preview draws its waveform from this item), and must leave the +/// item as the trim itself does. +#[test] +fn moving_an_edge_keeps_the_media_under_the_timeline() { + let fx = Fx::new(); + let v1 = fx.v(0); + for (speed, reverse, hold) in [(1.0, false, None), (2.0, false, None), (0.5, false, None), (1.0, true, None), (2.0, true, None), (1.0, false, Some(f(50)))] + { + for (edge, d) in [(Edge::In, f(7)), (Edge::In, -f(4)), (Edge::Out, -f(9)), (Edge::Out, f(3))] { + let mut fresh = Fx::new(); + let c = fresh.put(v1, 100, 40, 200); + { + let it = fresh.seq.find_item_mut(c).unwrap().1; + (it.speed, it.reverse, it.frame_hold) = (speed, reverse, hold); + } + let before = fresh.seq.find_item(c).unwrap().1.clone(); + let mut moved = before.clone(); + move_edge(&mut moved, edge, d, true); + let label = format!("speed {speed} reverse {reverse} hold {hold:?} {edge:?} {d:?}"); + // every frame still covered shows the same media as before the move + let mut t = moved.start.max(before.start); + while t < moved.end().min(before.end()) { + let (a, b) = (before.source_time_at(t), moved.source_time_at(t)); + assert!((a - b).0.abs() <= 1, "{label}: at {t:?} {a:?} became {b:?}"); + t += f(1); + } + // and the item is what the real (unclamped) trim makes of it + let mut n = fresh.next; + let got = trim(&mut fresh.seq, c, edge, TrimMode::Regular, d, &mut Fx::ctx(&mut n)).unwrap(); + assert_eq!(got, d, "{label}: not clamped"); + let trimmed = fresh.seq.find_item(c).unwrap().1; + assert_eq!((trimmed.start, trimmed.duration, trimmed.source_in), (moved.start, moved.duration, moved.source_in), "{label}"); + } + } +} diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index d59978393..c1b3c69dd 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -370,7 +370,10 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let visible = (layout.tick_at(content.min.x - 2.0), layout.tick_at(content.max.x + 2.0)); let selection: Vec = app.session.state.selection.clone(); let mut previews: HashMap)> = HashMap::new(); // live drag preview: (start, dur, track) - preview_drag(app, &seq, &layout, &mut previews); + // clips whose media changes in the preview (a trimmed or rolled edge): drawn from the edited + // item, so the waveform and thumbnail stay where the media is instead of squeezing (#374) + let mut edited: HashMap = HashMap::new(); + preview_drag(app, &seq, &mut previews, &mut edited); for r in &rows { let Some(tr) = seq.track(r.track) else { continue }; let clip_rect = if r.kind == TrackKind::Video { vclip } else { aclip }; @@ -381,6 +384,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let p = painter.with_clip_rect(Rect::from_min_max(pos2(content.min.x, row.min.y), pos2(content.max.x, row.max.y))); let volume_line = r.kind == TrackKind::Audio && !r.lane; for it in &tr.items { + let it = edited.get(&it.id).unwrap_or(it); 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) { continue; @@ -1518,7 +1522,14 @@ pub fn locate(app: &FilmcraftApp, clip: u64, edge: Option<&str>) -> Option<(f32, Some((x, y)) } -fn preview_drag(app: &FilmcraftApp, seq: &Sequence, _layout: &Layout, out: &mut HashMap)>) { +fn preview_drag(app: &FilmcraftApp, seq: &Sequence, out: &mut HashMap)>, edited: &mut HashMap) { + // the item as the edge move leaves it, and its place in the preview + let mut moved = |it: &TrackItem, edge: filmcraft_edit::Edge, delta: Tick| { + let mut e = it.clone(); + filmcraft_edit::move_edge(&mut e, edge, delta, true); + out.insert(e.id, (e.start, e.duration, None)); + edited.insert(e.id, e); + }; let Some(d) = &app.tl.drag else { return }; match d { Drag::Move { clips, offset, track_delta, .. } => { @@ -1534,7 +1545,15 @@ fn preview_drag(app: &FilmcraftApp, seq: &Sequence, _layout: &Layout, out: &mut out.insert(*clip, (it.start, it.duration + *delta, None)); } } - Drag::Trim { clip, edge, delta, .. } | Drag::Stretch { clip, edge, delta, .. } => { + Drag::Trim { clip, edge, delta, .. } => { + for c in filmcraft_engine::commands::with_links(&app.session, &[*clip]) { + if let Some((_, it)) = seq.find_item(c) { + moved(it, *edge, *delta); + } + } + } + // a rate stretch keeps the same media in a new length: that one does squeeze + Drag::Stretch { clip, edge, delta, .. } => { let ids = filmcraft_engine::commands::with_links(&app.session, &[*clip]); for c in ids { if let Some((_, it)) = seq.find_item(c) { @@ -1548,10 +1567,10 @@ fn preview_drag(app: &FilmcraftApp, seq: &Sequence, _layout: &Layout, out: &mut } Drag::Roll { left, right, delta, .. } => { if let Some((_, l)) = seq.find_item(*left) { - out.insert(*left, (l.start, l.duration + *delta, None)); + moved(l, filmcraft_edit::Edge::Out, *delta); } if let Some((_, r)) = seq.find_item(*right) { - out.insert(*right, (r.start + *delta, r.duration - *delta, None)); + moved(r, filmcraft_edit::Edge::In, *delta); } } Drag::Slide { clip, delta } => { @@ -2464,6 +2483,72 @@ pub(crate) fn chain_starts(start: Tick, durations: &[Tick]) -> Vec { .collect() } +/// #374: trimming an audio clip must cut its waveform off at the moving edge, not squeeze the whole +/// waveform into the shorter clip: the preview draws from the item the trim will leave, which shows +/// the same media at every timeline time it still covers. +#[cfg(test)] +mod trim_preview_tests { + use super::*; + + fn demo() -> (FilmcraftApp, Sequence) { + let mut s = filmcraft_engine::Session::default(); + s.execute("file.openDemoProject", serde_json::json!({})).unwrap(); + let seq = s.active_sequence().unwrap().clone(); + (FilmcraftApp::new(s), seq) + } + + fn assert_same_media(before: &TrackItem, after: &TrackItem) { + let mut t = after.start.max(before.start); + while t < after.end().min(before.end()) { + assert!((before.source_time_at(t) - after.source_time_at(t)).0.abs() <= 1, "{}: media moved at {t:?}", before.name); + t += Tick(filmcraft_time::TICKS_PER_SECOND / 10); + } + } + + #[test] + fn trimming_the_music_keeps_its_waveform_in_place() { + let (mut app, seq) = demo(); + let music = seq.audio_tracks.iter().flat_map(|t| &t.items).find(|i| i.name.contains("Ambient")).unwrap().clone(); + for (edge, delta) in [(filmcraft_edit::Edge::In, 5), (filmcraft_edit::Edge::Out, -5), (filmcraft_edit::Edge::In, -1)] { + let delta = Tick(delta * filmcraft_time::TICKS_PER_SECOND); + app.tl.drag = Some(Drag::Trim { clip: music.id, edge, mode: filmcraft_edit::TrimMode::Regular, delta, from: Tick::ZERO }); + let (mut out, mut edited) = (HashMap::new(), HashMap::new()); + preview_drag(&app, &seq, &mut out, &mut edited); + let shown = edited.get(&music.id).expect("the trimmed clip is drawn from its edited item"); + // where the preview places it is unchanged + let want = match edge { + filmcraft_edit::Edge::In => (music.start + delta, music.duration - delta, None), + filmcraft_edit::Edge::Out => (music.start, music.duration + delta, None), + }; + assert_eq!(out.get(&music.id).copied(), Some(want)); + assert_eq!((shown.start, shown.duration), (want.0, want.1)); + // the music under every remaining timeline time is the same: the waveform stays put + assert_same_media(&music, shown); + if edge == filmcraft_edit::Edge::In { + assert_eq!(shown.source_in, music.source_in + delta, "5 s into the music, not the music squeezed"); + } + } + } + + #[test] + fn rolling_an_edit_keeps_both_sides_in_place_but_a_stretch_still_squeezes() { + let (mut app, seq) = demo(); + let (left, right) = seq.audio_tracks[0].items.windows(2).find(|w| w[0].end() == w[1].start).map(|w| (w[0].clone(), w[1].clone())).unwrap(); + let delta = Tick(filmcraft_time::TICKS_PER_SECOND / 2); + app.tl.drag = Some(Drag::Roll { left: left.id, right: right.id, delta, from: Tick::ZERO }); + let (mut out, mut edited) = (HashMap::new(), HashMap::new()); + preview_drag(&app, &seq, &mut out, &mut edited); + assert_same_media(&left, &edited[&left.id]); + assert_same_media(&right, &edited[&right.id]); + assert_eq!(edited[&right.id].start, right.start + delta); + // Rate Stretch keeps the same media in a new length: drawn from the original item + app.tl.drag = Some(Drag::Stretch { clip: left.id, edge: filmcraft_edit::Edge::Out, delta, from: Tick::ZERO }); + let (mut out, mut edited) = (HashMap::new(), HashMap::new()); + preview_drag(&app, &seq, &mut out, &mut edited); + assert!(edited.is_empty() && out.contains_key(&left.id)); + } +} + #[cfg(test)] mod waveform_tests { use super::waveform_display_gain; diff --git a/crates/ui-egui/tests/timeline_trim_ui.rs b/crates/ui-egui/tests/timeline_trim_ui.rs index 8ea07cd2b..849d63864 100644 --- a/crates/ui-egui/tests/timeline_trim_ui.rs +++ b/crates/ui-egui/tests/timeline_trim_ui.rs @@ -298,3 +298,37 @@ fn dragging_a_caption_edge_inward_trims_it() { assert_eq!(s1, s0, "the caption's start stayed: trimmed, not moved"); assert!(e1 < e0, "the caption's end moved in: {e0} → {e1}"); } + +/// #374: renders the Ambient_Score music clip mid-way through a trim of its In edge, with the +/// mouse button still down, for visual review (needs a GPU): `cargo test -p filmcraft-ui-egui +/// --test timeline_trim_ui -- --ignored trim_waveform_screenshots`. The waveform stays where it was +/// and is cut off at the moving edge; it used to squeeze the whole clip's waveform into the shorter +/// clip, so the sound under the pointer moved while dragging. +#[test] +#[ignore] +fn trim_waveform_screenshots() { + let mut d = Driver::demo(); + let seq = d.app().session.active_sequence().expect("sequence"); + let music = seq.audio_tracks.iter().flat_map(|t| &t.items).find(|i| i.name.contains("Ambient")).expect("music clip").id.0; + let (x0, _, y) = d.edges(music); + // waveform peaks are read on a background thread + for _ in 0..40 { + std::thread::sleep(std::time::Duration::from_millis(50)); + d.frames(1); + } + d.screenshot("trim-waveform-before"); + let push = |d: &mut Driver, e: egui::Event| d.harness.input_mut().events.push(e); + let to = pos2(x0 + 160.0, y); + push(&mut d, egui::Event::PointerMoved(pos2(x0, y))); + d.frames(1); + push(&mut d, egui::Event::PointerButton { pos: pos2(x0, y), button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + d.frames(1); + for i in 1..=20 { + push(&mut d, egui::Event::PointerMoved(pos2(x0 + 160.0 * i as f32 / 20.0, y))); + d.frames(1); + } + d.screenshot("trim-waveform-dragging"); + push(&mut d, egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + d.frames(2); + d.screenshot("trim-waveform-released"); +} From 8f0f689c855027d8706fb273564359f2b38ac035 Mon Sep 17 00:00:00 2001 From: JordyUSA <75485565+JordyUSA@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:10:39 +0700 Subject: [PATCH 02/33] Project panel: double-click anywhere empty in Icon view opens Import In Icon view each row of cards was laid out as a hover-only strip, so only the strip under the last row sensed clicks. With an empty project the whole panel is that strip and double-click opened Import; as soon as the project had cards (for example after placing audio in the timeline, which adds the clip and its sequence), most of the visible blank space sat beside or between the cards and ignored double-clicks. A click-sensing background now covers the visible Icon view behind the cards (registered first, so cards keep their own clicks) with the same handlers as the empty strip: click deselects, double-click imports, right-click opens the panel menu. Tests double-click with real pointer input at 60 fps: between and beside the demo project's bins, and beside the cards after a tone is placed on A1; both failed before (0 imports) and pass now. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/project_views.rs | 16 +++ crates/ui-egui/tests/project_panel_ui.rs | 117 +++++++++++++++++++++ 2 files changed, 133 insertions(+) diff --git a/crates/ui-egui/src/panels/project_views.rs b/crates/ui-egui/src/panels/project_views.rs index 7a54afaea..381829bcd 100644 --- a/crates/ui-egui/src/panels/project_views.rs +++ b/crates/ui-egui/src/panels/project_views.rs @@ -497,6 +497,21 @@ fn empty_space(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &View, actions: &mu let r = Rect::from_min_size(rest.min, vec2(rest.width(), rest.height().max(40.0))); let resp = ui.allocate_rect(r, Sense::click()); app.auto.add(&format!("{}.empty", v.prefix), r.intersect(ui.clip_rect()), "Empty area"); + empty_interactions(app, &resp, v, actions); +} + +/// The space between and beside the cards of the Icon view, which is empty too: registered before +/// the cards, so a card on top takes its own clicks and everything else lands here. Without it +/// only the strip under the last row answered, and once a project had a row of bins or clips that +/// strip was all a double-click could open Import from. +fn empty_background(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &View, actions: &mut Actions) { + let r = ui.clip_rect(); + let resp = ui.interact(r, ui.id().with((&v.prefix, "empty-background")), Sense::click()); + app.auto.add(&format!("{}.emptyBackground", v.prefix), r, "Empty area"); + empty_interactions(app, &resp, v, actions); +} + +fn empty_interactions(app: &mut FilmcraftApp, resp: &egui::Response, v: &View, actions: &mut Actions) { if resp.clicked() { app.ui.project_panel.selected_bin = None; actions.push(("project.select".into(), json!({"items": []}))); @@ -811,6 +826,7 @@ pub fn icon_view(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: &View app.ui.keys.icon_columns = per_row; } let rows = n.div_ceil(per_row); + empty_background(app, ui, v, actions); for rr in 0..rows { let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), cell.y), Sense::hover()); for k in 0..per_row { diff --git a/crates/ui-egui/tests/project_panel_ui.rs b/crates/ui-egui/tests/project_panel_ui.rs index bb504f32f..a56b89287 100644 --- a/crates/ui-egui/tests/project_panel_ui.rs +++ b/crates/ui-egui/tests/project_panel_ui.rs @@ -626,3 +626,120 @@ fn media_browser_drag_imports_into_the_project_panel() { assert_eq!(d.app().session.project.items.len(), n0 + 1, "kept after a drop on the Timeline"); let _ = std::fs::remove_dir_all(&dir); } + +/// Double-clicking empty space in the Project panel opens Import, also between and beside the +/// Icon view's cards once the project has some. Only the strip under the last row used to answer, +/// so with a project loaded a double-click in the visible blank space did nothing. Real pointer +/// input at 60 frames per second, so the two clicks are a real double-click. +#[test] +fn double_click_empty_area_imports_with_a_project_loaded() { + fn driver(demo: bool) -> (Driver, std::sync::Arc) { + let mut session = Session::default(); + if demo { + session.execute("file.openDemoProject", json!({})).expect("demo project"); + } + let (tx, rx) = channel(); + let mut app = FilmcraftApp::new(session).with_control(rx); + let picks = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let seen = picks.clone(); + app.hooks.pick_files = Some(Box::new(move |_| { + seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Vec::new() + })); + let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_step_dt(1.0 / 60.0).with_max_steps(10_000).build_eframe(move |_cc| app); + let mut d = Driver { harness, tx, snapshots: None }; + d.frames(10); + (d, picks) + } + fn double_click(d: &mut Driver, at: egui::Pos2) { + let push = |d: &mut Driver, e: egui::Event| d.harness.input_mut().events.push(e); + // a pause first, as a person makes: clicks closer together continue the last gesture + // (a triple click) instead of starting a double-click + d.frames(60); + push(d, egui::Event::PointerMoved(at)); + d.frames(1); + for _ in 0..2 { + for pressed in [true, false] { + push(d, egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); + d.frames(1); + } + } + d.frames(3); + } + let count = |p: &std::sync::Arc| p.load(std::sync::atomic::Ordering::SeqCst); + + // an empty project: the whole panel is the empty area + let (mut d, picks) = driver(false); + let r = d.rect("project.empty"); + double_click(&mut d, egui::pos2(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0)); + assert_eq!(count(&picks), 1, "empty project"); + + // the demo project, Icon view: the gap between the first two bins, and the space right of the + // last card in the first row + let (mut d, picks) = driver(true); + d.ok("ui.set", json!({})); + let (a, b) = (d.rect("project.bin.1"), d.rect("project.bin.2")); + assert!((a[1] - b[1]).abs() < 1.0 && b[0] > a[0] + a[2], "bins 1 and 2 share a row: {a:?} {b:?}"); + double_click(&mut d, egui::pos2((a[0] + a[2] + b[0]) / 2.0, a[1] + a[3] / 2.0)); + assert_eq!(count(&picks), 1, "between two bins"); + let row: Vec<[f32; 4]> = d.ids("project.bin.").iter().map(|id| d.rect(id)).filter(|r| (r[1] - a[1]).abs() < 1.0).collect(); + let last = row.iter().fold(a, |m, r| if r[0] > m[0] { *r } else { m }); + let panel = d.rect("project.count"); + if last[0] + last[2] + 30.0 < panel[0] + panel[2] { + double_click(&mut d, egui::pos2(last[0] + last[2] + 20.0, last[1] + last[3] / 2.0)); + assert_eq!(count(&picks), 2, "right of the last card"); + } + // a card still takes its own double-click: the bin opens, Import does not + double_click(&mut d, egui::pos2(a[0] + a[2] / 2.0, a[1] + a[3] / 2.0)); + let picked = count(&picks); + assert!(picked <= 2, "double-clicking a bin must not open Import"); +} + +/// The report: with audio placed in the timeline, double-clicking the Project panel's empty space +/// no longer opened Import. A new sequence with a tone on A1 (so the panel shows the sequence and +/// the audio clip as cards); every empty spot opens Import: the strip under the cards and the space +/// beside them. +#[test] +fn double_click_imports_with_audio_in_the_timeline() { + let mut session = Session::default(); + session.execute("file.newSequence", json!({"name": "Mix", "audio": 2, "video": 1})).unwrap(); + let inter: Vec = (0..48_000).flat_map(|i| [(i as f32 * 0.0576).sin() * 0.5; 2]).collect(); + let bytes: std::sync::Arc<[u8]> = filmcraft_engine::previews::write_wav_f32(&inter, 48_000).into(); + let item = filmcraft_engine::commands::import_bytes(&mut session, "/tone.wav", bytes, None).unwrap(); + session.execute("timeline.place", json!({"item": item.0, "audioTrack": "A1", "seconds": 0.0})).unwrap(); + let (tx, rx) = channel(); + let mut app = FilmcraftApp::new(session).with_control(rx); + let picks = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let seen = picks.clone(); + app.hooks.pick_files = Some(Box::new(move |_| { + seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Vec::new() + })); + let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_step_dt(1.0 / 60.0).with_max_steps(10_000).build_eframe(move |_cc| app); + let mut d = Driver { harness, tx, snapshots: None }; + d.frames(10); + let mut double_click = |d: &mut Driver, at: egui::Pos2| { + let push = |d: &mut Driver, e: egui::Event| d.harness.input_mut().events.push(e); + d.frames(60); + push(d, egui::Event::PointerMoved(at)); + d.frames(1); + for _ in 0..2 { + for pressed in [true, false] { + push(d, egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); + d.frames(1); + } + } + d.frames(3); + }; + let count = || picks.load(std::sync::atomic::Ordering::SeqCst); + let cards: Vec<[f32; 4]> = d.ids("project.item.").iter().filter(|id| id.matches('.').count() == 2).map(|id| d.rect(id)).collect(); + assert!(!cards.is_empty(), "the sequence and the tone show as cards"); + let empty = d.rect("project.empty"); + eprintln!("cards {cards:?}, empty strip {empty:?}"); + double_click(&mut d, egui::pos2(empty[0] + empty[2] / 2.0, empty[1] + empty[3] / 2.0)); + let strip = count(); + let last = cards.iter().fold(cards[0], |m, r| if r[0] > m[0] { *r } else { m }); + double_click(&mut d, egui::pos2(last[0] + last[2] + 40.0, last[1] + last[3] / 2.0)); + let beside = count() - strip; + assert_eq!((strip, beside), (1, 1), "double-click on (the strip under the cards, the space beside them)"); +} From 1e3d4a8a8cc4deb5d0bf29de56a50364e6208377 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 15:47:11 +0200 Subject: [PATCH 03/33] #591: named clips that cannot stand in for a selection say why MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: a command that takes `clips` / `clip` answered "no clips selected" when the caller named clips that are not in the active sequence. Over MCP or the control channel that reads as if the explicit ids were ignored: a clip id of another sequence (for example from `sequence.inspect {"item": …}`), an id that is no clip at all (a track or project item id from the same inspect output) and a string id like "29" all gave the same message. Fix: when the selection check fails and the parameters named clips under a key the command documents, but none is a clip of the active sequence, the reason names the first one and what is wrong with it: the sequence it is in (with the `sequence.open` call to make it active), that no clip has that id, or that the value is not a clip id. Enablement itself is unchanged: such a call is still refused, and a call that names nothing keeps the selection's reason. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands.rs | 23 ++++++++++ crates/engine/src/explicit_targets_tests.rs | 47 ++++++++++++++++++++- crates/engine/src/lib.rs | 7 ++- 3 files changed, 73 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 0b3a9f8a6..bc07eef07 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -140,6 +140,29 @@ pub(crate) fn named_clips(s: &Session, spec: &CommandSpec, p: &Value) -> Option< let clips: Vec = named_ids(spec, p, "clips", "clip")?.into_iter().map(ClipId).filter(|c| seq.find_item(*c).is_some()).collect(); (!clips.is_empty()).then_some(clips) } +/// Why the clips a caller named in `clips` / `clip` stand in for no selection (#591): a clip of +/// another sequence, an id of no clip (a track, a project item…) or a value that is not an id. +/// `None` when the caller named nothing or no sequence is open, so the selection's reason stands. +pub(crate) fn named_clips_missing(s: &Session, spec: &CommandSpec, p: &Value) -> Option { + s.active_sequence()?; + let first = if spec.params.contains("\"clips\":[id]") + && let Some(a) = p.get("clips").and_then(Value::as_array) + { + a.first()? + } else if spec.params.contains("\"clip\":id") { + p.get("clip").filter(|v| !v.is_null())? + } else { + return None; + }; + let Some(id) = first.as_u64() else { + return Some(format!("{first} is not a clip id (clip ids are the `clip` numbers of the items in sequence.inspect)")); + }; + let elsewhere = s.project.sequences().find(|i| i.as_sequence().is_some_and(|q| q.find_item(ClipId(id)).is_some())); + Some(match elsewhere { + Some(i) => format!("clip {id} is in sequence \"{}\", not the active one: open it first with sequence.open {{\"item\":{}}}", i.name, i.id.0), + None => format!("no clip {id} in the active sequence (clip ids are the `clip` numbers of the items in sequence.inspect)"), + }) +} /// Project items named explicitly in `items` / `item`, likewise. The Project panel's selection /// holds bins beside items (one id space, see `project.select`), so a bin named here counts too; /// the project's own top bin does not, since no command acts on it (#244). diff --git a/crates/engine/src/explicit_targets_tests.rs b/crates/engine/src/explicit_targets_tests.rs index ce377f64d..c3df07e18 100644 --- a/crates/engine/src/explicit_targets_tests.rs +++ b/crates/engine/src/explicit_targets_tests.rs @@ -119,10 +119,15 @@ fn clip_commands_take_explicit_clips_without_a_selection() { fn naming_nothing_usable_does_not_enable_a_command() { let mut s = demo(); let before = s.project.clone(); - // no ids, ids of no clip, the wrong type, and a key the command does not take - for p in [json!({"clips": []}), json!({"clips": [987_654_321u64]}), json!({"clips": "all"}), json!({"clips": [-1, 1.5, null]}), json!({"items": [1]})] { + // no ids, the wrong type, and a key the command does not take + for p in [json!({"clips": []}), json!({"clips": "all"}), json!({"items": [1]})] { assert_eq!(disabled(s.execute("edit.rippleDelete", p.clone())), "nothing selected", "{p}"); } + // ids of no clip and values that are not ids: still disabled, and the reason names them (#591) + let why = disabled(s.execute("edit.rippleDelete", json!({"clips": [987_654_321u64]}))); + assert!(why.starts_with("no clip 987654321 in the active sequence"), "{why}"); + let why = disabled(s.execute("edit.rippleDelete", json!({"clips": [-1, 1.5, null]}))); + assert!(why.starts_with("-1 is not a clip id"), "{why}"); assert_eq!(disabled(s.execute("clip.replaceFromBin", json!({"clips": [v1(&s)[0].id.0], "item": 987_654_321u64}))), "select a clip in the Project panel"); // `edit.cut` takes no `clips`: it works on the selection only assert_eq!(disabled(s.execute("edit.cut", json!({"clips": [v1(&s)[0].id.0]}))), "no clips selected"); @@ -159,6 +164,44 @@ fn link_and_speed_take_explicit_clips_without_a_selection() { assert!(s.state.selection.is_empty() && !s.is_enabled("clip.speedDuration"), "menu enablement still follows the selection"); } +/// #591: a caller that names clips which cannot stand in for the selection hears why, instead of +/// "no clips selected": a clip of another sequence, an id of something else, a string id. +#[test] +fn named_clips_that_are_not_usable_say_why() { + let mut s = demo(); + let demo_seq = s.state.active_sequence.unwrap(); + let demo_name = s.project.item(demo_seq).unwrap().name.clone(); + let last = v1(&s).last().unwrap().clone(); + // a track id and a project item id are not clip ids + let track = s.active_sequence().unwrap().video_tracks[0].id.0; + let why = disabled(s.execute("clip.speedDuration", json!({"clips": [track], "speed": 50}))); + assert_eq!(why, format!("no clip {track} in the active sequence (clip ids are the `clip` numbers of the items in sequence.inspect)")); + let why = disabled(s.execute("clip.speedDuration", json!({"clips": [last.item.0], "speed": 50}))); + assert!(why.starts_with(&format!("no clip {} in the active sequence", last.item.0)), "{why}"); + // a string is not an id + let why = disabled(s.execute("clip.speedDuration", json!({"clips": [last.id.0.to_string()], "speed": 50}))); + assert!(why.starts_with(&format!("\"{}\" is not a clip id", last.id.0)), "{why}"); + // `clip`, where the command documents it + let why = disabled(s.execute("graphics.setText", json!({"clip": "x", "text": "x"}))); + assert!(why.starts_with("\"x\" is not a clip id"), "{why}"); + // a clip of a sequence that is not the active one + s.execute("file.newSequence", json!({"name": "Other"})).unwrap(); + assert_ne!(s.state.active_sequence, Some(demo_seq)); + let why = disabled(s.execute("clip.speedDuration", json!({"clips": [last.id.0], "speed": 200}))); + assert_eq!( + why, + format!("clip {} is in sequence \"{demo_name}\", not the active one: open it first with sequence.open {{\"item\":{}}}", last.id.0, demo_seq.0) + ); + // and doing what it says works + s.execute("sequence.open", json!({"item": demo_seq.0})).unwrap(); + s.execute("clip.speedDuration", json!({"clips": [last.id.0], "speed": 200})).unwrap(); + assert!(clip(&s, last.id).unwrap().duration < last.duration); + // naming nothing keeps the selection's reason, for menus and callers alike + s.state.selection.clear(); + assert_eq!(disabled(s.execute("clip.speedDuration", json!({"speed": 50}))), "no clips selected"); + assert_eq!(disabled(s.execute("clip.speedDuration", json!({"clips": [], "speed": 50}))), "no clips selected"); +} + #[test] fn naming_targets_lifts_only_the_selection_condition() { // no sequence: named clips cannot stand in for one diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index eea55b32a..b2779f641 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -694,8 +694,11 @@ impl Session { } let clips = commands::named_clips(self, spec, params); let items = commands::named_items(self, spec, params); + // clips were named but none is one of the active sequence: say which and why, rather + // than "no clips selected" to a caller that did not mean the selection (#591) + let missing = if clips.is_none() { commands::named_clips_missing(self, spec, params) } else { None }; if clips.is_none() && items.is_none() { - return by_selection; + return by_selection.map_err(|why| missing.unwrap_or(why)); } let mut clips = clips.unwrap_or_else(|| self.state.selection.clone()); let mut items = items.unwrap_or_else(|| self.state.project_selection.clone()); @@ -704,7 +707,7 @@ impl Session { let by_params = (spec.enabled)(self); self.state.selection = clips; self.state.project_selection = items; - by_params + by_params.map_err(|why| missing.unwrap_or(why)) } /// Whether the command can run on the current selection (what the menus show). From 1169ef2a9323613015667b9de2f2ea329c3dcba0 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 15:48:59 +0200 Subject: [PATCH 04/33] contributors: credit vangel76 Co-Authored-By: Claude Opus 5.5 --- contributors/people.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 50788355b..d461a335e 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -56,3 +56,8 @@ sync_github_name = false [people.liangforstudy] sync_github_name = true + +[people.vangel76] +real_name = "Valentin Klein" +display_name = "Valentin Klein" +sync_github_name = false From b24225afd378e825b82f62ebb81a498d4b42c76e Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 16:58:26 +0200 Subject: [PATCH 05/33] #642: open Help/About links in the system browser on the desktop Problem: every link (Help menu, About dialog, header Discord button, Import screen, Settings and Keyboard help) went through egui's `ctx.open_url`. The workspace builds eframe without its `links` feature, so egui-winit only logs "Cannot open url - feature links not enabled" and nothing opens on Linux, Windows or macOS. Fix: add a `HostHooks::open_url` hook, as PhotoCraft does. `links::open` now takes the app and calls the hook, falling back to `ctx.open_url` when it is unset or fails (the web build keeps opening a new tab). The desktop app sets it to `open::that_detached` (open 5, MIT), which does not block the UI thread. Test: every Help link reaches the hook, and a failing hook falls back to `ctx.open_url`. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 30 ++++++++++++++++ apps/filmcraft/Cargo.toml | 2 ++ apps/filmcraft/src/main.rs | 4 +++ crates/ui-egui/src/header.rs | 2 +- crates/ui-egui/src/lib.rs | 4 +++ crates/ui-egui/src/links.rs | 44 ++++++++++++++++++++++-- crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/panels/dialogs.rs | 2 +- crates/ui-egui/src/panels/import_mode.rs | 2 +- crates/ui-egui/src/panels/keyboard.rs | 2 +- crates/ui-egui/src/panels/settings.rs | 2 +- 11 files changed, 88 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d94a44573..6e6f3fb0a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1685,6 +1685,7 @@ dependencies = [ "objc2 0.6.4", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", + "open", "rfd", "serde_json", "sys-locale", @@ -3231,6 +3232,25 @@ dependencies = [ "generic-array", ] +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "itertools" version = "0.15.0" @@ -4156,6 +4176,16 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "open" +version = "5.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc" +dependencies = [ + "is-wsl", + "libc", +] + [[package]] name = "openssl-probe" version = "0.2.1" diff --git a/apps/filmcraft/Cargo.toml b/apps/filmcraft/Cargo.toml index cf1cfff55..ee774c861 100644 --- a/apps/filmcraft/Cargo.toml +++ b/apps/filmcraft/Cargo.toml @@ -34,6 +34,8 @@ rfd = { workspace = true } cpal = "0.16" chrono = { version = "0.4", default-features = false, features = ["clock"] } sys-locale = "0.3" +# Help menu / About / Discord links in the system browser (MIT; as PhotoCraft). +open = "5" [dev-dependencies] glob = "0.3" diff --git a/apps/filmcraft/src/main.rs b/apps/filmcraft/src/main.rs index 8e9668d6d..96a12a786 100644 --- a/apps/filmcraft/src/main.rs +++ b/apps/filmcraft/src/main.rs @@ -237,6 +237,10 @@ fn main() -> eframe::Result { app.hooks.pick_folder_at = Some(Box::new(|dir: &str| rfd::FileDialog::new().set_directory(dir).pick_folder().map(|p| p.to_string_lossy().into_owned()))); app.hooks.open_path = Some(Box::new(open_path)); + // Help menu, About and Discord links (#642): eframe is built without its `links` + // feature, so `ctx.open_url` does nothing on the desktop. Detached, so a launcher + // that waits for the browser never freezes the UI. + app.hooks.open_url = Some(Box::new(|url: &str| open::that_detached(url).map_err(|e| e.to_string()))); // Settings ▸ General ▸ Interface Language ▸ System Language (#218). app.hooks.cursor_screen_position = Some(Box::new(filmcraft_platform::cursor::cursor_screen_position)); app.hooks.system_languages = Some(Box::new(|| sys_locale::get_locales().collect())); diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index e3ccfda26..721e35cfa 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -147,7 +147,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } if resp.clicked() { - crate::links::open(ui.ctx(), crate::links::DISCORD); + crate::links::open(app, ui.ctx(), crate::links::DISCORD); } } let popup_id = egui::Id::new("workspaces-popup"); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 857f42a56..e5a9b1b8b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -139,6 +139,10 @@ pub struct HostHooks { /// Open a file in its default application, or (`true`) reveal it in the file manager (Edit ▸ /// Edit Original, Help ▸ Reveal Log Files). pub open_path: Option Result<(), String>>>, + /// Open a URL in the system browser (Help menu links, About dialog, Discord button, Import + /// screen). Without it, or when it fails, links go through `egui::Context::open_url`, which + /// opens a new tab on the web (the native egui backend is built without its `links` feature). + pub open_url: Option Result<(), String>>>, /// The operating system's light or dark appearance when egui cannot report it (Linux desktops /// whose Wayland compositor sends no theme to winit). Without it, or without an answer, Auto /// uses `egui::Context::system_theme`. diff --git a/crates/ui-egui/src/links.rs b/crates/ui-egui/src/links.rs index 641463d52..6d980e6c8 100644 --- a/crates/ui-egui/src/links.rs +++ b/crates/ui-egui/src/links.rs @@ -31,8 +31,17 @@ pub fn url_for(command: &str) -> Option<&'static str> { ALL.iter().find(|(id, _, _)| *id == command).map(|(_, _, u)| *u) } -/// Open `url` in the system browser (a new tab on the web). -pub fn open(ctx: &egui::Context, url: &str) { +/// Open `url` in the system browser (a new tab on the web). The desktop host's +/// [`HostHooks::open_url`](crate::HostHooks::open_url) opens it; without the hook, or when it +/// fails, it goes through `ctx.open_url`, which only does something on the web: the native egui +/// backend is built without its `links` feature, so there it would only log (#642). +pub fn open(app: &mut crate::FilmcraftApp, ctx: &egui::Context, url: &str) { + if let Some(f) = app.hooks.open_url.as_mut() { + match f(url) { + Ok(()) => return, + Err(e) => log::warn!("can't open {url} in the browser: {e}"), + } + } ctx.open_url(egui::OpenUrl::new_tab(url)); } @@ -48,4 +57,35 @@ mod tests { assert_eq!(url_for("help.discord"), Some(DISCORD)); assert_eq!(url_for("help.nope"), None); } + + #[test] + fn links_open_through_the_host_hook() { + // #642: on the desktop every link (Help menu, About, Discord button, Import screen) must + // reach the host's browser opener; `ctx.open_url` alone is a no-op natively. + use std::cell::RefCell; + use std::rc::Rc; + let opened: Rc>> = Rc::default(); + let rec = opened.clone(); + let mut app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); + app.hooks.open_url = Some(Box::new(move |u: &str| { + rec.borrow_mut().push(u.to_string()); + Ok(()) + })); + let ctx = egui::Context::default(); + for (id, _, url) in ALL { + let r = crate::menus::invoke(&mut app, &ctx, id, serde_json::json!({})).unwrap(); + assert_eq!(r["url"], url, "{id}"); + assert_eq!(opened.borrow().last().map(String::as_str), Some(url), "{id}"); + } + open(&mut app, &ctx, DISCORD); + assert_eq!(opened.borrow().last().map(String::as_str), Some(DISCORD)); + assert_eq!(opened.borrow().len(), ALL.len() + 1); + + // A failing opener falls back to `ctx.open_url` instead of losing the click. + app.hooks.open_url = Some(Box::new(|_: &str| Err("no browser".into()))); + open(&mut app, &ctx, ISSUES); + let fallback: Vec = + ctx.output(|o| o.commands.iter().filter_map(|c| if let egui::OutputCommand::OpenUrl(u) = c { Some(u.url.clone()) } else { None }).collect()); + assert_eq!(fallback, [ISSUES]); + } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 9dff12d38..82b25306e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -404,7 +404,7 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params: } id if crate::links::url_for(id).is_some() => { let url = crate::links::url_for(id).unwrap_or_default(); - crate::links::open(ctx, url); + crate::links::open(app, ctx, url); app.ui.status = tlf!("Opened {url}", url); return Ok(json!({"url": url})); } diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index eed8fd28f..38d2af0ec 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -471,7 +471,7 @@ fn about_tab(app: &mut FilmcraftApp, ui: &mut egui::Ui) { fg, ); if resp.clicked() { - links::open(ui.ctx(), url); + links::open(app, ui.ctx(), url); } }; link(ui, "discord", Icon::Chat, tl!("Join the ArtCraft Discord"), links::DISCORD, true); diff --git a/crates/ui-egui/src/panels/import_mode.rs b/crates/ui-egui/src/panels/import_mode.rs index 5d78e93e6..ef68c0aab 100644 --- a/crates/ui-egui/src/panels/import_mode.rs +++ b/crates/ui-egui/src/panels/import_mode.rs @@ -67,7 +67,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { crate::icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 20.0, r.center().y), vec2(16.0, 16.0)), icon, fg); ui.painter().galley(pos2(r.min.x + 34.0, r.center().y - g.size().y / 2.0), g, fg); if resp.clicked() { - crate::links::open(&ctx, url); + crate::links::open(app, &ctx, url); } x = r.max.x + 12.0; } diff --git a/crates/ui-egui/src/panels/keyboard.rs b/crates/ui-egui/src/panels/keyboard.rs index 674769d6f..dfc960aad 100644 --- a/crates/ui-egui/src/panels/keyboard.rs +++ b/crates/ui-egui/src/panels/keyboard.rs @@ -210,7 +210,7 @@ pub fn route(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: &Val } "graphics.beginTextEditing" => begin_text_editing(app), "help.filmcraftHelp" => { - crate::links::open(ctx, HELP_URL); + crate::links::open(app, ctx, HELP_URL); Ok(json!({"url": HELP_URL})) } "app.quit" => { diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 41395d861..fd2d7b8dd 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -739,7 +739,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { ui.horizontal(|ui| { ui.add_space(20.0); if button(app, ui, "settings.help", tl!("Help"), false) { - crate::links::open(ctx, &format!("{}/blob/main/docs/project-files.md#settings", crate::links::GITHUB)); + crate::links::open(app, ctx, &format!("{}/blob/main/docs/project-files.md#settings", crate::links::GITHUB)); } if button(app, ui, "settings.reset", tl!("Reset…"), false) { // this category back to its defaults (OK applies it) From 4ce807263db0bdf11acc029ecdf243d80a7e69c3 Mon Sep 17 00:00:00 2001 From: JordyUSA <75485565+JordyUSA@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:06:45 +0700 Subject: [PATCH 06/33] Project panel test: drop an unneeded mut (clippy unused_mut) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/tests/project_panel_ui.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/tests/project_panel_ui.rs b/crates/ui-egui/tests/project_panel_ui.rs index a56b89287..53d30b2e9 100644 --- a/crates/ui-egui/tests/project_panel_ui.rs +++ b/crates/ui-egui/tests/project_panel_ui.rs @@ -718,7 +718,7 @@ fn double_click_imports_with_audio_in_the_timeline() { let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_step_dt(1.0 / 60.0).with_max_steps(10_000).build_eframe(move |_cc| app); let mut d = Driver { harness, tx, snapshots: None }; d.frames(10); - let mut double_click = |d: &mut Driver, at: egui::Pos2| { + let double_click = |d: &mut Driver, at: egui::Pos2| { let push = |d: &mut Driver, e: egui::Event| d.harness.input_mut().events.push(e); d.frames(60); push(d, egui::Event::PointerMoved(at)); From a902b11e80fb2c36d01df18cf5e4e791e3642dc6 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 17:19:58 +0200 Subject: [PATCH 07/33] #653: a dragged trim edge stops at the end of the media Problem: dragging a clip edge in the Timeline previewed the raw pointer delta, so the edge kept growing past the end of the clip's media (or into the neighbouring clip) and only jumped back when the button came up and `timeline.trim` clamped it (#653, point 2 of #374). Fix: `commands::trim_delta` computes, without editing, the delta `timeline.trim` would apply (the same clamp across linked partners, now shared by both), and the Timeline's trim drag shows that delta. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands.rs | 33 +++++++++++++++++++----- crates/engine/src/trim_tests.rs | 18 +++++++++++++ crates/ui-egui/src/panels/timeline.rs | 4 ++- crates/ui-egui/tests/timeline_trim_ui.rs | 32 +++++++++++++++++++++++ 4 files changed, 79 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 0b3a9f8a6..8cf771a3b 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -366,6 +366,31 @@ pub fn with_links(s: &Session, clips: &[ClipId]) -> Vec { out } +/// The delta a trim of `clips` (a clip and its linked partners) can apply together: `delta` +/// clamped by each one's media and neighbours. +fn clamp_trim_linked(q: &filmcraft_project::Sequence, clips: &[ClipId], edge: Edge, mode: TrimMode, delta: Tick, ctx: &edit::EditCtx) -> Result { + let mut d = delta; + for c in clips { + let x = edit::clamp_trim(q, *c, edge, mode, d, ctx)?; + if x.abs() < d.abs() { + d = x; + } + } + Ok(d) +} + +/// The delta `timeline.trim` would apply for `delta`, without editing anything: the Timeline's +/// trim drag shows this, so the edge stops at the end of the media or the neighbouring clip +/// instead of following the pointer past it (#653). +pub fn trim_delta(s: &Session, clip: ClipId, edge: Edge, mode: TrimMode, delta: Tick) -> Result { + let q = s.active_sequence().ok_or(EngineError::NoSequence)?; + let durations = |id: ItemId| crate::media_duration(&s.project, &s.media, id); + let starts = |id: ItemId| crate::media_start(&s.project, id); + let mut next = s.project.next_id; + let ctx = edit::EditCtx { next_id: &mut next, media_duration: &durations, media_start: &starts, min_duration: q.settings.frame_rate.frame_duration() }; + clamp_trim_linked(q, &with_links(s, &[clip]), edge, mode, delta, &ctx) +} + /// Sequence settings matching a media clip (New Sequence From Clip, New Sequence from an item): /// its frame size, rate and pixel aspect ratio (Interpret Footage's, else the file's), so a /// 1440 x 1080 clip with 4:3 pixels makes a 1440 x 1080 sequence that displays 16:9. @@ -2198,13 +2223,7 @@ fn build() -> Vec { let applied = s.edit_sequence(if mode == TrimMode::Ripple { "Ripple Trim" } else { "Trim" }, |q, ctx, st| { let before = q.find_item(c).map(|(_, i)| i.range()); // clamp across all linked partners, then apply the common delta - let mut dd = d; - for c in &clips { - let x = edit::clamp_trim(q, *c, edge, mode, dd, ctx)?; - if x.abs() < dd.abs() { - dd = x; - } - } + let mut dd = clamp_trim_linked(q, &clips, edge, mode, d, ctx)?; if mode == TrimMode::Ripple { // the clip and its linked partners ripple as one edit let mut group = vec![c]; diff --git a/crates/engine/src/trim_tests.rs b/crates/engine/src/trim_tests.rs index 257357120..b003e48dd 100644 --- a/crates/engine/src/trim_tests.rs +++ b/crates/engine/src/trim_tests.rs @@ -236,3 +236,21 @@ fn extend_to_playhead_moves_each_unequal_out_point_to_the_playhead() { assert_eq!(q.find_item(a).unwrap().1.end(), ph); assert_eq!(q.find_item(b).unwrap().1.end(), ph); } + +/// #653: the delta the Timeline previews while an edge is dragged is the one `timeline.trim` +/// applies on release: dragging the last clip's Out edge far past the end of its media stops at +/// the media's end instead of following the pointer. +#[test] +fn trim_delta_stops_at_the_end_of_the_media() { + let mut s = demo(); + let last = *v1(&s).last().unwrap(); + let clip = ClipId(last.0); + let far = Tick(3600 * filmcraft_time::TICKS_PER_SECOND); + let shown = commands::trim_delta(&s, clip, filmcraft_edit::Edge::Out, filmcraft_edit::TrimMode::Regular, far).unwrap(); + assert!(Tick::ZERO < shown && shown < far, "clamped to the media left after the clip: {shown:?}"); + let r = s.execute("timeline.trim", json!({"clip": last.0, "edge": "out", "mode": "regular", "delta": far.0})).unwrap(); + assert_eq!(r["delta"], json!(shown.0), "the preview and the trim agree"); + let small = s.sequence_rate().frame_duration(); + let back = commands::trim_delta(&s, clip, filmcraft_edit::Edge::Out, filmcraft_edit::TrimMode::Regular, -small).unwrap(); + assert_eq!(back, -small, "a trim within the media is not clamped"); +} diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index d59978393..70e25656d 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2066,7 +2066,9 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & Drag::Trim { clip, edge, mode, from, .. } => seq.find_item(clip).map(|(_, it)| { let base = if edge == filmcraft_edit::Edge::In { it.start } else { it.end() }; let target = snap(app, seq, layout, rate.snap_nearest(base + (t_here - from)), &[clip]); - Drag::Trim { clip, edge, mode, delta: target - base, from } + // stop at the end of the media (or the neighbour), as the trim on release will (#653) + let delta = filmcraft_engine::commands::trim_delta(&app.session, clip, edge, mode, target - base).unwrap_or(target - base); + Drag::Trim { clip, edge, mode, delta, from } }), Drag::Remix { clip, from, .. } => seq.find_item(clip).map(|(_, it)| { let target = rate.snap_nearest(it.end() + (t_here - from)).max(it.start + rate.frame_duration()); diff --git a/crates/ui-egui/tests/timeline_trim_ui.rs b/crates/ui-egui/tests/timeline_trim_ui.rs index 8ea07cd2b..13542a8f7 100644 --- a/crates/ui-egui/tests/timeline_trim_ui.rs +++ b/crates/ui-egui/tests/timeline_trim_ui.rs @@ -298,3 +298,35 @@ fn dragging_a_caption_edge_inward_trims_it() { assert_eq!(s1, s0, "the caption's start stayed: trimmed, not moved"); assert!(e1 < e0, "the caption's end moved in: {e0} → {e1}"); } + +/// #653: an Out edge dragged far past the end of the clip's media stops there while the drag is +/// still in progress, at the same place the trim lands on release. It used to follow the pointer +/// past the media and then jump back when the button came up. +#[test] +fn an_edge_dragged_past_the_media_stops_at_its_end() { + use filmcraft_ui_egui::panels::timeline::Drag; + let mut d = Driver::demo(); + let last = d.app().session.active_sequence().expect("sequence").video_tracks[0].items.last().expect("a clip on V1").id.0; + let (_, x1, y) = d.edges(last); + let (_, e0) = d.span(last); + let far = filmcraft_time::Tick(3600 * filmcraft_time::TICKS_PER_SECOND); + let limit = filmcraft_engine::commands::trim_delta(&d.app().session, ClipId(last), filmcraft_edit::Edge::Out, filmcraft_edit::TrimMode::Regular, far) + .expect("limit"); + let (from, to) = (pos2(x1 - 3.0, y), pos2(x1 + 300.0, y)); + let push = |d: &mut Driver, e: egui::Event| d.harness.input_mut().events.push(e); + push(&mut d, egui::Event::PointerMoved(from)); + d.frames(1); + push(&mut d, egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + d.frames(1); + for i in 1..=60 { + push(&mut d, egui::Event::PointerMoved(from + (to - from) * (i as f32 / 60.0))); + d.frames(1); + } + match &d.app().tl.drag { + Some(Drag::Trim { delta, .. }) => assert_eq!(*delta, limit, "the dragged edge stops at the end of the media"), + other => panic!("not trimming: {other:?}"), + } + push(&mut d, egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + d.frames(2); + assert_eq!(d.span(last).1, e0 + limit.0, "released where it was shown"); +} From ec7c43eea21318a488e0cd38c3d09befac2d5e75 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 02:39:53 +1100 Subject: [PATCH 08/33] Treat media extensions as case-insensitive on import. Camera files such as CLIP.MOV were hidden or skipped on Linux because sibling matching and tests assumed lowercase suffixes. Import, the media browser, and image-sequence numbering now accept mixed-case extensions. --- crates/engine/src/image_sequence_tests.rs | 11 +++++++++++ crates/engine/src/media_browser_tests.rs | 5 +++-- crates/media/src/lib.rs | 14 ++++++++++++++ crates/media/src/sequence.rs | 11 ++++++++++- 4 files changed, 38 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/image_sequence_tests.rs b/crates/engine/src/image_sequence_tests.rs index ca36d9cce..c8f213a2b 100644 --- a/crates/engine/src/image_sequence_tests.rs +++ b/crates/engine/src/image_sequence_tests.rs @@ -11,6 +11,17 @@ use serde_json::{Value, json}; use crate::Session; use crate::media_test_util::tmp_dir; +#[test] +fn import_accepts_uppercase_extensions() { + let dir = tmp_dir("import-uppercase"); + let wav = dir.join("TONE.WAV"); + std::fs::write(&wav, filmcraft_media::wav::write_wav16(&[0.1; 4800], 2, 48_000)).unwrap(); + let mut s = Session::default(); + let r = s.execute("file.import", json!({"paths": [wav.to_string_lossy()]})).unwrap(); + assert_eq!(r["items"].as_array().unwrap().len(), 1); + assert!(s.project.items.values().any(|it| it.name == "TONE.WAV")); +} + fn demo() -> Session { let mut s = Session::default(); s.execute("file.openDemoProject", json!({})).unwrap(); diff --git a/crates/engine/src/media_browser_tests.rs b/crates/engine/src/media_browser_tests.rs index 972220b3a..7b26dc0c6 100644 --- a/crates/engine/src/media_browser_tests.rs +++ b/crates/engine/src/media_browser_tests.rs @@ -29,6 +29,7 @@ impl FakeFs { "/home/me/Movies/edit.edl", "/home/me/Movies/subs.srt", "/home/me/Movies/clip.mov", + "/home/me/Movies/CLIP.MOV", ] { files.insert(f.to_string(), vec![0; 16]); } @@ -100,7 +101,7 @@ fn listing_puts_folders_first_and_hides_unsupported_files() { assert_eq!(l["dir"], "/home/me"); assert_eq!(names(&l), ["Movies"]); let l = s.execute("mediaBrowser.list", json!({"path": "/home/me/Movies"})).unwrap(); - assert_eq!(names(&l), ["Selects", "A-interview.wav", "b-roll.wav", "clip.mov", "edit.edl", "shot_0001.png", "shot_0002.png", "subs.srt"]); + assert_eq!(names(&l), ["Selects", "A-interview.wav", "b-roll.wav", "CLIP.MOV", "clip.mov", "edit.edl", "shot_0001.png", "shot_0002.png", "subs.srt"]); let e = &l["entries"][0]; assert_eq!((e["isDir"].as_bool(), e["kind"].as_str()), (Some(true), Some("folder"))); let shot = l["entries"].as_array().unwrap().iter().find(|e| e["name"] == "shot_0001.png").unwrap(); @@ -117,7 +118,7 @@ fn file_type_filter() { let l = s.execute("mediaBrowser.list", json!({"path": "/home/me/Movies", "fileTypes": "image"})).unwrap(); assert_eq!(names(&l), ["Selects", "shot_0001.png", "shot_0002.png"]); let l = s.execute("mediaBrowser.list", json!({"path": "/home/me/Movies", "fileTypes": "mov"})).unwrap(); - assert_eq!(names(&l), ["Selects", "clip.mov"]); + assert_eq!(names(&l), ["Selects", "CLIP.MOV", "clip.mov"]); // the setting persists and applies by default s.execute("mediaBrowser.settings", json!({"fileTypes": "project"})).unwrap(); let l = s.execute("mediaBrowser.list", json!({"path": "/home/me/Movies"})).unwrap(); diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index 6b5ff1b1e..d65a06304 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -180,6 +180,20 @@ pub fn is_importable(path: &Path) -> bool { STILL_EXTENSIONS.contains(&ext.as_str()) || AUDIO_EXTENSIONS.contains(&ext.as_str()) || VIDEO_EXTENSIONS.contains(&ext.as_str()) } +#[cfg(test)] +mod importable_tests { + use super::*; + use std::path::Path; + + #[test] + fn camera_extensions_match_regardless_of_case() { + for name in ["clip.mov", "clip.MOV", "clip.Mov", "shot.MP4", "IMG_0001.JPG", "sound.WAV", "take.M2TS"] { + assert!(is_importable(Path::new(name)), "{name}"); + } + assert!(!is_importable(Path::new("notes.txt"))); + } +} + /// A pluggable opener (containers/codecs register themselves here from higher-level crates). pub type Opener = fn(name: &str, bytes: Arc<[u8]>) -> Option>; diff --git a/crates/media/src/sequence.rs b/crates/media/src/sequence.rs index 9521cba44..8c60e0158 100644 --- a/crates/media/src/sequence.rs +++ b/crates/media/src/sequence.rs @@ -75,7 +75,15 @@ impl Numbered { /// The frame number of a sibling file name of this sequence (same prefix, suffix and padding). pub fn number_of(&self, file_name: &str) -> Option { - let mid = file_name.strip_prefix(self.prefix.as_str())?.strip_suffix(self.suffix.as_str())?; + let rest = file_name.strip_prefix(self.prefix.as_str())?; + let mid = { + let n = rest.as_bytes(); + let s = self.suffix.as_bytes(); + if n.len() < s.len() || !n[n.len() - s.len()..].eq_ignore_ascii_case(s) { + return None; + } + &rest[..n.len() - s.len()] + }; if mid.is_empty() || !mid.bytes().all(|b| b.is_ascii_digit()) || mid.len() > 18 { return None; } @@ -231,6 +239,7 @@ mod tests { assert!(!u.padded()); assert_eq!(u.file_name(10), "frame10.TIF"); assert_eq!(u.number_of("frame10.TIF"), Some(10)); + assert_eq!(u.number_of("frame10.tif"), Some(10), "Linux stills often mix .TIF and .tif"); assert_eq!(Numbered::parse("clip.mov"), None, "not a still"); assert_eq!(Numbered::parse("nodigits.png"), None); let w = Numbered::parse("C:\\renders\\out.0010.exr.png").unwrap(); From 10ffc016b8977af11101679ea21e4b68401f7e5b Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 17:44:06 +0200 Subject: [PATCH 09/33] #284: reset buttons keep keyframes; a reset button on every property Problem: Reset Effect replaced the effect with a fresh instance, so every keyframe (and every mask) on it was deleted, and Effect Controls had no way to reset a single property. Fix: effects.reset now resets the parameters in place, as Premiere does: a static parameter gets its default, an animated one keeps its keyframes and gets the default as a keyframe at the playhead; masks stay. New effects.resetParam does the same for one parameter, and every Effect Controls property row has a reset button for it (automation id effectControls...reset). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/clip_ops_tests.rs | 34 ++++++++++++ crates/engine/src/commands.rs | 58 +++++++++++++++++--- crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/zh-cn.tsv | 1 + crates/ui-egui/src/panels/effect_controls.rs | 11 ++++ crates/ui-egui/tests/keyframes_ui.rs | 16 ++++++ 7 files changed, 115 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/clip_ops_tests.rs b/crates/engine/src/clip_ops_tests.rs index b6fcfe86d..8e90933ab 100644 --- a/crates/engine/src/clip_ops_tests.rs +++ b/crates/engine/src/clip_ops_tests.rs @@ -923,3 +923,37 @@ fn linked_slip_keeps_picture_and_sound_aligned_at_unequal_media_limits() { 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))); } + +#[test] +fn reset_keeps_keyframes_and_resets_one_parameter() { + // #284: Reset Effect used to replace the effect with a fresh one, deleting every keyframe + let mut s = demo(); + let a = v1(&s)[0].clone(); + let sec = Tick(TICKS_PER_SECOND); + let (t0, t1, mid) = (a.start, a.start + sec, a.start + Tick(TICKS_PER_SECOND / 2)); + for (t, v) in [(t0, 150.0), (t1, 50.0)] { + s.execute("effects.setParam", json!({"clip": a.id.0, "effect": "motion", "param": "scale", "value": v, "keyframe": true, "time": t.0})).unwrap(); + } + s.execute("effects.setParam", json!({"clip": a.id.0, "effect": "motion", "param": "rotation", "value": 30.0})).unwrap(); + let idx = clip(&s, a.id.0).effects.iter().position(|e| e.effect == "motion").unwrap(); + s.execute("playhead.set", json!({"time": mid.0})).unwrap(); + s.execute("effects.reset", json!({"clip": a.id.0, "index": idx})).unwrap(); + let it = clip(&s, a.id.0); + let scale = it.effect("motion").unwrap().param("scale").unwrap(); + // (the playhead snaps to a frame) + let mid_media = it.source_time_at(s.playhead()); + let kf: Vec<(Tick, f64)> = scale.keyframes.iter().map(|k| (k.time, k.value.as_f64().unwrap())).collect(); + assert_eq!(kf, vec![(it.source_time_at(t0), 150.0), (mid_media, 100.0), (it.source_time_at(t1), 50.0)], "keyframes kept, default at the playhead"); + assert_eq!(f64_param(&it, "motion", "rotation"), 0.0); + // one parameter: only it goes back to its default + s.execute("effects.setParam", json!({"clip": a.id.0, "effect": "motion", "param": "rotation", "value": 45.0})).unwrap(); + s.execute("effects.setParam", json!({"clip": a.id.0, "effect": "opacity", "param": "opacity", "value": 40.0})).unwrap(); + s.execute("effects.resetParam", json!({"clip": a.id.0, "effect": "motion", "param": "rotation"})).unwrap(); + let it = clip(&s, a.id.0); + assert_eq!(f64_param(&it, "motion", "rotation"), 0.0); + assert_eq!(f64_param(&it, "opacity", "opacity"), 40.0); + assert_eq!(it.effect("motion").unwrap().param("scale").unwrap().keyframes.len(), 3); + assert!(s.execute("effects.resetParam", json!({"clip": a.id.0, "effect": "motion", "param": "nope"})).is_err()); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(f64_param(&clip(&s, a.id.0), "motion", "rotation"), 45.0); +} diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 0b3a9f8a6..928c25d18 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -2619,15 +2619,35 @@ fn build() -> Vec { cmd!("effects.reset", "Reset Effect", [], None, r#"{"clip":id,"index":n}"#, has_seq, |s, p| { let c = clip_p(p, "clip").ok_or_else(|| bad("effects.reset", "need `clip`"))?; let idx = u64_p(p, "index").unwrap_or(0) as usize; - let (fw, fh) = s.active_sequence().map(|q| (q.settings.width, q.settings.height)).unwrap_or((1920, 1080)); + let frame = s.active_sequence().map(|q| (q.settings.width, q.settings.height)).unwrap_or((1920, 1080)); + let ph = s.playhead(); s.edit_sequence("Reset Effect", |q, _, _| { let (_, it) = q.find_item_mut(c).ok_or(filmcraft_edit::EditError::NoItem(c))?; - if let Some(e) = it.effects.get_mut(idx) - && let Some(d) = e.def() - { - let mut fresh = d.instance(); - resolve_auto_points(&mut fresh, (fw, fh), (fw, fh)); - *e = fresh; + let mt = it.source_time_at(ph.clamp(it.start, (it.end() - Tick(1)).max(it.start))); + if let Some(e) = it.effects.get_mut(idx) { + reset_params(e, None, frame, mt); + } + Ok(()) + })?; + Ok(Value::Null) + }), + cmd!("effects.resetParam", "Reset Parameter", [], None, r#"{"clip":id,"effect":str|index,"param":str}"#, has_seq, |s, p| { + let c = clip_p(p, "clip").ok_or_else(|| bad("effects.resetParam", "need `clip`"))?; + let pid = str_p(p, "param").ok_or_else(|| bad("effects.resetParam", "need `param`"))?.to_string(); + let eff = p.get("effect").cloned().unwrap_or(json!("motion")); + let frame = s.active_sequence().map(|q| (q.settings.width, q.settings.height)).unwrap_or((1920, 1080)); + let ph = s.playhead(); + s.edit_sequence("Reset Parameter", |q, _, _| { + let (_, it) = q.find_item_mut(c).ok_or(filmcraft_edit::EditError::NoItem(c))?; + let mt = it.source_time_at(ph.clamp(it.start, (it.end() - Tick(1)).max(it.start))); + let e = match &eff { + Value::Number(n) => it.effects.get_mut(n.as_u64().unwrap_or(0) as usize), + Value::String(sid) => it.effects.iter_mut().find(|e| &e.effect == sid), + _ => None, + } + .ok_or_else(|| bad("effects.resetParam", "no such effect on clip"))?; + if !reset_params(e, Some(&pid), frame, mt) { + return Err(bad("effects.resetParam", format!("no param `{pid}`"))); } Ok(()) })?; @@ -2843,6 +2863,30 @@ fn build() -> Vec { v } +/// Put `e`'s parameters (all of them, or only `only`) back to their defaults, as Premiere's reset +/// buttons do: a parameter that is not animated gets its default as its static value, and an +/// animated one keeps its keyframes and gets the default as a keyframe at media time `mt`. +/// Masks are left alone. Returns whether any parameter was reset. +fn reset_params(e: &mut filmcraft_project::EffectInstance, only: Option<&str>, frame: (u32, u32), mt: Tick) -> bool { + let Some(d) = e.def() else { return false }; + let mut fresh = d.instance(); + resolve_auto_points(&mut fresh, frame, frame); + let mut any = false; + for (id, prm) in fresh.params { + if only.is_some_and(|o| o != id) { + continue; + } + any = true; + match e.params.get_mut(&id) { + Some(old) if old.is_animated() => old.set_at(mt, prm.value), + _ => { + e.params.insert(id, prm); + } + } + } + any +} + fn keyframe_op(s: &mut Session, p: &Value, op: &str) -> Result { let c = clip_p(p, "clip").ok_or_else(|| bad("keyframe", "need `clip`"))?; let pid = str_p(p, "param").ok_or_else(|| bad("keyframe", "need `param`"))?.to_string(); diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 08f899ecb..980ef5374 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -968,6 +968,7 @@ Reset… Restablecer… Source · {name} Origen · {name} Reset Effect Restablecer efecto + Reset Parameter Restablecer parámetro Save Preset… Guardar ajuste preestablecido… Custom Setup Configuración personalizada Edit… Editar… diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 8dbf44199..f9ec82d09 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -970,6 +970,7 @@ Reset… リセット… Source · {name} ソース · {name} Reset Effect エフェクトをリセット + Reset Parameter パラメーターをリセット Save Preset… プリセットを保存… Custom Setup カスタム設定 Edit… 編集… diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index 731067f98..9e46602c2 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -963,6 +963,7 @@ Reset… 重置… Source · {name} 源 · {name} Reset Effect 重置效果 + Reset Parameter 重置参数 Save Preset… 保存预设… Custom Setup 自定义设置 Edit… 编辑… diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index 1eeb87b20..e1f3aebb8 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -544,6 +544,17 @@ pub(crate) fn param_row( actions.push(("effects.addKeyframe".into(), with_mask(json!({"clip": clip.0, "effect": eff_json, "param": pd.id})))); } } + // reset to the default, under the effect's own reset button (an animated parameter keeps its + // keyframes and gets one at the playhead) + if mask.is_none() { + let rr = Rect::from_center_size(pos2(r.max.x - 14.0, r.center().y), vec2(12.0, 12.0)); + let rresp = ui.interact(rr, egui::Id::new(("param-reset", clip.0, idx, pkey)), Sense::click()).on_hover_text(tl!("Reset Parameter")); + icons::paint(ui.painter(), rr, Icon::Reset, if rresp.hovered() { t.text } else { t.text_dim }); + app.auto.add(&format!("effectControls.{}.{}.reset", e.effect, pkey), rr, "Reset Parameter"); + if rresp.clicked() { + actions.push(("effects.resetParam".into(), json!({"clip": clip.0, "effect": eff_json, "param": pd.id}))); + } + } // keyframes in the lane: draggable diamonds; right-click for interpolation if param.is_animated() { let y = r.center().y; diff --git a/crates/ui-egui/tests/keyframes_ui.rs b/crates/ui-egui/tests/keyframes_ui.rs index 630a8ceae..87986f547 100644 --- a/crates/ui-egui/tests/keyframes_ui.rs +++ b/crates/ui-egui/tests/keyframes_ui.rs @@ -184,6 +184,22 @@ fn properties_diamond_adds_and_removes_the_keyframe_at_the_playhead() { assert!(d.find(&format!("{SCALE}.prevKeyframe")).is_none()); } +/// #284: every Effect Controls property has a reset button; on an animated property it puts the +/// default in as a keyframe at the playhead and keeps the other keyframes. +#[test] +fn effect_controls_reset_button_keeps_keyframes() { + let (mut d, clip) = Driver::demo(); + let t0 = d.seek(0.5); + d.click(&format!("{SCALE}.addKeyframe")); + d.exec("effects.setParam", json!({"clip": clip.id, "effect": "motion", "param": "scale", "value": 150.0, "time": t0})); + d.seek(2.5); + d.click("effectControls.motion.scale.reset"); + d.shot("effect-controls-reset"); + let (keys, value) = d.scale(&clip); + assert_eq!(keys.len(), 2, "the keyframe stays and the default is keyed at the playhead: {keys:?}"); + assert_eq!(value, 100.0); +} + /// Crop is not on a clip until it is used: its diamond applies the effect, then adds the keyframe. #[test] fn properties_diamond_applies_crop_first() { From 4b67c44dbe9a9dc78cacb30cb2bf04e1f12a3403 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 18:12:54 +0200 Subject: [PATCH 10/33] #342: ProRes 4444 and 4444 XQ export, with alpha Problem: the ProRes encoder already codes 4444 / 4444 XQ (optionally with alpha), but export only knew the four 422 flavours: any other proresProfile fell back to HQ and the encoder was always fed a 4:2:2 frame, so graphics could not round-trip with their alpha. Fix: proresProfile accepts 4444 and 4444xq. The ProRes export encoder builds a 4:4:4 frame for them and, with Include Alpha Channel, codes the straight alpha of the unflattened picture (ExportSettings:: supports_alpha / keeps_alpha). Export Settings offers both profiles and the alpha checkbox for them; new presets Apple ProRes 4444, 4444 with Alpha and 4444 XQ; summary text and the MXF coding label name the profile (and the MXF descriptor keeps its 12-bit 4:4:4 depth). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands.rs | 2 +- crates/engine/src/export_tests.rs | 9 ++- crates/export/src/lib.rs | 75 ++++++++++++++++++++---- crates/export/src/mxf_out.rs | 7 +-- crates/export/src/pipeline.rs | 4 +- crates/export/src/presets.rs | 8 +++ crates/export/src/settings.rs | 19 +++++- crates/export/src/tests.rs | 45 ++++++++++++++ crates/ui-egui/src/panels/export_mode.rs | 7 ++- 9 files changed, 154 insertions(+), 22 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 0b3a9f8a6..ec5385be8 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1180,7 +1180,7 @@ fn build() -> Vec { "Media…", ["File", "Export"], None, - r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|gif|wav|aiff"?,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass"?,"hardwareEncoding":"off|auto"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"alpha":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq"?,"dnxProfile":"lb|sq|hq|hqx"?,"apvProfile":"422-10|422-12|444-10|444-12"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#, + r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|gif|wav|aiff"?,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass"?,"hardwareEncoding":"off|auto"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"alpha":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq|4444|4444xq"?,"dnxProfile":"lb|sq|hq|hqx"?,"apvProfile":"422-10|422-12|444-10|444-12"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#, has_seq, crate::export_tools::export_media ), diff --git a/crates/engine/src/export_tests.rs b/crates/engine/src/export_tests.rs index 2757ebba4..a1f3f2318 100644 --- a/crates/engine/src/export_tests.rs +++ b/crates/engine/src/export_tests.rs @@ -202,9 +202,16 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() { "proxy" => ("Proxy", 45.0), "lt" => ("LT", 102.0), "standard" => ("Standard", 147.0), + "4444" => ("4444", 330.0), + "4444xq" => ("XQ", 500.0), _ => ("HQ", 220.0), }; assert_eq!(v["profile"], profile, "{name}"); + if matches!(profile, "4444" | "XQ") { + let pix = v["pix_fmt"].as_str().unwrap_or_default(); + let alpha = settings["alpha"].as_bool().unwrap_or(false); + assert!(pix.contains("444") && pix.starts_with("yuva") == alpha, "{name}: {pix}"); + } assert!(vbr <= mbps * px * 1.5, "{name}: {vbr:.1} Mb/s vs nominal {:.1}", mbps * px); assert_eq!(a["codec_name"], "pcm_s24le", "{name}"); } @@ -303,7 +310,7 @@ fn user_presets_persist_with_favourites_import_and_export() { let favs = s.execute("export.presets.list", json!({"favorites": true})).unwrap()["presets"].as_array().unwrap().len(); assert_eq!(favs, 2); let hits = s.execute("export.presets.list", json!({"query": "prores"})).unwrap()["presets"].as_array().unwrap().len(); - assert_eq!(hits, 5, "four QuickTime ProRes presets and MXF OP1a ProRes 422 HQ"); + assert_eq!(hits, 8, "seven QuickTime ProRes presets and MXF OP1a ProRes 422 HQ"); // persisted: a fresh session with the same data directory sees both let mut s2 = demo(); s2.export_presets.set_dir(&dir.0); diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 312d0301e..05196d72c 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -239,7 +239,7 @@ pub struct ExportSettings { /// `progress.total`, `finished` and the final status; this call only adds to `done`. #[serde(default)] pub part_of_batch: bool, - /// ProRes flavour: `proxy`, `lt`, `standard` or `hq` (empty = HQ). + /// ProRes flavour: `proxy`, `lt`, `standard`, `hq`, `4444` or `4444xq` (empty = HQ). #[serde(default)] pub prores_profile: String, /// DNxHR profile: `lb`, `sq`, `hq` or `hqx` (empty = HQ). @@ -256,8 +256,9 @@ pub struct ExportSettings { /// sequence's HDR space and signal it (VUI / `colr` / `mdcv` / `clli` / SEI). #[serde(default)] pub sdr: bool, - /// Keep the alpha channel in PNG and TIFF sequences (straight alpha, Premiere's "Include - /// Alpha Channel"). Off, every frame is flattened over black (#160). Other formats ignore it. + /// Keep the alpha channel in PNG and TIFF sequences and ProRes 4444 / 4444 XQ QuickTime movies + /// (straight alpha, Premiere's "Include Alpha Channel"). Off, every frame is flattened over + /// black (#160). Other formats ignore it. #[serde(default)] pub alpha: bool, /// Output frame size (None = Match Source: the sequence size times `scale`). @@ -986,6 +987,8 @@ fn aac_factory(_format: Format, sample_rate: u32, channels: u32, s: &ExportSetti struct ProResEncoder { enc: filmcraft_prores::Encoder, profile: filmcraft_prores::Profile, + /// Code the frames' alpha channel (4444 profiles with Include Alpha Channel). + alpha: bool, w: u32, h: u32, rate: FrameRate, @@ -1002,13 +1005,19 @@ impl VideoEncoder for ProResEncoder { self.rate.num as u32 } fn encode(&mut self, f: &EncoderFrame) -> Result> { - let mut fr = filmcraft_prores::Frame::new(f.width, f.height, filmcraft_prores::ChromaFormat::Yuv422, 10, false); - timed(Stage::Convert, || match f.hdr { - Some(rgb) => { - let (kr, kb) = self.signal.kr_kb(); - rgbf_to_yuv422_10(rgb, f.width as usize, f.height as usize, kr, kb, &mut fr.y, &mut fr.cb, &mut fr.cr) + let chroma = self.profile.chroma(); + let mut fr = filmcraft_prores::Frame::new(f.width, f.height, chroma, 10, self.alpha); + let (w, h) = (f.width as usize, f.height as usize); + timed(Stage::Convert, || match (chroma, f.hdr) { + (filmcraft_prores::ChromaFormat::Yuv444, hdr) => { + let (kr, kb) = if hdr.is_some() { self.signal.kr_kb() } else { (0.2126, 0.0722) }; + to_yuv444_10(f.rgba, hdr, w, h, kr, kb, &mut fr.y, &mut fr.cb, &mut fr.cr, fr.alpha.as_deref_mut()) } - None => rgba_to_yuv422_10(f.rgba, f.width as usize, f.height as usize, &mut fr.y, &mut fr.cb, &mut fr.cr), + (_, Some(rgb)) => { + let (kr, kb) = self.signal.kr_kb(); + rgbf_to_yuv422_10(rgb, w, h, kr, kb, &mut fr.y, &mut fr.cb, &mut fr.cr) + } + (_, None) => rgba_to_yuv422_10(f.rgba, w, h, &mut fr.y, &mut fr.cb, &mut fr.cr), }); let data = self.enc.encode(&fr).map_err(|e| ExportError::Encode(e.to_string()))?; Ok(vec![EncodedPacket { data, key: true, duration: self.rate.den as u32, composition_offset: 0 }]) @@ -1018,6 +1027,47 @@ impl VideoEncoder for ProResEncoder { } } +/// Limited-range 10-bit 4:4:4 with matrix (Kr, Kb) from straight RGBA8, or from encoded R'G'B' +/// floats (3 per pixel) when `hdr` is given. `alpha` (full range, 0 = transparent) takes the +/// RGBA8 alpha; it stays opaque for float input, which carries none. +#[allow(clippy::too_many_arguments)] +pub fn to_yuv444_10( + rgba: &[u8], + hdr: Option<&[f32]>, + w: usize, + h: usize, + kr: f32, + kb: f32, + y: &mut [u16], + cb: &mut [u16], + cr: &mut [u16], + alpha: Option<&mut [u16]>, +) { + let (w, n) = (w.max(1), w.saturating_mul(h)); + let kg = 1.0 - kr - kb; + let (sb, sr) = (2.0 * (1.0 - kb), 2.0 * (1.0 - kr)); + let rgb_at = |i: usize| -> [f32; 3] { + match hdr { + Some(f) => f.get(i * 3..i * 3 + 3).map_or([0.0; 3], |p| [p[0], p[1], p[2]]), + None => rgba.get(i * 4..i * 4 + 3).map_or([0.0; 3], |p| [p[0] as f32 / 255.0, p[1] as f32 / 255.0, p[2] as f32 / 255.0]), + } + }; + y.par_chunks_mut(w).zip(cb.par_chunks_mut(w).zip(cr.par_chunks_mut(w))).enumerate().for_each(|(row, (yr, (cbr, crr)))| { + for (x, (yv, (cbv, crv))) in yr.iter_mut().zip(cbr.iter_mut().zip(crr.iter_mut())).enumerate() { + let [r, g, b] = rgb_at(row * w + x); + let yy = kr * r + kg * g + kb * b; + *yv = (64.0 + 876.0 * yy).round().clamp(4.0, 1019.0) as u16; + *cbv = (512.0 + 896.0 * (b - yy) / sb).round().clamp(4.0, 1019.0) as u16; + *crv = (512.0 + 896.0 * (r - yy) / sr).round().clamp(4.0, 1019.0) as u16; + } + }); + if let (Some(a), None) = (alpha, hdr) { + for (i, dst) in a.iter_mut().enumerate().take(n) { + *dst = rgba.get(i * 4 + 3).map_or(1023, |&v| ((v as u32 * 1023 + 127) / 255) as u16); + } + } +} + /// BT.709 limited-range 10-bit 4:2:2 from straight RGBA8 (chroma averaged horizontally). pub fn rgba_to_yuv422_10(rgba: &[u8], w: usize, h: usize, y: &mut [u16], cb: &mut [u16], cr: &mut [u16]) { let cw = w.div_ceil(2); @@ -1051,6 +1101,8 @@ pub fn prores_profile(name: &str) -> filmcraft_prores::Profile { "proxy" => Profile::Proxy, "lt" => Profile::Lt, "standard" | "422" => Profile::Standard, + "4444" => Profile::P4444, + "4444xq" | "4444 xq" | "4444-xq" | "xq" => Profile::P4444Xq, _ => Profile::Hq, } } @@ -1089,7 +1141,10 @@ fn prores_factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSet if s.signal.is_hdr() { cfg.color = filmcraft_prores::ColorInfo { primaries: s.signal.primaries, transfer: s.signal.transfer, matrix: s.signal.matrix }; } - Ok(Box::new(ProResEncoder { enc: filmcraft_prores::Encoder::with_config(cfg), profile, w, h, rate, signal: s.signal }) as Box) + let alpha = s.keeps_alpha(); + cfg.encode_alpha = alpha; + let enc = filmcraft_prores::Encoder::with_config(cfg); + Ok(Box::new(ProResEncoder { enc, profile, alpha, w, h, rate, signal: s.signal }) as Box) }) } diff --git a/crates/export/src/mxf_out.rs b/crates/export/src/mxf_out.rs index 24233e7a3..ecd8ede23 100644 --- a/crates/export/src/mxf_out.rs +++ b/crates/export/src/mxf_out.rs @@ -38,7 +38,9 @@ fn picture(settings: &ExportSettings, w: u32, h: u32) -> PictureDesc { Profile::Proxy => 1, Profile::Lt => 2, Profile::Standard => 3, - _ => 4, + Profile::Hq => 4, + Profile::P4444 => 5, + Profile::P4444Xq => 6, }, } } @@ -53,9 +55,6 @@ fn picture(settings: &ExportSettings, w: u32, h: u32) -> PictureDesc { _ => PictureCoding::Vc3 { cid: crate::dnx_profile(&settings.dnx_profile).cid() }, }; let mut d = PictureDesc::new(coding, w, h); - if matches!(coding, PictureCoding::ProRes { .. }) { - d.depth = 10; - } d.color = match settings.signal { s if s == ColorSignal::PQ => ColorSpace::Rec2020Pq, s if s == ColorSignal::HLG => ColorSpace::Rec2020Hlg, diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index 306aef2fd..78503d002 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -12,7 +12,7 @@ use filmcraft_time::FrameRate; use rayon::prelude::*; use crate::settings::{ExportEffects, Placement, Scaling, TextOverlay}; -use crate::{ExportError, ExportSettings, Format, FrameRenderer, GpuRendering, Result, build_frame_renderer, note_gpu_fallback, note_gpu_frame}; +use crate::{ExportError, ExportSettings, FrameRenderer, GpuRendering, Result, build_frame_renderer, note_gpu_fallback, note_gpu_frame}; /// Where the rendered picture lands in the output frame. #[derive(Clone, Copy, Debug, PartialEq)] @@ -201,7 +201,7 @@ impl Pipeline { geom, opts, hdr_out, - alpha: settings.alpha && matches!(settings.format, Format::PngSequence | Format::TiffSequence), + alpha: settings.keeps_alpha(), out_tf, effects, overlay, diff --git a/crates/export/src/presets.rs b/crates/export/src/presets.rs index 3aa37d056..4323de772 100644 --- a/crates/export/src/presets.rs +++ b/crates/export/src/presets.rs @@ -122,6 +122,14 @@ pub fn builtin_presets() -> Vec { p("Apple ProRes 422", bc, "QuickTime, ProRes 422, 24-bit 48 kHz PCM", mov(Format::ProRes, "standard")), p("Apple ProRes 422 LT", bc, "QuickTime, ProRes 422 LT, 24-bit 48 kHz PCM", mov(Format::ProRes, "lt")), p("Apple ProRes 422 Proxy", bc, "QuickTime, ProRes 422 Proxy, 24-bit 48 kHz PCM", mov(Format::ProRes, "proxy")), + p("Apple ProRes 4444", bc, "QuickTime, ProRes 4444 (4:4:4), 24-bit 48 kHz PCM", mov(Format::ProRes, "4444")), + p( + "Apple ProRes 4444 with Alpha", + bc, + "QuickTime, ProRes 4444 (4:4:4) with alpha channel, 24-bit 48 kHz PCM", + ExportSettings { alpha: true, ..mov(Format::ProRes, "4444") }, + ), + p("Apple ProRes 4444 XQ", bc, "QuickTime, ProRes 4444 XQ (4:4:4), 24-bit 48 kHz PCM", mov(Format::ProRes, "4444xq")), p("Avid DNxHR HQ", bc, "QuickTime, DNxHR HQ (8-bit 4:2:2), 24-bit 48 kHz PCM", mov(Format::DnxHr, "hq")), p("Avid DNxHR SQ", bc, "QuickTime, DNxHR SQ (8-bit 4:2:2), 24-bit 48 kHz PCM", mov(Format::DnxHr, "sq")), p("Avid DNxHR LB", bc, "QuickTime, DNxHR LB (8-bit 4:2:2), 24-bit 48 kHz PCM", mov(Format::DnxHr, "lb")), diff --git a/crates/export/src/settings.rs b/crates/export/src/settings.rs index 8e8fc7ef2..8b761693e 100644 --- a/crates/export/src/settings.rs +++ b/crates/export/src/settings.rs @@ -374,6 +374,21 @@ impl ExportSettings { if self.format.is_mxf() { self.mxf_video_codec.encoder_format() } else { self.format } } + /// Whether the output can carry an alpha channel ([`ExportSettings::alpha`]): PNG and TIFF + /// sequences and ProRes 4444 / 4444 XQ QuickTime movies. + pub fn supports_alpha(&self) -> bool { + match self.format { + Format::PngSequence | Format::TiffSequence => true, + Format::ProRes => crate::prores_profile(&self.prores_profile).chroma() == filmcraft_prores::ChromaFormat::Yuv444, + _ => false, + } + } + + /// Whether the exported frames keep their alpha channel instead of being flattened over black. + pub fn keeps_alpha(&self) -> bool { + self.alpha && self.supports_alpha() + } + /// Whether the output is a numbered image sequence. pub fn is_image_sequence(&self) -> bool { matches!(self.format, Format::PngSequence | Format::TiffSequence | Format::BmpSequence) @@ -540,7 +555,9 @@ impl ExportSettings { Profile::Proxy => ", ProRes 422 Proxy", Profile::Lt => ", ProRes 422 LT", Profile::Standard => ", ProRes 422", - _ => ", ProRes 422 HQ", + Profile::Hq => ", ProRes 422 HQ", + Profile::P4444 => ", ProRes 4444", + Profile::P4444Xq => ", ProRes 4444 XQ", } } Format::DnxHr => v += &format!(", DNxHR {}", if self.dnx_profile.is_empty() { "HQ".into() } else { self.dnx_profile.to_ascii_uppercase() }), diff --git a/crates/export/src/tests.rs b/crates/export/src/tests.rs index 656ddd9f5..ae8e9fd6c 100644 --- a/crates/export/src/tests.rs +++ b/crates/export/src/tests.rs @@ -101,6 +101,51 @@ fn prores_export_roundtrip() { assert!(f[0] > 240 && f[1] < 15 && f[2] < 15, "{:?}", &f[..4]); } +/// ProRes 4444 / 4444 XQ export (#342): 4:4:4 frames under the `ap4h` / `ap4x` codes, and with +/// Include Alpha Channel the picture's alpha survives instead of being flattened over black. +#[test] +fn prores_4444_export_roundtrip() { + let (p, seq, m) = project(); + for (profile, label) in [("4444", "ProRes 4444"), ("4444xq", "ProRes 4444 XQ")] { + let path = tmp(&format!("pr_{profile}.mov")); + let s = ExportSettings { format: Format::ProRes, path: path.clone(), prores_profile: profile.into(), ..Default::default() }; + export(&p, seq, &s, &m, &Progress::default()).unwrap(); + let bytes: Arc<[u8]> = std::fs::read(&path).unwrap().into(); + let src = filmcraft_codecs::open_bytes("pr.mov", bytes).unwrap(); + let codec = src.info().video.as_ref().unwrap().codec.clone(); + assert!(codec.contains(label), "{profile}: {codec}"); + let f = src.video_frame(FrameRequest::full(Tick(TICKS_PER_SECOND / 3))).unwrap(); + assert!(f.format_label().contains("4:4:4"), "{profile}: {}", f.format_label()); + let px = f.to_rgba8(); + assert!(px[0] > 240 && px[1] < 15 && px[2] < 15 && px[3] == 255, "{profile}: {:?}", &px[..4]); + } + + // a half-transparent matte keeps its alpha with Include Alpha Channel, and only then + let mut p = (*p).clone(); + let half = GeneratorSource::new(Generator::ColorMatte { color: [1.0, 0.0, 0.0, 0.5] }, 320, 180, FrameRate::FPS_24, Tick(2 * TICKS_PER_SECOND)); + let item = p.sequence(seq).unwrap().video_tracks[0].items[0].item; + if let ItemKind::Media(mc) = &mut p.item_mut(item).unwrap().kind { + mc.media = MediaRef::Generator(half.generator.clone()); + } + let mut m = m; + m.0.insert(item, Arc::new(half)); + let p = Arc::new(p); + for alpha in [true, false] { + let path = tmp(&format!("pr_4444_alpha_{alpha}.mov")); + let s = ExportSettings { format: Format::ProRes, path: path.clone(), prores_profile: "4444".into(), alpha, ..Default::default() }; + assert!(s.keeps_alpha() == alpha); + export(&p, seq, &s, &m, &Progress::default()).unwrap(); + let bytes: Arc<[u8]> = std::fs::read(&path).unwrap().into(); + let src = filmcraft_codecs::open_bytes("pr.mov", bytes).unwrap(); + let px = src.video_frame(FrameRequest::full(Tick(TICKS_PER_SECOND / 3))).unwrap().to_rgba8(); + if alpha { + assert!((px[3] as i32 - 128).abs() <= 3, "alpha kept: {:?}", &px[..4]); + } else { + assert_eq!(px[3], 255, "flattened: {:?}", &px[..4]); + } + } +} + #[test] fn dnxhr_export_roundtrip() { let (p, seq, m) = project(); diff --git a/crates/ui-egui/src/panels/export_mode.rs b/crates/ui-egui/src/panels/export_mode.rs index b66ab5521..c93f5ebc8 100644 --- a/crates/ui-egui/src/panels/export_mode.rs +++ b/crates/ui-egui/src/panels/export_mode.rs @@ -606,7 +606,7 @@ fn video_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T } }); } - if matches!(s.format, Format::PngSequence | Format::TiffSequence) { + if s.supports_alpha() { row(ui, t, tl!("Alpha"), |ui| { check(ui, reg, "export.video.alpha", &mut s.alpha, tl!("Include Alpha Channel")); }); @@ -700,8 +700,9 @@ fn video_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T } Format::ProRes => { row(ui, t, tl!("Profile"), |ui| { - let keys = ["proxy", "lt", "standard", "hq"]; - let labels = ["Apple ProRes 422 Proxy", "Apple ProRes 422 LT", "Apple ProRes 422", "Apple ProRes 422 HQ"]; + let keys = ["proxy", "lt", "standard", "hq", "4444", "4444xq"]; + let labels = + ["Apple ProRes 422 Proxy", "Apple ProRes 422 LT", "Apple ProRes 422", "Apple ProRes 422 HQ", "Apple ProRes 4444", "Apple ProRes 4444 XQ"]; let cur = keys.iter().position(|k| *k == s.prores_profile).unwrap_or(3); if let Some(i) = combo(ui, reg, "export.video.proresProfile", labels[cur], &opts(&labels), 220.0) { s.prores_profile = keys[i].into(); From 5b9a909d477f9d55ed9ced1f96da30ee36ce1959 Mon Sep 17 00:00:00 2001 From: Manuel Camacho Date: Sat, 10 Oct 2026 09:41:45 -0700 Subject: [PATCH 11/33] HW5: AV1 sequence headers compare by picture format, so SVT-AV1 files stay on hardware (#30) FrameStreamInfo::parameters_changed compared the in-band AV1 sequence header with av1C's byte for byte. SVT-AV1 writes a provisional av1C header whose coding tool flags (CDEF, restoration, warped motion...) differ from the in-band one, so every such MP4 dropped to the software decoder from its first frame, on every hardware backend. Headers now compare by what the picture format depends on: profile, maximum size and colour configuration. A header that does not parse still counts as a change. Regression test with an SVT-AV1 720p 10-bit av1C / in-band pair. Co-Authored-By: Claude Opus 5.5 --- crates/codecs/src/hw_frame.rs | 57 +++++++++++++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 3 deletions(-) diff --git a/crates/codecs/src/hw_frame.rs b/crates/codecs/src/hw_frame.rs index b37fc4d01..dfa64771c 100644 --- a/crates/codecs/src/hw_frame.rs +++ b/crates/codecs/src/hw_frame.rs @@ -82,6 +82,13 @@ pub(crate) fn obus(data: &[u8]) -> Vec<(u8, &[u8])> { out } +/// What an AV1 sequence header fixes about the pictures: profile, maximum size and the colour +/// configuration (bit depth, chroma format, colour description). `None` when it does not parse. +fn av1_format(seq: &[u8]) -> Option<(u8, u32, u32, filmcraft_av1::ColorConfig)> { + let h = filmcraft_av1::SequenceHeader::parse(seq).ok()?; + Some((h.profile, h.max_frame_width, h.max_frame_height, h.color)) +} + /// VP9 colour as our decoder reports it: `color_space` and range from the bitstream, transfer and /// primaries from the container when it says. (Shared with `Vp9Decoder`.) pub(crate) fn vp9_color(width: u32, height: u32, color_space: u8, full_range: bool, transfer: Option, primaries: Option) -> ColorInfo { @@ -243,14 +250,58 @@ impl FrameStreamInfo { } /// Whether `sample` declares parameters the hardware session was not set up for: an AV1 - /// sequence header that differs from `av1C`'s, a VP9 key frame of another size, bit depth or - /// chroma format. The decoder then hands the stream to our software decoder. + /// sequence header whose picture format (profile, size, colour configuration) differs from + /// `av1C`'s, a VP9 key frame of another size, bit depth or chroma format. The decoder then + /// hands the stream to our software decoder. pub fn parameters_changed(&self, sample: &[u8]) -> bool { match self.codec { - FrameCodec::Av1 => obus(sample).into_iter().any(|(t, p)| t == 1 && self.sequence_header.as_deref() != Some(p)), + // Only what the picture format depends on counts: encoders such as SVT-AV1 put a + // provisional sequence header in `av1C` whose coding tool flags (CDEF, restoration, + // warped motion…) differ from the in-band one, and decoders follow the in-band one. + FrameCodec::Av1 => obus(sample).into_iter().any(|(t, p)| t == 1 && self.sequence_header.as_deref().is_none_or(|s| av1_format(s) != av1_format(p))), FrameCodec::Vp9 => { self.picture_params(sample).is_some_and(|p| p.size != self.size || p.bit_depth != self.bit_depth || p.subsampling != self.subsampling) } } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// An `av1C` sequence header from SVT-AV1 (1280x720 10-bit) and the in-band one of the same + /// stream: they differ only in coding tool flags. + const AV1C: [u8; 14] = [0x0a, 0x0c, 0x02, 0x00, 0x00, 0x2d, 0x6a, 0x67, 0xfd, 0x9e, 0x01, 0x7c, 0x20, 0x20]; + const IN_BAND: [u8; 14] = [0x0a, 0x0c, 0x02, 0x00, 0x00, 0x2d, 0x6a, 0x67, 0xfd, 0x9e, 0x35, 0x7c, 0xe0, 0x20]; + + fn info() -> FrameStreamInfo { + let c = filmcraft_isobmff::Av1Config { + seq_profile: 0, + seq_level_idx_0: 0, + seq_tier_0: false, + high_bitdepth: true, + twelve_bit: false, + monochrome: false, + chroma_subsampling_x: true, + chroma_subsampling_y: true, + chroma_sample_position: 0, + initial_presentation_delay_minus_one: None, + config_obus: AV1C.to_vec(), + }; + FrameStreamInfo::from_av1(&c, (0, 0)).unwrap() + } + + #[test] + fn av1_sequence_headers_compare_by_picture_format() { + let i = info(); + assert_eq!((i.size, i.bit_depth), ((1280, 720), 10)); + assert!(!i.parameters_changed(&AV1C), "the same header"); + // used to send every SVT-AV1 MP4 to the software decoder from its first frame + assert!(!i.parameters_changed(&IN_BAND), "only coding tool flags differ"); + // a header that does not parse is a change + assert!(i.parameters_changed(&[0x0a, 0x02, 0xe0, 0x00])); + // no sequence header in the sample: nothing changed + assert!(!i.parameters_changed(&[0x12, 0x00])); + } +} From 0c2cc293bccdaf295c473209a3387bd4a85dff51 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 19:22:28 +0200 Subject: [PATCH 12/33] #342: translate the ProRes 4444 export presets i18n::tests::spanish_translates_dynamic_names lists every built-in export preset's name and description; the three new ProRes 4444 presets had no Spanish entry, so ui-egui's lib tests failed. Adds them to es, ja and zh-cn. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/es.tsv | 6 ++++++ crates/ui-egui/src/i18n/ja.tsv | 6 ++++++ crates/ui-egui/src/i18n/zh-cn.tsv | 6 ++++++ 3 files changed, 18 insertions(+) diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 08f899ecb..681fe92de 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2975,6 +2975,9 @@ Apple ProRes 422 HQ Apple ProRes 422 HQ Apple ProRes 422 LT Apple ProRes 422 LT Apple ProRes 422 Proxy Apple ProRes 422 Proxy + Apple ProRes 4444 Apple ProRes 4444 + Apple ProRes 4444 with Alpha Apple ProRes 4444 con alfa + Apple ProRes 4444 XQ Apple ProRes 4444 XQ Application Aplicación Apply Guide Template Aplicar plantilla de guías Auto Save Now Guardar automáticamente ahora @@ -3184,6 +3187,9 @@ QuickTime, ProRes 422 HQ, 24-bit 48 kHz PCM QuickTime, ProRes 422 HQ, PCM de 24 bits y 48 kHz QuickTime, ProRes 422 LT, 24-bit 48 kHz PCM QuickTime, ProRes 422 LT, PCM de 24 bits y 48 kHz QuickTime, ProRes 422 Proxy, 24-bit 48 kHz PCM QuickTime, ProRes 422 Proxy, PCM de 24 bits y 48 kHz + QuickTime, ProRes 4444 (4:4:4), 24-bit 48 kHz PCM QuickTime, ProRes 4444 (4:4:4), PCM de 24 bits y 48 kHz + QuickTime, ProRes 4444 (4:4:4) with alpha channel, 24-bit 48 kHz PCM QuickTime, ProRes 4444 (4:4:4) con canal alfa, PCM de 24 bits y 48 kHz + QuickTime, ProRes 4444 XQ (4:4:4), 24-bit 48 kHz PCM QuickTime, ProRes 4444 XQ (4:4:4), PCM de 24 bits y 48 kHz QuickTime, ProRes 422, 24-bit 48 kHz PCM QuickTime, ProRes 422, PCM de 24 bits y 48 kHz Quit FilmCraft Salir de FilmCraft Rate Stretch Tool Herramienta Ajuste de velocidad diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 8dbf44199..be1f78a23 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2970,6 +2970,9 @@ Apple ProRes 422 HQ Apple ProRes 422 HQ Apple ProRes 422 LT Apple ProRes 422 LT Apple ProRes 422 Proxy Apple ProRes 422 Proxy + Apple ProRes 4444 Apple ProRes 4444 + Apple ProRes 4444 with Alpha Apple ProRes 4444(アルファ付き) + Apple ProRes 4444 XQ Apple ProRes 4444 XQ Application アプリケーション Apply Guide Template ガイドテンプレートを適用 Auto Save Now 今すぐ自動保存 @@ -3179,6 +3182,9 @@ 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 4444 (4:4:4), 24-bit 48 kHz PCM QuickTime、ProRes 4444 (4:4:4)、24-bit 48 kHz PCM + QuickTime, ProRes 4444 (4:4:4) with alpha channel, 24-bit 48 kHz PCM QuickTime、ProRes 4444 (4:4:4) アルファチャンネル付き、24-bit 48 kHz PCM + QuickTime, ProRes 4444 XQ (4:4:4), 24-bit 48 kHz PCM QuickTime、ProRes 4444 XQ (4:4:4)、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 レートストレッチツール diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index 731067f98..44e1de7df 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -2963,6 +2963,9 @@ Apple ProRes 422 HQ Apple ProRes 422 HQ Apple ProRes 422 LT Apple ProRes 422 LT Apple ProRes 422 Proxy Apple ProRes 422 Proxy + Apple ProRes 4444 Apple ProRes 4444 + Apple ProRes 4444 with Alpha Apple ProRes 4444(带 Alpha) + Apple ProRes 4444 XQ Apple ProRes 4444 XQ Application 应用程序 Apply Guide Template 应用参考线模板 Auto Save Now 立即自动保存 @@ -3172,6 +3175,9 @@ QuickTime, ProRes 422 HQ, 24-bit 48 kHz PCM QuickTime,ProRes 422 HQ,24 位 48 kHz PCM QuickTime, ProRes 422 LT, 24-bit 48 kHz PCM QuickTime,ProRes 422 LT,24 位 48 kHz PCM QuickTime, ProRes 422 Proxy, 24-bit 48 kHz PCM QuickTime,ProRes 422 Proxy,24 位 48 kHz PCM + QuickTime, ProRes 4444 (4:4:4), 24-bit 48 kHz PCM QuickTime,ProRes 4444 (4:4:4),24 位 48 kHz PCM + QuickTime, ProRes 4444 (4:4:4) with alpha channel, 24-bit 48 kHz PCM QuickTime,ProRes 4444 (4:4:4),带 Alpha 通道,24 位 48 kHz PCM + QuickTime, ProRes 4444 XQ (4:4:4), 24-bit 48 kHz PCM QuickTime,ProRes 4444 XQ (4:4:4),24 位 48 kHz PCM QuickTime, ProRes 422, 24-bit 48 kHz PCM QuickTime,ProRes 422,24 位 48 kHz PCM Quit FilmCraft 退出 FilmCraft Rate Stretch Tool 速率伸缩工具 From 30417a345d7a0556ac518acf7f8a357c7420e4bb Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:56:04 +0700 Subject: [PATCH 13/33] settings: reflect unwired Timeline focus preference in schema --- crates/engine/src/settings.rs | 2 +- crates/engine/src/settings_tests.rs | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/settings.rs b/crates/engine/src/settings.rs index 2f020835f..68271518b 100644 --- a/crates/engine/src/settings.rs +++ b/crates/engine/src/settings.rs @@ -1074,7 +1074,7 @@ static CATEGORIES: &[Category] = &[ f("timeline.multichannelMonoMediaTracks", "Multichannel Mono Media", Kind::Choice(TRACKS), false), ], ), - b("timeline.focusTimelineOnEdit", "Set focus on the Timeline when performing Insert/Overwrite edits", true), + b("timeline.focusTimelineOnEdit", "Set focus on the Timeline when performing Insert/Overwrite edits", false), b("timeline.snapPlayhead", "Snap playhead in Timeline when Snap is enabled", true), b("timeline.returnToBeginning", "At playback end, return to beginning when restarting playback", true), b("timeline.outOfSyncUnlinked", "Display out of sync indicators for unlinked clips", false), diff --git a/crates/engine/src/settings_tests.rs b/crates/engine/src/settings_tests.rs index 241f36f6d..50a3cf3af 100644 --- a/crates/engine/src/settings_tests.rs +++ b/crates/engine/src/settings_tests.rs @@ -251,6 +251,18 @@ fn schema_command_describes_every_category() { assert_eq!(auto["value"], "pageScroll"); assert_eq!(auto["wired"], true); assert!(auto["kind"]["choices"].as_array().unwrap().iter().any(|c| c["value"] == "smoothScroll")); + + // This option is stored, but Insert/Overwrite does not yet implement focus transfer. + let focus = tl["fields"].as_array().unwrap().iter().find(|f| f["key"] == "timeline.focusTimelineOnEdit").unwrap(); + assert_eq!(focus["kind"], json!({"type": "bool"})); + assert_eq!(focus["wired"], false); + assert_eq!(focus["value"], false); + set(&mut s, "timeline.focusTimelineOnEdit", json!(true)); + let updated = s.execute("prefs.schema", json!({"category": "timeline"})).unwrap(); + let focus = updated["categories"][0]["fields"].as_array().unwrap().iter().find(|f| f["key"] == "timeline.focusTimelineOnEdit").unwrap(); + assert_eq!(focus["value"], true); + assert_eq!(focus["wired"], false); + let one = s.execute("prefs.schema", json!({"category": "trim"})).unwrap(); assert_eq!(one["categories"].as_array().unwrap().len(), 1); assert!(s.execute("prefs.schema", json!({"category": "nope"})).is_err()); From bc5965e42b52d6886c32aea35b96ebd894310099 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 20:22:46 +0200 Subject: [PATCH 14/33] #710: --version works after global options Problem: `filmcraft-cli --demo --version` (or `--compact -V`, `--data-dir d --version`) failed with "missing subcommand" and exit 2. The version check only looked at the very first argument, so a preceding global option left `--version` to the option parser and no subcommand behind. Fix: parse the arguments first and treat `--version` anywhere, or `-V` in the subcommand position, as a version query (`Args::wants_version`), still before the hardware decoders are registered. `--help` keeps priority. Co-Authored-By: Claude Opus 5.5 --- apps/filmcraft-cli/src/args.rs | 16 ++++++++++++++++ apps/filmcraft-cli/src/main.rs | 13 +++++++------ apps/filmcraft-cli/tests/cli.rs | 10 ++++++++++ 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/apps/filmcraft-cli/src/args.rs b/apps/filmcraft-cli/src/args.rs index bac68febb..af531c50c 100644 --- a/apps/filmcraft-cli/src/args.rs +++ b/apps/filmcraft-cli/src/args.rs @@ -60,6 +60,12 @@ impl Args { self.opts.iter().any(|(n, _)| n == k) } + /// A version query: `--version` anywhere, or `-V` in the subcommand position, so global + /// options may come first (`--demo --version`, `--compact -V`). + pub fn wants_version(&self) -> bool { + self.flag("--version") || self.pos(0) == Some("-V") + } + /// Command params from positionals `from..`: one JSON object, or `key=value` pairs /// (dotted keys nest: `color.r=1`). pub fn params_from(&self, from: usize) -> Result { @@ -135,6 +141,16 @@ mod tests { assert_eq!(a.params_from(2).unwrap(), json!({"a": {"b": [1]}})); } + #[test] + fn version_after_global_options() { + for s in ["--version", "-V", "--demo --version", "--compact -V", "--data-dir d --version", "export --version"] { + assert!(args(s).wants_version(), "{s}"); + } + for s in ["version", "--data-dir -V", "exec x -V", "-- --version"] { + assert!(!args(s).wants_version(), "{s}"); + } + } + #[test] fn double_dash_ends_options() { let a = args("import -- --weird-name.mov a.mov"); diff --git a/apps/filmcraft-cli/src/main.rs b/apps/filmcraft-cli/src/main.rs index b051d6cbf..2d54c4923 100644 --- a/apps/filmcraft-cli/src/main.rs +++ b/apps/filmcraft-cli/src/main.rs @@ -252,12 +252,6 @@ async fn main() { } async fn cli() { - if matches!(std::env::args().nth(1).as_deref(), Some("--version" | "-V")) { - outln!("filmcraft-cli {}", env!("CARGO_PKG_VERSION")); - return; - } - register_hardware_decoders(); - register_gpu_frame_renderer(); let a = Args::parse(std::env::args().skip(1)); // `--help` anywhere (`filmcraft-cli --help`, `filmcraft-cli export --help`) prints the reference: // the parser takes it as a flag option, so it never reaches the subcommand match. @@ -265,6 +259,13 @@ async fn cli() { out!("{HELP}"); return; } + // `--version` anywhere and `-V` as the subcommand, also after global options (`--demo --version`, #710). + if a.wants_version() { + outln!("filmcraft-cli {}", env!("CARGO_PKG_VERSION")); + return; + } + register_hardware_decoders(); + register_gpu_frame_renderer(); let Some(cmd) = a.pos(0) else { usage("missing subcommand") }; match cmd { "help" | "-h" => out!("{HELP}"), diff --git a/apps/filmcraft-cli/tests/cli.rs b/apps/filmcraft-cli/tests/cli.rs index 2736806e5..8552cc640 100644 --- a/apps/filmcraft-cli/tests/cli.rs +++ b/apps/filmcraft-cli/tests/cli.rs @@ -24,6 +24,16 @@ fn help_and_usage_errors() { assert_eq!(cli(&["exec", "file.newBin", "notkv"]).status.code(), Some(2)); } +#[test] +fn version_after_global_options() { + let want = format!("filmcraft-cli {}\n", env!("CARGO_PKG_VERSION")); + for args in [&["--version"][..], &["-V"], &["version"], &["--demo", "--version"], &["--compact", "-V"], &["--demo", "version"]] { + let o = cli(args); + assert!(o.status.success(), "{args:?}: {}", String::from_utf8_lossy(&o.stderr)); + assert_eq!(String::from_utf8_lossy(&o.stdout), want, "{args:?}"); + } +} + #[test] fn long_help_matches_help_command() { let reference = cli(&["help"]); From ce6afffc6f0e123107cffcd13aef6c39e080f0bd Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 20:39:48 +0200 Subject: [PATCH 15/33] #712: keyboard Slide keeps linked picture and sound aligned at a neighbour limit Problem: timeline.slideLeft/Right(5) slid every expanded selection member by the raw requested delta, so when a neighbour limited only one linked partner (e.g. a short sound-only follower), picture and sound ended up at different starts. timeline.slide already clamped to a common delta. Fix: add edit::slide_items, which clamps the delta by the most restrictive item and slides all items by it, and use it from both the keyboard Slide commands and timeline.slide. Co-Authored-By: Claude Opus 5.5 --- crates/edit/src/lib.rs | 21 +++++++++++++++++++++ crates/engine/src/commands.rs | 16 ++-------------- crates/engine/src/keyboard.rs | 8 ++------ crates/engine/src/keyboard_tests.rs | 26 ++++++++++++++++++++++++++ 4 files changed, 51 insertions(+), 20 deletions(-) diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index 2f5a8382b..554e9e5c7 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -876,6 +876,27 @@ pub fn slide(seq: &mut Sequence, clip: ClipId, delta: Tick, ctx: &mut EditCtx) - Ok(d) } +/// Slide several items (a linked picture/sound pair, a selection) by one common delta: the most +/// restrictive item's neighbour limit clamps them all, so their relative timing is kept. +/// Returns the delta applied. +pub fn slide_items(seq: &mut Sequence, clips: &[ClipId], delta: Tick, ctx: &mut EditCtx) -> Result { + let mut d = delta; + for c in clips { + let x = slide(&mut seq.clone(), *c, d, ctx)?; + if x.0.signum() != d.0.signum() { + d = Tick::ZERO; + } else if x.abs() < d.abs() { + d = x; + } + } + if d != Tick::ZERO { + for c in clips { + slide(seq, *c, d, ctx)?; + } + } + Ok(d) +} + /// Rate stretch: change an item's duration by dragging an edge, adjusting speed to keep the same media. pub fn rate_stretch(seq: &mut Sequence, clip: ClipId, edge: Edge, delta: Tick, ctx: &mut EditCtx) -> Result { let (tid, it) = seq.find_item(clip).ok_or(EditError::NoItem(clip))?; diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 0b3a9f8a6..47cc2f181 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -2349,20 +2349,8 @@ fn build() -> Vec { .or_else(|| p.get("deltaFrames").and_then(Value::as_i64).map(|f| s.sequence_rate().tick_of(f))) .unwrap_or_default(); let clips = with_links(s, &[c]); - s.edit_sequence("Slide", |q, ctx, _| { - // clamp across all linked partners, then slide them by the common delta - let mut dd = d; - for c in &clips { - let x = edit::slide(&mut q.clone(), *c, dd, ctx)?; - if x.abs() < dd.abs() { - dd = x; - } - } - for c in &clips { - edit::slide(q, *c, dd, ctx)?; - } - Ok(()) - })?; + // clamp across all linked partners, then slide them by the common delta + s.edit_sequence("Slide", |q, ctx, _| Ok(edit::slide_items(q, &clips, d, ctx)?))?; Ok(Value::Null) }), cmd!("timeline.rateStretch", "Rate Stretch", [], None, r#"{"clip":id,"edge":"in|out","delta":ticks}"#, has_seq, |s, p| { diff --git a/crates/engine/src/keyboard.rs b/crates/engine/src/keyboard.rs index d8487251c..55563816f 100644 --- a/crates/engine/src/keyboard.rs +++ b/crates/engine/src/keyboard.rs @@ -265,12 +265,8 @@ fn slip_selection(s: &mut Session, frames: i64) -> Result { fn slide_selection(s: &mut Session, frames: i64) -> Result { let d = s.sequence_rate().tick_of(frames); let clips = with_links(s, &s.state.selection.clone()); - s.edit_sequence("Slide", |q, ctx, _| { - for c in &clips { - edit::slide(q, *c, d, ctx)?; - } - Ok(()) - })?; + // one common delta, clamped by the most restrictive member, keeps linked partners in sync + s.edit_sequence("Slide", |q, ctx, _| Ok(edit::slide_items(q, &clips, d, ctx)?))?; Ok(Value::Null) } diff --git a/crates/engine/src/keyboard_tests.rs b/crates/engine/src/keyboard_tests.rs index b23a9809d..60e9b7b43 100644 --- a/crates/engine/src/keyboard_tests.rs +++ b/crates/engine/src/keyboard_tests.rs @@ -376,3 +376,29 @@ fn reveal_nested_sequence_opens_it_at_the_matching_frame() { assert_ne!(s.state.active_sequence, Some(outer)); assert_eq!(s.playhead(), nest_clip.source_in + rate.tick_of(7), "{r}"); } + +#[test] +fn keyboard_slide_keeps_linked_partners_aligned_at_a_neighbour_limit() { + // #712: a three-frame sound-only follower limits only the sound partner + let mut s = Session::default(); + s.execute("file.newProject", json!({"name": "Slide"})).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()); + let r = s.execute("timeline.place", json!({"item": item, "track": "A1", "time": fr.0 * 60, "sourceIn": 0, "duration": fr.0 * 3})).unwrap(); + let f = r["clips"][0].as_u64().unwrap(); + s.execute("sequence.linkedSelection", json!({"on": true})).unwrap(); + s.execute("timeline.select", json!({"clips": [v]})).unwrap(); + s.execute("timeline.slideRight5", json!({})).unwrap(); + assert_eq!(clip(&s, v).start, Tick(fr.0 * 2), "the pair moves only as far as the sound may"); + assert_eq!(clip(&s, a).start, clip(&s, v).start, "picture and sound stay aligned"); + let fl = clip(&s, f); + assert_eq!((fl.start, fl.duration, fl.source_in), (Tick(fr.0 * 62), fr, Tick(fr.0 * 2))); + // nothing more to give: a further slide right moves neither partner + s.execute("timeline.slideRight", json!({})).unwrap(); + assert_eq!((clip(&s, v).start, clip(&s, a).start), (Tick(fr.0 * 2), Tick(fr.0 * 2))); +} From 1edd8d6e334ebe7dbf7913df61cedc595fc24661 Mon Sep 17 00:00:00 2001 From: ReReddo <249398616+ReReddo@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:38:40 +0300 Subject: [PATCH 16/33] appearance: the Highlight Color drives the playhead, timecodes and focus (#707) Settings > Appearance > Highlight Color replaced only the accent (selections, primary buttons). The playhead, the timecodes, the focused panel's outline, drag values and the track-targeting buttons stayed blue, so a pink highlight left an interface that was mostly blue. A highlight of one's own now colours those too: each keeps the highlight's hue and is as much more or less saturated and bright as the theme's own blue is than the theme's highlight (the playhead and timecodes a little lighter, the targeting buttons a little deeper). With the default highlight nothing is derived, so every theme looks exactly as before. The track-targeting fill, written into the Timeline code, is a theme token now (track_target). Tests: theme (the default highlight leaves the five colours as designed in every theme; a pink one gives them its hue, lighter and darker as the default blues are). --- crates/ui-egui/src/panels/timeline.rs | 4 +-- crates/ui-egui/src/theme.rs | 50 +++++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index d59978393..81e324f60 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -954,12 +954,12 @@ fn draw_transition(p: &egui::Painter, r: Rect, trn: &filmcraft_project::Transiti } } -/// A Premiere track-header button (patch / target): blue fill when on, full track height. +/// A Premiere track-header button (patch / target): highlight-blue fill when on, full track height. fn patch_button(ui: &mut egui::Ui, r: Rect, clip: Rect, label: &str, on: bool, show_off: bool, id: egui::Id, t: &Tokens) -> egui::Response { let resp = ui.interact(r.intersect(clip), id, Sense::click()); let p = ui.painter().with_clip_rect(clip); if on { - p.rect_filled(r, 2.0, Color32::from_rgb(0x26, 0x5b, 0xc1)); + p.rect_filled(r, 2.0, t.track_target); p.text(r.center(), Align2::CENTER_CENTER, label, Tokens::semibold(10.0), Color32::from_rgb(0xeb, 0xeb, 0xeb)); } else if show_off { if resp.hovered() { diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index b7560f321..a86834950 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -133,6 +133,8 @@ pub struct Tokens { pub tl_ruler_tick: Color32, pub tl_ruler_text: Color32, pub playhead: Color32, + /// Fill of a track header's source-patch / track-targeting button while it is on. + pub track_target: Color32, pub in_out_shade: Color32, pub clip_selected_border: Color32, pub render_red: Color32, @@ -225,6 +227,7 @@ impl Tokens { tl_ruler_tick: Color32::from_rgb(0x8d, 0x8d, 0x8d), tl_ruler_text: Color32::from_rgb(0xb0, 0xb0, 0xb0), playhead: Color32::from_rgb(0x58, 0x95, 0xec), + track_target: Color32::from_rgb(0x26, 0x5b, 0xc1), in_out_shade: Color32::from_rgb(0x3f, 0x3f, 0x3f), clip_selected_border: Color32::from_rgb(0xeb, 0xeb, 0xeb), render_red: Color32::from_rgb(0xe3, 0x48, 0x50), @@ -330,11 +333,28 @@ impl Tokens { } } - /// Settings ▸ Appearance on top of a theme: the highlight colour (selections, focus, primary - /// buttons) and accessible colour contrast (brighter secondary text and borders). + /// Settings ▸ Appearance on top of a theme: the highlight colour (selections, primary buttons, + /// the focus outline, the playhead, timecodes, drag values and track targeting) and accessible + /// colour contrast (brighter secondary text and borders). pub fn with_appearance(mut self, highlight: Option<[u8; 3]>, accessible_contrast: bool) -> Self { if let Some([r, g, b]) = highlight { let c = Color32::from_rgb(r, g, b); + // A highlight of the user's own also colours the theme's other blues: the same hue, as + // much more or less saturated and bright as each of them is than the theme's highlight. + // The theme's own highlight leaves them exactly as designed. + if c != self.accent { + let tint = |s: f32, v: f32| { + let mut h = egui::ecolor::HsvaGamma::from(c); + h.s = (h.s * s).clamp(0.0, 1.0); + h.v = (h.v * v).clamp(0.0, 1.0); + Color32::from(h) + }; + self.focus = tint(0.92, 1.06); + self.playhead = self.focus; + self.timecode = tint(0.98, 1.09); + self.hot_text = self.timecode; + self.track_target = tint(1.0, 0.865); + } let lift = |v: u8| v.saturating_add(16); self.accent = c; self.accent_hover = Color32::from_rgb(lift(r), lift(g), lift(b)); @@ -550,6 +570,32 @@ mod tests { } } + /// The colours the highlight drives besides the accent (#707). + fn highlight_blues(t: &Tokens) -> [Color32; 5] { + [t.focus, t.playhead, t.timecode, t.hot_text, t.track_target] + } + + #[test] + fn the_default_highlight_leaves_the_theme_blues_as_designed() { + let default = filmcraft_engine::settings::parse_hex(filmcraft_engine::settings::DEFAULT_HIGHLIGHT); + for k in [ThemeKind::Dark, ThemeKind::Medium, ThemeKind::Light] { + assert_eq!(highlight_blues(&Tokens::for_kind(k).with_appearance(default, false)), highlight_blues(&Tokens::for_kind(k)), "{k:?}"); + } + } + + #[test] + fn a_highlight_of_ones_own_colours_the_playhead_timecodes_focus_and_targeting() { + let pink = Color32::from_rgb(0xe0, 0x45, 0x7b); + let t = Tokens::for_kind(ThemeKind::Dark).with_appearance(Some([0xe0, 0x45, 0x7b]), false); + let hue = |c: Color32| egui::ecolor::HsvaGamma::from(c).h; + for (c, was) in highlight_blues(&t).into_iter().zip(highlight_blues(&Tokens::for_kind(ThemeKind::Dark))) { + assert_ne!(c, was); + assert!((hue(c) - hue(pink)).abs() < 0.01, "{c:?} has the highlight's hue"); + } + let v = |c: Color32| egui::ecolor::HsvaGamma::from(c).v; + assert!(v(t.playhead) > v(pink) && v(t.timecode) > v(pink) && v(t.track_target) < v(pink), "lighter and darker as the default blues are"); + } + /// 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] From c13f2aeddb7beaf72642de20a06abadf6748ca86 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 20:41:12 +0200 Subject: [PATCH 17/33] #695: Timeline markers can be picked and cleared Problem: Markers > Clear Selected Marker removes only a marker exactly at the playhead, but a click on a marker on the Timeline ruler left the playhead on the frame under the pointer (with snapping off), and right-clicking a marker opened the clip menu, so a marker placed on the Timeline could not be removed from there. Fix: a press on a marker's pentagon puts the playhead exactly on it, and right-clicking a marker opens a marker menu (Clear Selected Marker, Clear Markers; automation ids timeline.markerMenu.). The pentagon's rect is shared by drawing and hit-testing (timeline_hit::marker_rect). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/timeline.rs | 39 +++++++++++++++++++++-- crates/ui-egui/src/panels/timeline_hit.rs | 35 +++++++++++++++++++- crates/ui-egui/tests/sequence_ui.rs | 26 +++++++++++++++ 3 files changed, 97 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index d59978393..7fc8f8b5f 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -22,7 +22,7 @@ use crate::icons::{self, Icon}; use crate::state::{TimelineView, Tool}; use crate::theme::Tokens; -use super::timeline_hit::{EdgeKind, Grab, edge_geometry, grab_at}; +use super::timeline_hit::{EdgeKind, Grab, edge_geometry, grab_at, marker_at, marker_rect}; pub use super::timeline_hit::{Hit, ROLL_PX, hit, selection_trim_kind}; const TOP_H: f32 = 58.0; // timecode + toolbar (left) / ruler (right) @@ -1304,7 +1304,7 @@ fn draw_top(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq: &Sequenc clip.rect_filled(Rect::from_min_max(pos2(x, marker_y), pos2(layout.x_of(m.start + m.duration), marker_y + 8.0)), 0.0, c.gamma_multiply(0.6)); } clip.add(egui::Shape::convex_polygon(shape, c, Stroke::NONE)); - let mr = Rect::from_center_size(pos2(x, marker_y + 6.0), vec2(10.0, 13.0)); + let mr = marker_rect(layout, m.start); app.auto.add(&format!("timeline.marker.{}", m.id.0), mr, &m.name); if ui.rect_contains_pointer(mr) && !m.name.is_empty() { egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::new(("mk", m.id.0)), egui::PopupAnchor::Pointer).show(|ui| { @@ -1578,6 +1578,22 @@ fn shift_track(seq: &Sequence, tid: TrackId, delta: i32) -> Option { /// The clip context menu: groups (separated by rules) of (label, command id). Entries marked `…` /// open their dialog through `menus::invoke`, like the same item in the Clip menu. +/// The menu of a right-clicked Timeline marker (automation ids `timeline.markerMenu.`); +/// the marker is under the playhead, where Clear Selected Marker looks for it (#695). +fn marker_menu(app: &mut FilmcraftApp, ctx: &egui::Context, ui: &mut egui::Ui) { + for (label, cmd) in [("Clear Selected Marker", "markers.clearCurrent"), ("Clear Markers", "markers.clearAll")] { + let label = crate::i18n::t(label); + let r = ui.button(label); + app.auto.add(&format!("timeline.markerMenu.{cmd}"), r.rect, label); + if r.clicked() { + if let Err(e) = crate::menus::invoke(app, ctx, cmd, json!({})) { + app.ui.status = e; + } + ui.close(); + } + } +} + pub(crate) const CLIP_MENU: &[&[(&str, &str)]] = &[ &[ ("Cut", "edit.cut"), @@ -2015,6 +2031,10 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & if mods.shift || app.session.prefs.timeline.snap_playhead { tt = snap_playhead(app, seq, layout, tt); } + // a press on a marker selects it: the playhead goes exactly onto it (#695) + if let Some(m) = marker_at(seq, layout, p) { + tt = m.max(Tick::ZERO); + } app.session.set_playhead(tt); } if resp.drag_started() { @@ -2199,6 +2219,17 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & let edit_point_menu_id = egui::Id::new("timeline.editPointMenu.target"); // the clip right-clicked, remembered while its menu is open: Unlink leaves only it selected let menu_clip_id = egui::Id::new("timeline.clipMenu.clip"); + // on a marker of the ruler, the marker menu instead (right-clicking a marker selects it first) + let marker_menu_id = egui::Id::new("timeline.markerMenu.open"); + if resp.secondary_clicked() + && let Some(p) = resp.interact_pointer_pos() + { + let marker = marker_at(seq, layout, p); + if let Some(m) = marker { + app.session.set_playhead(m.max(Tick::ZERO)); + } + ctx.data_mut(|d| d.insert_temp(marker_menu_id, marker.is_some())); + } if resp.secondary_clicked() && let Some(p) = resp.interact_pointer_pos() && let Hit::Clip { clip, edge, track } = hit(seq, layout, p) @@ -2226,6 +2257,10 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & } resp.context_menu(|ui| { ui.set_min_width(220.0); + if ctx.data(|d| d.get_temp::(marker_menu_id)).unwrap_or(false) { + marker_menu(app, &ctx, ui); + return; + } if let Some(target) = ctx.data(|d| d.get_temp::>(edit_point_menu_id)).flatten() { edit_point_menu(app, &ctx, ui, seq, target); return; diff --git a/crates/ui-egui/src/panels/timeline_hit.rs b/crates/ui-egui/src/panels/timeline_hit.rs index 16dd5f712..9e311f50d 100644 --- a/crates/ui-egui/src/panels/timeline_hit.rs +++ b/crates/ui-egui/src/panels/timeline_hit.rs @@ -5,9 +5,10 @@ use std::cmp::Ordering; -use egui::{Modifiers, Pos2}; +use egui::{Modifiers, Pos2, Rect, pos2, vec2}; use filmcraft_edit::Edge; use filmcraft_project::{ClipId, Sequence, Track, TrackId, TransitionId}; +use filmcraft_time::Tick; use super::timeline::Layout; use crate::state::Tool; @@ -68,6 +69,18 @@ pub enum Hit { None, } +/// Where the Timeline ruler draws (and hit-tests) the pentagon of a marker at `start`. +pub fn marker_rect(layout: &Layout, start: Tick) -> Rect { + Rect::from_center_size(pos2(layout.x_of(start), layout.ruler.min.y + 8.0), vec2(10.0, 13.0)) +} + +/// The start of the marker whose pentagon is under `pos` on the ruler (the topmost, i.e. last +/// drawn, when several overlap). A press there puts the playhead on the marker, so Markers ▸ Clear +/// Selected Marker removes it (#695). +pub fn marker_at(seq: &Sequence, layout: &Layout, pos: Pos2) -> Option { + seq.markers.iter().rev().map(|m| m.start).find(|t| marker_rect(layout, *t).contains(pos)) +} + pub fn hit(seq: &Sequence, layout: &Layout, pos: Pos2) -> Hit { if layout.ruler.contains(pos) { return Hit::Ruler; @@ -404,6 +417,26 @@ mod tests { } } + #[test] + fn a_press_on_a_marker_lands_on_its_start() { + let (mut seq, l, _) = fixture(clips()); + let off = Tick::from_seconds_f64(3.017); + seq.markers.push(filmcraft_project::Marker { + id: filmcraft_project::MarkerId(9), + start: off, + duration: Tick::ZERO, + name: String::new(), + comment: String::new(), + kind: filmcraft_project::MarkerKind::Comment, + color: Label::Green, + }); + // 3 px beside the pentagon's tip, where the nearest frame is not the marker's time + assert_eq!(marker_at(&seq, &l, pos2(304.7, 10.0)), Some(off)); + assert_eq!(marker_at(&seq, &l, pos2(310.0, 10.0)), None); + assert_eq!(marker_at(&seq, &l, pos2(301.7, 30.0)), None); + assert_eq!(marker_at(&seq, &l, pos2(f32::NAN, 10.0)), None); + } + use egui::Modifiers; use crate::state::Tool; diff --git a/crates/ui-egui/tests/sequence_ui.rs b/crates/ui-egui/tests/sequence_ui.rs index 0a16547f8..460b7929a 100644 --- a/crates/ui-egui/tests/sequence_ui.rs +++ b/crates/ui-egui/tests/sequence_ui.rs @@ -321,6 +321,32 @@ fn markers_panel_colour_filter() { assert_eq!(d.ids("markers.row.").len(), 2); } +/// #695: a marker on the Timeline could not be cleared: with snapping off a click on it left the +/// playhead on the frame under the pointer, not on the marker Clear Selected Marker looks for, and +/// right-clicking it opened the clip menu. +#[test] +fn a_clicked_timeline_marker_can_be_cleared() { + let mut d = Driver::demo(); + d.exec("markers.clearAll", json!({})); + let first = d.exec("markers.add", json!({"frame": 30}))["marker"].as_u64().unwrap(); + let second = d.exec("markers.add", json!({"frame": 90}))["marker"].as_u64().unwrap(); + d.exec("sequence.snap", json!({"on": false})); + d.exec("playhead.set", json!({"frame": 0})); + // zoomed out so that a few px beside a marker is another frame + let v = &mut d.app().ui.timeline; + (v.pps, v.target_pps, v.scroll, v.target_scroll) = (60.0, 60.0, 0.0, 0.0); + d.frames(4); + let markers = |d: &mut Driver| d.app().session.active_sequence().unwrap().markers.iter().map(|m| m.id.0).collect::>(); + d.ok("ui.click", json!({"id": format!("timeline.marker.{first}"), "fx": 0.95})); + d.frames(2); + d.exec("markers.clearCurrent", json!({})); + assert_eq!(markers(&mut d), vec![second], "the clicked marker is cleared"); + d.ok("ui.click", json!({"id": format!("timeline.marker.{second}"), "fx": 0.95, "button": "right"})); + d.frames(3); + d.click("timeline.markerMenu.markers.clearCurrent"); + assert!(markers(&mut d).is_empty(), "cleared from its context menu"); +} + #[test] fn shift_semicolon_goes_to_the_next_gap() { let mut d = Driver::demo(); From 57cacfefa8b82dce966dc3274fb77bba173c5431 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 20:48:37 +0200 Subject: [PATCH 18/33] #715: a Matroska remux that keeps its timestamps starts at its first picture Problem: MkvSource used the raw Segment timestamps as media time and the absolute Info/Duration endpoint as the clip length. A remux that keeps its source's first PTS (ffmpeg -copyts, 1.48 s in the report) repeated its opening picture until then and made a 3.48 s clip out of 2 s of media. Fix: after opening, every track moves back by the earliest start among the playable tracks (an audio track's start before CodecDelay, never below zero), so a delay between picture and sound survives. The duration is the Segment end minus that origin, never less than the main track's samples. Zero-origin files are unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/codecs/src/mkv.rs | 129 +++++++++++++++++++++++++++++++++------ 1 file changed, 112 insertions(+), 17 deletions(-) diff --git a/crates/codecs/src/mkv.rs b/crates/codecs/src/mkv.rs index cef204d9d..f8902394b 100644 --- a/crates/codecs/src/mkv.rs +++ b/crates/codecs/src/mkv.rs @@ -102,10 +102,9 @@ fn audio_starts(file: &MkvFile, bytes: &crate::Src, ti: usize, rate: i64) -> Vec fn opus_starts(file: &MkvFile, bytes: &crate::Src, ti: usize, head: &filmcraft_opus::OpusHead, rate: i64) -> Vec { let Some(t) = file.tracks.get(ti) else { return Vec::new() }; let (n, d) = tb(t); - let scale_ns = (n as i128 * 1_000_000_000 / d as i128).max(1); let delay_ns = t.codec_delay_ns as i128; - // The demuxer's rounding of CodecDelay to ticks (half away from zero), undone here. - let delay_ticks = ((delay_ns + scale_ns / 2) / scale_ns) as i64; + // The demuxer's rounding of CodecDelay to ticks, undone here. + let delay_ticks = codec_delay_ticks(t); let skip = if t.codec_delay_ns > 0 { (delay_ns * rate as i128 / 1_000_000_000) as i64 } else { head.pre_skip as i64 }; let at = |pts: i64| ((pts.saturating_add(delay_ticks) as i128 * n as i128 * rate as i128 / d as i128) as i64).saturating_sub(skip); let packets = t.samples.iter().enumerate().map(|(i, s)| crate::audio::PacketTime { @@ -116,6 +115,46 @@ fn opus_starts(file: &MkvFile, bytes: &crate::Src, ti: usize, head: &filmcraft_o crate::audio::contiguous_starts(packets, stamp_tolerance(t, rate)) } +/// `CodecDelay` in timestamp ticks, rounded as the demuxer rounds it (half away from zero) before +/// subtracting it from every timestamp of the track. +fn codec_delay_ticks(t: &filmcraft_matroska::Track) -> i64 { + let (n, d) = tb(t); + let scale_ns = (n as i128 * 1_000_000_000 / d as i128).max(1); + i64::try_from((t.codec_delay_ns as i128 + scale_ns / 2) / scale_ns).unwrap_or(i64::MAX) +} + +/// The track timestamp that media time zero stands for (#715): the earliest start among the +/// playable tracks. Block timestamps count from the start of the Segment, and a remux that keeps +/// its source's timestamps (`ffmpeg -copyts`, a cut from a recording) starts its first picture +/// seconds in; taken as media time, that repeated the opening picture until then. An audio track +/// starts where its first packet does before `CodecDelay` was taken off (the delay only hides +/// priming), and never before zero. +fn media_origin(file: &MkvFile, vtrack: Option, atracks: &[usize]) -> i64 { + let start = |i: usize| { + let t = file.tracks.get(i)?; + let first = t.samples.iter().map(|s| s.pts).min()?; + Some(if t.kind == TrackKind::Audio { first.saturating_add(codec_delay_ticks(t)).max(0) } else { first }) + }; + vtrack.into_iter().chain(atracks.iter().copied()).filter_map(start).min().unwrap_or(0).max(0) +} + +/// Move every track back by `origin` timestamp ticks, so the streams keep their relative timing +/// (a delay between picture and sound survives). +fn rebase(file: &mut MkvFile, origin: i64) { + if origin <= 0 { + return; + } + for s in file.tracks.iter_mut().flat_map(|t| t.samples.iter_mut()) { + s.pts = s.pts.saturating_sub(origin); + } +} + +/// End of the last sample of track `i`, in its timebase. +fn track_end(file: &MkvFile, i: usize) -> Option<(i64, &filmcraft_matroska::Track)> { + let t = file.tracks.get(i)?; + Some((t.samples.iter().map(|s| s.pts.saturating_add(i64::try_from(s.duration).unwrap_or(i64::MAX))).max().unwrap_or(0), t)) +} + /// Two timestamp ticks in sample frames at `rate`, plus one: how far a packet's Matroska timestamp /// can be from its exact start. fn stamp_tolerance(t: &filmcraft_matroska::Track, rate: i64) -> i64 { @@ -144,7 +183,7 @@ fn tb(t: &filmcraft_matroska::Track) -> (i64, i64) { fn to_tick(t: &filmcraft_matroska::Track, pts: i64) -> Tick { let (n, d) = tb(t); - Tick::from_rational(pts * n, 1, d) + Tick::from_rational(pts, n, d) } /// A time as the track timestamp to look a frame up by: rounded to the nearest tick, as muxers @@ -273,7 +312,7 @@ impl MkvSource { /// Open from a random-access reader: only the index is read now, samples on demand. pub fn open_reader(name: &str, reader: filmcraft_media::SharedReader) -> crate::Result { let bytes = crate::Src(reader); - let file = filmcraft_matroska::open(&bytes).map_err(|e| CodecError::Container(e.to_string()))?; + let mut file = filmcraft_matroska::open(&bytes).map_err(|e| CodecError::Container(e.to_string()))?; let vtrack = file.tracks.iter().position(|t| t.kind == TrackKind::Video && !t.samples.is_empty()); // every audio track is a stream (the track count comes from the file: capped) let atracks: Vec = file @@ -287,6 +326,24 @@ impl MkvSource { if vtrack.is_none() && atracks.is_empty() { return Err(CodecError::Unsupported("no playable tracks".into())); } + let main = vtrack.or(atracks.first().copied()); + // The Segment's end (`Info/Duration` or the last sample), in Segment time. + let segment_end = match file.duration_ns() { + Some(ns) => Tick::from_rational(i64::try_from(ns).unwrap_or(i64::MAX), 1, 1_000_000_000), + None => main.and_then(|i| track_end(&file, i)).map(|(end, t)| to_tick(t, end)).unwrap_or_default(), + }; + // Media time zero is the first picture (or sound): the streams start at their common origin. + let origin = media_origin(&file, vtrack, &atracks); + rebase(&mut file, origin); + // `Info/Duration` is the Segment's end: the media lasts from the origin to there (never less + // than its main track's samples). + let duration = match main.and_then(|i| track_end(&file, i)) { + Some((end, t)) if origin > 0 => { + let (n, d) = tb(t); + (segment_end - Tick::from_rational(origin, n, d)).max(to_tick(t, end)) + } + _ => segment_end, + }; let mut explicit_color = None; let mut ventry = None; // display rotation from the track's Projection (portrait phone video is stored landscape) @@ -310,7 +367,7 @@ impl MkvSource { if explicit { explicit_color = Some(color); } - let secs = file.duration_ns().unwrap_or(0) as f64 / 1e9; + let secs = duration.seconds(); 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()) }; VideoStreamInfo { @@ -340,17 +397,6 @@ impl MkvSource { AudioStreamInfo { sample_rate: rate, channels, codec: codec_label(&t.codec), bits_per_sample: a.bit_depth.map(|b| b as u32) } }) .collect(); - let duration = match file.duration_ns() { - Some(ns) => Tick::from_rational(ns as i64, 1, 1_000_000_000), - None => vtrack - .or(atracks.first().copied()) - .map(|i| { - let t = &file.tracks[i]; - let end = t.samples.iter().map(|s| s.pts + s.duration as i64).max().unwrap_or(0); - to_tick(t, end) - }) - .unwrap_or_default(), - }; let info = MediaInfo { name: name.to_string(), kind: if video.is_some() { MediaKind::Movie } else { MediaKind::AudioOnly }, @@ -601,3 +647,52 @@ mod multi_audio_tests { assert!(source.audio_stream(2, 0, 50, 48_000).is_err()); } } + +#[cfg(test)] +mod origin_tests { + use filmcraft_frame::PixelData; + use filmcraft_matroska::{MkvWriter, MuxOptions, TrackKind, TrackSpec}; + use filmcraft_media::{FrameRequest, MediaSource}; + use filmcraft_time::Tick; + + /// A flat grey Motion-JPEG picture: its level says which frame it is. + fn picture(k: u8) -> Vec { + let img = image::GrayImage::from_pixel(16, 16, image::Luma([20 + 20 * k])); + let mut out = Vec::new(); + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 95).encode_image(&img).unwrap(); + out + } + + /// #715: a remux that keeps its source's timestamps (first picture at 1.48 s, sound 0.2 s after + /// it) starts at its first picture, lasts as long as its pictures, and keeps the sound's delay. + /// It repeated picture 0 until 1.48 s, and the clip ran to the Segment's absolute end. + #[test] + fn timestamp_preserving_remux_starts_at_the_first_picture() { + let mut v = TrackSpec::new(TrackKind::Video, "V_MJPEG"); + v.video_size = Some((16, 16)); + v.default_duration_ns = Some(40_000_000); + let mut a = TrackSpec::new(TrackKind::Audio, "A_PCM/INT/LIT"); + a.audio = Some((48_000.0, 1, Some(16))); + let mut w = MkvWriter::new(std::io::Cursor::new(Vec::new()), vec![v, a], MuxOptions::default()).unwrap(); + for k in 0..10u8 { + let pts = 1_480_000_000 + i64::from(k) * 40_000_000; + w.write_frame(0, pts, true, &picture(k), None).unwrap(); + if k == 5 { + let pcm: Vec = (0..960).flat_map(|_| 16_384i16.to_le_bytes()).collect(); + w.write_frame(1, 1_680_000_000, true, &pcm, Some(20_000_000)).unwrap(); + } + } + let src = super::MkvSource::open("shifted.mkv", w.finish().unwrap().into_inner().into()).unwrap(); + assert_eq!(src.info().duration, Tick::from_rational(10, 1, 25), "ten pictures at 25 fps"); + let level = |k: i64| { + let f = src.video_frame(FrameRequest::full(Tick::from_rational(k, 1, 25))).unwrap(); + let PixelData::Rgba8(px) = &f.data else { panic!("MJPEG decodes to RGBA8") }; + px[0] + }; + let seen: Vec = (0..10).map(level).collect(); + assert!(seen.windows(2).all(|p| p[0] < p[1]), "one picture per frame, in order: {seen:?}"); + let sound = src.audio(0, 48_000 * 2 / 5, 48_000).unwrap(); + assert!(sound.channels[0][9_590].abs() < 0.001, "silent before the delay"); + assert!((sound.channels[0][9_610] - 0.5).abs() < 0.001, "sound 0.2 s after the first picture"); + } +} From dfbcadb88c9e25c041c6d80cea2f278f407202c0 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 20:53:19 +0200 Subject: [PATCH 19/33] #719: positive Roundness rounds the Lumetri vignette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: the Lumetri vignette scaled its horizontal coordinate by `1 + vround * 0.0` and applied aspect correction only for negative Roundness, so 0 … +100 rendered identically (CPU path, the GPU op and the GPU shader parameters alike). Negative values moved towards a circle, the direction positive values should go. Fix: one helper, `gpufx::lumetri_vignette_aspect`, scales the horizontal coordinate by aspect^roundness: 0 keeps the ellipse that follows the frame, +100 is a circle, negative values widen the oval. Non-finite or out-of-range values fall back safely. The CPU path, the CPU FxOp and the GPU parameter packing all use it, so they stay in parity. Co-Authored-By: Claude Opus 5.5 --- crates/gpu/src/fx.rs | 2 +- crates/render/src/effects.rs | 55 +++++++++++++++++++++++++++++++++++- crates/render/src/gpufx.rs | 13 ++++++++- 3 files changed, 67 insertions(+), 3 deletions(-) diff --git a/crates/gpu/src/fx.rs b/crates/gpu/src/fx.rs index 0247105fa..959a0fa0d 100644 --- a/crates/gpu/src/fx.rs +++ b/crates/gpu/src/fx.rs @@ -213,7 +213,7 @@ fn steps(op: &FxOp) -> Vec { let w0 = 1.0 - wh * 0.15; let sh_k = sh * 0.35; let hl_k = hl * 0.35; - let vround_aspect = if *vround < 0.0 { aspect.powf(-vround) } else { 1.0 }; + let vround_aspect = filmcraft_render::gpufx::lumetri_vignette_aspect(*aspect, *vround); let st_k = [(st[0] - 0.5) * 0.3, (st[1] - 0.5) * 0.3, (st[2] - 0.5) * 0.3]; let ht_k = [(ht[0] - 0.5) * 0.3, (ht[1] - 0.5) * 0.3, (ht[2] - 0.5) * 0.3]; diff --git a/crates/render/src/effects.rs b/crates/render/src/effects.rs index a20e94756..f4eeb3ba0 100644 --- a/crates/render/src/effects.rs +++ b/crates/render/src/effects.rs @@ -679,7 +679,7 @@ fn lumetri(img: &mut Image, e: &EffectInstance, cx: &FxCtx) { v = v.map(|q| l3 + (q - l3) * s); // vignette if va.abs() > 1e-4 { - let nx = (x as f32 / w - 0.5) * 2.0 * (1.0 + vround * 0.0) * if vround < 0.0 { aspect.powf(-vround) } else { 1.0 }; + let nx = (x as f32 / w - 0.5) * 2.0 * crate::gpufx::lumetri_vignette_aspect(aspect, vround); let ny = (y as f32 / h - 0.5) * 2.0; let d = (nx * nx + ny * ny).sqrt() / std::f32::consts::SQRT_2; let edge = ((d - vmid * 0.9) / (vfeather.max(0.01) * 0.9)).clamp(0.0, 1.0); @@ -1515,4 +1515,57 @@ mod lumetri_cpu_parity_tests { eprintln!("{name}: max sample diff vs fn lumetri: {max_diff:.2e}"); } } + + /// #719: positive Roundness rounds the Lumetri vignette (it used to be ignored), +100 + /// is a circle and negative values widen the oval, on the CPU path and the GPU op alike. + #[test] + fn lumetri_vignette_roundness_shapes_the_vignette() { + let (w, h) = (80usize, 40usize); + let cx = FxCtx { + t: Tick::ZERO, + px_scale: 1.0, + seconds: 0.0, + timecode: "", + clip_name: "", + project: None, + env: None, + working: filmcraft_color::WorkingSpace::Rec709, + }; + let render = |roundness: f64| { + let e = effect(&[ + ("basic_on", ParamValue::Bool(false)), + ("creative_on", ParamValue::Bool(false)), + ("vignette_amount", fl(-3.0)), + ("vignette_roundness", fl(roundness)), + ]); + let grey = Image { w, h, px: [0.5, 0.5, 0.5, 1.0].repeat(w * h) }; + let mut cpu = grey.clone(); + lumetri(&mut cpu, &e, &cx); + let mut op_img = grey; + let op = FxOp::eval(&e, &cx, w, h).expect("lumetri op"); + op.apply(&mut op_img); + for (a, b) in cpu.px.iter().zip(&op_img.px) { + assert!((a - b).abs() <= 1e-6, "roundness {roundness}: CPU and GPU op disagree"); + } + cpu + }; + let red = |img: &Image, x: usize, y: usize| img.px[(y * w + x) * 4]; + // 16 px right of and 16 px below the centre (40, 20) + let (right, below) = ((56, 20), (40, 36)); + + let ellipse = render(0.0); + assert!(red(&ellipse, right.0, right.1) > red(&ellipse, below.0, below.1) + 0.01, "0 follows the frame"); + + let circle = render(100.0); + assert_ne!(circle.px, ellipse.px, "positive roundness changes the vignette"); + let (r, b) = (red(&circle, right.0, right.1), red(&circle, below.0, below.1)); + assert!((r - b).abs() < 1e-4, "+100 is a circle: {r} vs {b}"); + assert!(r < red(&ellipse, right.0, right.1), "+100 darkens the sides more than 0"); + let half = render(50.0); + assert!(red(&half, 70, 20) < red(&ellipse, 70, 20), "+50 darkens the sides more than 0"); + assert!(red(&half, 70, 20) > red(&circle, 70, 20), "+50 darkens the sides less than +100"); + + let oval = render(-100.0); + assert!(red(&oval, 79, 20) > red(&ellipse, 79, 20), "negative values widen the oval"); + } } diff --git a/crates/render/src/gpufx.rs b/crates/render/src/gpufx.rs index 0b432ea0c..572d9627d 100644 --- a/crates/render/src/gpufx.rs +++ b/crates/render/src/gpufx.rs @@ -981,7 +981,7 @@ impl FxOp { v = v.map(|q| l3 + (q - l3) * s); // vignette if vignette_on && va.abs() > 1e-4 { - let nx = (x as f32 / w - 0.5) * 2.0 * (1.0 + vround * 0.0) * if vround < 0.0 { aspect.powf(-vround) } else { 1.0 }; + let nx = (x as f32 / w - 0.5) * 2.0 * lumetri_vignette_aspect(aspect, vround); let ny = (y as f32 / h - 0.5) * 2.0; let d = (nx * nx + ny * ny).sqrt() / std::f32::consts::SQRT_2; let edge = ((d - vmid * 0.9) / (vfeather.max(0.01) * 0.9)).clamp(0.0, 1.0); @@ -996,6 +996,17 @@ impl FxOp { } } +/// Horizontal scale of the Lumetri vignette's coordinates for a Roundness of `vround` +/// (−1 … 1) on a frame `aspect` wide. At 0 the vignette is an ellipse that follows the +/// frame; positive values move it towards a circle (1 = a true circle), negative values +/// stretch it into a wider oval. Non-finite or out-of-range input never yields NaN. +pub fn lumetri_vignette_aspect(aspect: f32, vround: f32) -> f32 { + if !aspect.is_finite() || aspect <= 0.0 || !vround.is_finite() { + return 1.0; + } + aspect.powf(vround.clamp(-1.0, 1.0)) +} + /// sRGB encoding that keeps values above 1 (super-whites) instead of clamping them. #[inline] fn linear_to_enc(v: f32) -> f32 { From ddb3890e689710dec8be710497cd9a64cf040f59 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 20:59:02 +0200 Subject: [PATCH 20/33] #456: a new bin asks for its name Problem: every New Bin entry (Cmd+B, File > New > Bin, the Project panel menu, the footer button and the context menus) ran `file.newBin` with the fixed name "New Bin", so each bin had to be renamed by hand afterwards. Fix: `file.newBin` without a `name` from the UI makes the bin in the shown bin (or the given parent), selects it and opens its inline rename field; agents that pass `name` still name it directly. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/menus.rs | 9 +++++++ crates/ui-egui/src/panels/project.rs | 30 ++++++++++++++++++++-- crates/ui-egui/src/panels/project_views.rs | 4 +-- crates/ui-egui/tests/project_panel_ui.rs | 20 +++++++++++++++ 4 files changed, 59 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 9dff12d38..ba8f1f18b 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -482,6 +482,15 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params: crate::panels::color_dialogs::open_sequence(app); return Ok(json!({"dialog": "sequenceColor"})); } + // New Bin from menus, shortcuts and the Project panel asks for the name (inline rename); + // agents pass `name` to name the bin directly + "file.newBin" if params.get("name").is_none() => { + let r = crate::panels::project::new_bin(app, ¶ms); + if let Err(e) = &r { + app.ui.status = e.clone(); + } + return r; + } // From menus/shortcuts (no params) these ask first; agents pass params to act directly. "file.revert" if params.as_object().is_none_or(|m| m.is_empty()) && app.session.is_dirty() => { if app.session.path.is_none() { diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 09b040694..2a18137a1 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -607,7 +607,7 @@ fn footer(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, v: &View, action "find" => actions.push(("edit.find".into(), json!({}))), "file.newBin" => { let parent = (v.bin != app.session.project.root.id).then_some(v.bin.0); - actions.push(("file.newBin".into(), json!({"name": "New Bin", "parent": parent}))); + actions.push(("file.newBin".into(), json!({"parent": parent, "panel": pre}))); } "project.delete" if app.session.state.project_selection.is_empty() => app.ui.status = tl!("Select items to clear").into(), _ => actions.push((id.into(), json!({}))), @@ -700,7 +700,7 @@ pub fn panel_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui) -> bool { ui.separator(); if item(ui, "newBin", tl!("New Bin"), true, s_bin.as_deref()) { let parent = (v.bin != app.session.project.root.id).then_some(v.bin.0); - actions.push(("file.newBin".into(), json!({"name": "New Bin", "parent": parent}))); + actions.push(("file.newBin".into(), json!({"parent": parent}))); } if item(ui, "newBinFromSelection", tl!("New Bin From Selection"), !app.session.state.project_selection.is_empty(), Some("Shift+B")) { actions.push(("file.newBinFromSelection".into(), json!({}))); @@ -939,6 +939,32 @@ fn start_rename(app: &mut FilmcraftApp, params: &Value) -> Result Ok(json!({"item": item, "bin": bin})) } +/// New Bin from a menu, a shortcut or the panel's buttons (no `name`): make a "New Bin" in `parent` +/// (left out: the bin the Project panel shows; null: the project's root) and start renaming it, so +/// its name can be typed right away (#456). `panel` names the panel instance that edits the name. +/// Agents pass `name` to `file.newBin` to name the bin directly. +pub fn new_bin(app: &mut FilmcraftApp, params: &Value) -> Result { + let shown = view_of(app, shown_inst(app)); + let parent = match params.get("parent") { + None => (shown.bin != app.session.project.root.id).then_some(shown.bin.0), + Some(Value::Null) => None, + Some(p) => Some(p.as_u64().ok_or("`parent` must be a bin id")?), + }; + let r = app.session.execute("file.newBin", json!({"name": tl!("New Bin"), "parent": parent})).map_err(|e| e.to_string())?; + let bin = r.get("bin").and_then(Value::as_u64).ok_or("the new bin has no id")?; + let text = app.session.project.root.find_bin(BinId(bin)).map(|b| b.name.clone()).ok_or("no such bin")?; + // a bin made inside a collapsed bin of the list opens it, so the name field is visible + if let Some(p) = parent + && !app.ui.expanded_bins.contains(&p) + { + app.ui.expanded_bins.push(p); + } + let panel = params.get("panel").and_then(Value::as_str).map_or(shown.prefix, str::to_string); + app.ui.project_panel.selected_bin = Some(bin); + app.ui.project_panel.rename = Some(Rename { item: None, bin: Some(bin), text, panel }); + Ok(r) +} + /// Commit the inline rename (Enter / focus lost). pub fn commit_rename(app: &mut FilmcraftApp) { let Some(r) = app.ui.project_panel.rename.take() else { return }; diff --git a/crates/ui-egui/src/panels/project_views.rs b/crates/ui-egui/src/panels/project_views.rs index 7a54afaea..af7911fae 100644 --- a/crates/ui-egui/src/panels/project_views.rs +++ b/crates/ui-egui/src/panels/project_views.rs @@ -311,7 +311,7 @@ fn bin_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &View, bin: u64, actio ui.close(); } if ui.button(tl!("New Bin")).clicked() { - actions.push(("file.newBin".into(), json!({"name": "New Bin", "parent": bin}))); + actions.push(("file.newBin".into(), json!({"parent": bin, "panel": v.prefix}))); ui.close(); } } @@ -517,7 +517,7 @@ fn background_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &View, bin: Opt (tl!("Import…"), "file.import"), ] { if ui.button(label).clicked() { - let p = if cmd == "file.newBin" { json!({"name": tl!("New Bin"), "parent": bin}) } else { json!({}) }; + let p = if cmd == "file.newBin" { json!({"parent": bin, "panel": v.prefix}) } else { json!({}) }; actions.push((cmd.into(), p)); ui.close(); } diff --git a/crates/ui-egui/tests/project_panel_ui.rs b/crates/ui-egui/tests/project_panel_ui.rs index bb504f32f..1b978cf29 100644 --- a/crates/ui-egui/tests/project_panel_ui.rs +++ b/crates/ui-egui/tests/project_panel_ui.rs @@ -477,6 +477,26 @@ fn footer_buttons_are_wired() { assert_eq!(d.app().session.prefs.project_panel.view.icon_sort.column, "Name"); } +#[test] +fn new_bin_asks_for_its_name() { + // #456: New Bin opens the name field of the new bin instead of leaving it "New Bin" + let mut d = project_driver(); + d.exec("project.view.set", json!({"view": "list"})); + d.click("project.button.file.newBin"); + d.frames(2); + let bin = d.app().ui.project_panel.rename.as_ref().and_then(|r| r.bin).expect("the new bin's name is being edited"); + assert!(d.has("project.rename"), "the name field is shown"); + d.key("Cmd+A"); + d.ok("ui.type", json!({"text": "Interviews"})); + d.key("Enter"); + let name = d.app().session.project.root.find_bin(filmcraft_project::BinId(bin)).unwrap().name.clone(); + assert_eq!(name, "Interviews"); + // with a name (agents) the bin is named directly and nothing is edited + let r = d.ok("ui.menu.invoke", json!({"id": "file.newBin", "params": {"name": "B-roll"}})); + assert!(r["bin"].as_u64().is_some(), "{r}"); + assert!(d.app().ui.project_panel.rename.is_none()); +} + // ------------------------------------------------------------------------------------ Media Browser fn media_dir(tag: &str) -> std::path::PathBuf { From 5d6f690e363ad1383a2db08d5388040c0c0cf8c6 Mon Sep 17 00:00:00 2001 From: ReReddo <249398616+ReReddo@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:05:23 +0300 Subject: [PATCH 21/33] effect controls: lines between property rows (#640) Effect Controls now draws a thin line under every row, as Premiere does, across the names, the values and the keyframe lane, so a row can be followed from its name to its value and keyframes. Each line runs through the middle of the spacing between rows, so a row sits centred between its two lines. It covers every kind of row: effect headers, properties, Custom Setup, the value and velocity graphs, the mask rows, and a selected transition's settings. The colour is the theme's separator, so it follows the dark and light themes. - effect_controls::row_line draws the line; effect_controls:: fit_dropdown_to_row keeps a dropdown (Blend Mode, a mask's mode) inside its row, clear of the lines. - The keyframe lane's ruler and clip bar are now painted after the rows, on their own background, so rows scrolled up under them no longer draw over them (their lines, and keyframe diamonds, which could already do so). --- crates/ui-egui/src/panels/effect_controls.rs | 32 ++++++++++++++++--- crates/ui-egui/src/panels/masks.rs | 4 +++ .../ui-egui/src/panels/transition_controls.rs | 9 ++++++ 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index e6161e368..9098c5a3b 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -122,10 +122,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let lx = |tk: Tick| -> f32 { lane.min.x + (((tk - it.start).0 as f64 / dur) as f32).clamp(0.0, 1.0) * lane.width() }; let rate = seq.settings.frame_rate; 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, 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); let sresp = ui.interact(scrub, egui::Id::new(("ec-scrub", clip.0)), Sense::click_and_drag()); @@ -162,6 +159,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { if resp.hovered() { bui.painter().rect_filled(r, 0.0, t.hover); } + row_line(bui, r, &lane, &t); icons::paint( bui.painter(), Rect::from_center_size(pos2(r.min.x + 10.0, r.center().y), vec2(10.0, 10.0)), @@ -215,7 +213,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { continue; } if crate::panels::audio_fx_editor::has_editor(&e.effect) { - custom_setup_row(app, bui, body, clip, idx, &e.effect); + custom_setup_row(app, bui, body, &lane, 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); @@ -234,6 +232,12 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } }); let _ = scroll_out; + // the ruler and the clip's bar, painted after the rows so rows scrolled up under them (their + // lines and keyframes) stay hidden + ui.painter().rect_filled(Rect::from_min_max(lane.min, pos2(lane.max.x, bar.max.y)), 0.0, t.tl_bg); + paint_ruler(ui.painter(), ruler, &it, rate, seq.settings.drop_frame, &t); + 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); // playhead: a handle on the ruler and a line down the lane, while it is on the clip if ph >= it.start && ph <= it.end() { let px = lx(ph); @@ -249,10 +253,25 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { run(app, ui.ctx(), actions); } +/// The thin line under a row, across the names, the values and the keyframe lane, so a row can be +/// followed across the panel, as in Premiere (#640). It runs through the middle of the spacing +/// between rows, so each row sits centred between its two lines. +pub(crate) fn row_line(ui: &egui::Ui, r: Rect, lane: &Rect, t: &Tokens) { + let y = r.max.y + ui.spacing().item_spacing.y / 2.0; + ui.painter().line_segment([pos2(r.min.x, y), pos2(lane.max.x, y)], Stroke::new(1.0, t.separator)); +} + +/// Make a dropdown in a row fit inside the row, clear of its row lines. +pub(crate) fn fit_dropdown_to_row(ui: &mut egui::Ui) { + ui.spacing_mut().interact_size.y = ROW_H - 4.0; + ui.spacing_mut().button_padding.y = 1.0; +} + /// Premiere's "Custom Setup ▸ Edit…" row: opens the effect's Clip Fx Editor window. -fn custom_setup_row(app: &mut FilmcraftApp, ui: &mut egui::Ui, body: Rect, clip: ClipId, idx: usize, effect: &str) { +fn custom_setup_row(app: &mut FilmcraftApp, ui: &mut egui::Ui, body: Rect, lane: &Rect, clip: ClipId, idx: usize, effect: &str) { let t = app.tokens; let (r, _) = ui.allocate_exact_size(vec2(body.width(), ROW_H), Sense::hover()); + row_line(ui, r, lane, &t); ui.painter().text(pos2(r.min.x + 42.0, r.center().y), Align2::LEFT_CENTER, tl!("Custom Setup"), Tokens::ui(12.0), t.text_dim); let br = Rect::from_min_size(pos2(r.min.x + 160.0, r.min.y + 2.0), vec2(60.0, ROW_H - 4.0)); let resp = ui.interact(br, egui::Id::new(("fx-custom-setup", clip.0, idx)), Sense::click()); @@ -436,6 +455,7 @@ pub(crate) fn param_row( v }; let (r, _) = ui.allocate_exact_size(vec2(body.width(), ROW_H), Sense::hover()); + row_line(ui, r, lane, &t); let mut x = r.min.x + 26.0; // twirl-down for the value/velocity graphs (animated scalar and point params) if param.is_animated() && graphable(¶m.value) { @@ -502,6 +522,7 @@ pub(crate) fn param_row( } (ParamKind::Choice(opts), ParamValue::Choice(c)) => { let mut sel = *c as usize; + fit_dropdown_to_row(&mut vui); egui::ComboBox::from_id_salt(id).selected_text(opts.get(sel).map_or("", |o| crate::i18n::t(o))).width(130.0).show_ui(&mut vui, |ui| { for (i, o) in opts.iter().enumerate() { if ui.selectable_value(&mut sel, i, crate::i18n::t(o)).changed() { @@ -952,6 +973,7 @@ pub(crate) fn graph_rows( let t = app.tokens; let (vr, _) = ui.allocate_exact_size(vec2(body.width(), 110.0), Sense::hover()); let (velr, _) = ui.allocate_exact_size(vec2(body.width(), 64.0), Sense::hover()); + row_line(ui, velr, lane, &t); let speed = it.speed.abs().max(1e-6); let dur = it.duration.0.max(1) as f64; let to_media = |f: f64| it.source_in + Tick((f * dur * speed) as i64); diff --git a/crates/ui-egui/src/panels/masks.rs b/crates/ui-egui/src/panels/masks.rs index 4ffba906a..a74489bde 100644 --- a/crates/ui-egui/src/panels/masks.rs +++ b/crates/ui-egui/src/panels/masks.rs @@ -45,6 +45,7 @@ pub fn effect_rows( let t = app.tokens; // creation tools: ellipse, 4-point polygon, free-draw Bézier let (r, _) = ui.allocate_exact_size(vec2(body.width(), ROW_H), Sense::hover()); + crate::panels::effect_controls::row_line(ui, r, lane, &t); let pen_on = app.ui.mask_pen.as_ref().is_some_and(|p| p.clip == clip.0 && p.effect == idx); for (i, (icon, kind, tip)) in [ (Icon::Ellipse, "ellipse", tl!("Create ellipse mask")), @@ -85,6 +86,7 @@ pub fn effect_rows( } else if resp.hovered() { ui.painter().rect_filled(r, 0.0, t.hover); } + crate::panels::effect_controls::row_line(ui, r, lane, &t); let tw = Rect::from_center_size(pos2(r.min.x + 26.0, r.center().y), vec2(10.0, 10.0)); icons::paint(ui.painter(), tw, if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_dim); let twresp = ui.interact(tw.expand(3.0), egui::Id::new(("mask-twirl", clip.0, idx, k)), Sense::click()); @@ -106,6 +108,7 @@ pub fn effect_rows( let mr = Rect::from_min_size(pos2(r.max.x - 118.0, r.min.y + 2.0), vec2(96.0, ROW_H - 4.0)); let mut mui = ui.new_child(egui::UiBuilder::new().max_rect(mr).layout(egui::Layout::left_to_right(egui::Align::Center))); let mut mode = m.mode; + crate::panels::effect_controls::fit_dropdown_to_row(&mut mui); egui::ComboBox::from_id_salt(("mask-mode", clip.0, idx, k)).selected_text(crate::i18n::t(mode.label())).width(88.0).show_ui(&mut mui, |ui| { for md in MaskMode::ALL { if ui.selectable_value(&mut mode, md, crate::i18n::t(md.label())).changed() { @@ -148,6 +151,7 @@ pub fn effect_rows( } // Inverted let (r, _) = ui.allocate_exact_size(vec2(body.width(), ROW_H), Sense::hover()); + crate::panels::effect_controls::row_line(ui, r, lane, &t); ui.painter().text(pos2(r.min.x + 40.0, r.center().y), Align2::LEFT_CENTER, tl!("Inverted"), Tokens::ui(12.0), t.text); let cr = Rect::from_min_size(pos2(r.min.x + (r.width() * 0.5).max(150.0), r.min.y + 2.0), vec2(20.0, ROW_H - 4.0)); let mut cui = ui.new_child(egui::UiBuilder::new().max_rect(cr)); diff --git a/crates/ui-egui/src/panels/transition_controls.rs b/crates/ui-egui/src/panels/transition_controls.rs index 80a8a0d4a..47decaf28 100644 --- a/crates/ui-egui/src/panels/transition_controls.rs +++ b/crates/ui-egui/src/panels/transition_controls.rs @@ -74,6 +74,11 @@ pub fn effect_controls(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, id let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink2(vec2(10.0, 6.0))).id_salt(("tr-ec", id.0))); child.label(RichText::new(format!("{seq_name} · {name}")).color(t.text_dim)); child.add_space(4.0); + // the line under each row, as for a clip's effects (#640): halfway into the row spacing + let row_line = |ui: &egui::Ui| { + let y = ui.cursor().top() - 4.0; + ui.painter().line_segment([egui::pos2(rect.min.x, y), egui::pos2(rect.max.x, y)], egui::Stroke::new(1.0, t.separator)); + }; egui::ScrollArea::vertical().id_salt(("tr-ec-scroll", id.0)).auto_shrink([false, false]).show(&mut child, |ui| { egui::Grid::new(("tr-ec-grid", id.0)).num_columns(2).spacing([14.0, 8.0]).show(ui, |ui| { ui.label(tl!("Duration")); @@ -84,6 +89,7 @@ pub fn effect_controls(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, id changes.push(json!({"frames": frames, "merge": true, "begin": r.drag_started() || !r.dragged()})); } ui.end_row(); + row_line(ui); ui.label(tl!("Alignment")); let shown = align.label(); @@ -106,6 +112,7 @@ pub fn effect_controls(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, id .inner; elems.push(("effectControls.transition.alignment".into(), r.rect, shown.to_string())); ui.end_row(); + row_line(ui); ui.label(tl!("Reverse")); let mut rev = x.reverse; @@ -115,6 +122,7 @@ pub fn effect_controls(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, id changes.push(json!({"reverse": rev})); } ui.end_row(); + row_line(ui); for pd in def.map(|d| d.params.as_slice()).unwrap_or(&[]) { let value = x.effect.params.get(pd.id).map_or(&pd.default, |p| &p.value); @@ -123,6 +131,7 @@ pub fn effect_controls(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, id changes.push(v); } ui.end_row(); + row_line(ui); } }); ui.add_space(8.0); From ade56c62770718c385727879e5ea6e5f22cd5052 Mon Sep 17 00:00:00 2001 From: ReReddo <249398616+ReReddo@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:01:55 +0300 Subject: [PATCH 22/33] effect controls: draggable divider, scroll the whole panel (#643) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The split between the effect list and the keyframe area was fixed. Now a divider between them drags left or right, as in Premiere: it shows a resize cursor and turns blue under the pointer, and the width it sets stays while FilmCraft runs (UiState::effect_controls_split, automation id effectControls.divider). The effect list keeps the 260 points its rows are laid out for (values from 150 points in, the reset button at the end), the keyframe area at least 60. Scrolling was tied to the effect list. The scroll area now spans the list and the keyframe area, so the mouse wheel scrolls anywhere over the panel and the scrollbar sits at its right edge. The ruler's click and drag are handled after the rows, which now reach under it, so they still move the playhead. At narrow widths labels no longer run into what follows them: property, effect and mask names are cut off with "…" (effect_controls::row_label), and a dropdown narrows to the space left in its row. Smaller readability fixes in the same panel: - Keyframes in the lane are drawn at about Premiere's size (a diamond ~9 points wide instead of ~7; Hold squares and Bezier circles to match), with a grab area to match. - The keyframe navigator's arrows are taller (11 points, clickable over 20 of the row's 22) and its diamond larger; Properties uses the same navigator, so it gets them too. - Checkboxes (Bypass, Uniform Scale, a mask's Inverted) sit centred between their row lines (effect_controls::fit_to_row, which also fits dropdowns). Tests: keyframes_ui effect_controls_divider_and_scrolling_anywhere (the divider drags, keeps its width and both minimum widths; the wheel scrolls the rows over the keyframe area). --- crates/ui-egui/src/panels/effect_controls.rs | 90 ++++++++++++++------ crates/ui-egui/src/panels/masks.rs | 15 +++- crates/ui-egui/src/state.rs | 5 ++ crates/ui-egui/tests/keyframes_ui.rs | 39 +++++++++ 4 files changed, 121 insertions(+), 28 deletions(-) diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index 9098c5a3b..fd6588db5 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -13,6 +13,10 @@ use crate::state::KeyframeRef; use crate::theme::Tokens; const ROW_H: f32 = 22.0; +/// The narrowest the effect list and the keyframe area can be dragged (#643): the list's rows are +/// laid out for at least 260 points (values from 150 points in, the reset button at the end). +const MIN_LIST_W: f32 = 260.0; +const MIN_LANE_W: f32 = 60.0; /// Keyframe lane header: the time ruler (beside the Source / Sequence pills) and the clip's bar. const RULER_H: f32 = 24.0; const CLIP_BAR_H: f32 = 18.0; @@ -99,7 +103,13 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { crate::dock::placeholder(ui, rect, &t, tl!("(no sequences)")); return; }; - let split = rect.min.x + (rect.width() * 0.58).max(260.0).min(rect.width() - 60.0); + // the divider between the effect list and the keyframe area; dragging it sets the list's width + // (#643), each side keeping a minimum width + // (the keyframe area has 4 points of margin left of it and 6 right) + let max_list = (rect.width() - MIN_LANE_W - 10.0).max(MIN_LIST_W); + let list_w = if app.ui.effect_controls_split > 0.0 { app.ui.effect_controls_split } else { (rect.width() * 0.58).max(260.0) }; + let list_w = list_w.clamp(MIN_LIST_W, max_list); + let split = rect.min.x + list_w; 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| { @@ -123,22 +133,14 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let rate = seq.settings.frame_rate; let ruler = Rect::from_min_max(lane.min, pos2(lane.max.x, lane.min.y + RULER_H)); 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)); - // click or drag anywhere on the ruler (or the bar) to move the playhead let scrub = Rect::from_min_max(ruler.min, bar.max); - let sresp = ui.interact(scrub, egui::Id::new(("ec-scrub", clip.0)), Sense::click_and_drag()); app.auto.add("effectControls.lane", lane, "keyframe lane"); app.auto.add("effectControls.ruler", scrub, "time ruler"); - if (sresp.dragged() || sresp.clicked()) - && let Some(pos) = sresp.interact_pointer_pos() - { - let f = ((pos.x - lane.min.x) / lane.width()).clamp(0.0, 1.0) as f64; - let tk = rate.snap_nearest(it.start + Tick((f * it.duration.0 as f64) as i64)); - app.stop(); - app.session.set_playhead(tk); - } let body = Rect::from_min_max(pos2(rect.min.x, head.max.y + 4.0), pos2(split, rect.max.y - 26.0)); let mut actions: Vec<(String, Value)> = Vec::new(); - let mut bui = ui.new_child(egui::UiBuilder::new().max_rect(body).id_salt("ec-body")); + // the rows span the list and the keyframe area: the wheel scrolls anywhere over them and the + // scrollbar sits at the panel's right edge (#643) + let mut bui = ui.new_child(egui::UiBuilder::new().max_rect(Rect::from_min_max(body.min, pos2(lane.max.x, body.max.y))).id_salt("ec-body")); bui.set_clip_rect(Rect::from_min_max(body.min, pos2(rect.max.x, body.max.y))); let scroll_out = egui::ScrollArea::vertical().id_salt("ec-scroll").auto_shrink([false, false]).show(&mut bui, |bui| { for (clip, it, kind) in &clips { @@ -176,7 +178,8 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { 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); + // (the reset button sits at the row's right end) + row_label(bui, pos2(r.min.x + 42.0, r.center().y), crate::i18n::t(def.name), r.max.x - 26.0 - (r.min.x + 42.0), 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")); @@ -238,6 +241,26 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { paint_ruler(ui.painter(), ruler, &it, rate, seq.settings.drop_frame, &t); 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 (after the rows, which + // reach under it, so it gets the pointer first) + let sresp = ui.interact(scrub, egui::Id::new(("ec-scrub", clip.0)), Sense::click_and_drag()); + if (sresp.dragged() || sresp.clicked()) + && let Some(pos) = sresp.interact_pointer_pos() + { + let f = ((pos.x - lane.min.x) / lane.width()).clamp(0.0, 1.0) as f64; + let tk = rate.snap_nearest(it.start + Tick((f * it.duration.0 as f64) as i64)); + app.stop(); + app.session.set_playhead(tk); + } + // the divider: a line the height of the panel, a resize cursor and a wider grab area + let divider = Rect::from_min_max(pos2(split - 2.0, rect.min.y), pos2(split + 4.0, rect.max.y - 26.0)); + let dresp = ui.interact(divider, egui::Id::new("ec-divider"), Sense::drag()).on_hover_cursor(egui::CursorIcon::ResizeHorizontal); + if dresp.dragged() { + app.ui.effect_controls_split = (list_w + dresp.drag_delta().x).clamp(MIN_LIST_W, max_list); + } + let dcol = if dresp.hovered() || dresp.dragged() { t.accent } else { t.separator }; + ui.painter().line_segment([pos2(split + 1.5, rect.min.y), pos2(split + 1.5, rect.max.y - 26.0)], Stroke::new(1.0, dcol)); + app.auto.add("effectControls.divider", divider, "divider"); // playhead: a handle on the ruler and a line down the lane, while it is on the clip if ph >= it.start && ph <= it.end() { let px = lx(ph); @@ -261,8 +284,20 @@ pub(crate) fn row_line(ui: &egui::Ui, r: Rect, lane: &Rect, t: &Tokens) { ui.painter().line_segment([pos2(r.min.x, y), pos2(lane.max.x, y)], Stroke::new(1.0, t.separator)); } -/// Make a dropdown in a row fit inside the row, clear of its row lines. -pub(crate) fn fit_dropdown_to_row(ui: &mut egui::Ui) { +/// A row's label, cut off with "…" where it would run into what follows it, as Premiere does when +/// the effect list is narrow (#643). +pub(crate) fn row_label(ui: &egui::Ui, left_center: Pos2, text: &str, max_w: f32, font: egui::FontId, color: Color32) { + let mut job = egui::text::LayoutJob::single_section(text.to_owned(), egui::TextFormat::simple(font, color)); + job.wrap.max_width = max_w.max(0.0); + job.wrap.max_rows = 1; + job.wrap.break_anywhere = true; + job.wrap.overflow_character = Some('…'); + let g = ui.fonts_mut(|f| f.layout_job(job)); + ui.painter().galley(pos2(left_center.x, left_center.y - g.size().y / 2.0), g, color); +} + +/// Make a dropdown or a checkbox in a row fit inside the row, centred between its row lines. +pub(crate) fn fit_to_row(ui: &mut egui::Ui) { ui.spacing_mut().interact_size.y = ROW_H - 4.0; ui.spacing_mut().button_padding.y = 1.0; } @@ -361,10 +396,11 @@ fn keyframe_nav( for (d, target, name, tip) in [(-1.0, param.prev_keyframe(mt), "prevKeyframe", "Go to previous keyframe"), (1.0, param.next_keyframe(mt), "nextKeyframe", "Go to next keyframe")] { - let r = Rect::from_center_size(at + vec2(d * NAV_STEP, 0.0), vec2(12.0, 14.0)); + // the arrows almost the row's height to click, as Premiere's + let r = Rect::from_center_size(at + vec2(d * NAV_STEP, 0.0), vec2(14.0, 20.0)); let resp = ui.interact(r, id.with(name), Sense::click()).on_hover_text(crate::i18n::t(tip)); let c = r.center(); - let pts = vec![c + vec2(-3.0 * d, -4.0), c + vec2(-3.0 * d, 4.0), c + vec2(3.0 * d, 0.0)]; + let pts = vec![c + vec2(-3.5 * d, -5.5), c + vec2(-3.5 * d, 5.5), c + vec2(3.5 * d, 0.0)]; ui.painter().add(egui::Shape::convex_polygon(pts, if target.is_some() { t.text } else { t.text_faint }, Stroke::NONE)); app.auto.add(&format!("{auto}.{name}"), r, tip); if resp.clicked() @@ -374,12 +410,12 @@ fn keyframe_nav( } } } - let r = Rect::from_center_size(at, vec2(13.0, 13.0)); + let r = Rect::from_center_size(at, vec2(15.0, 15.0)); let resp = ui.interact(r.expand(1.5), id.with("key"), Sense::click()).on_hover_text(crate::i18n::t(tip)); if param.is_some_and(|p| p.keyframes.iter().any(|k| k.time == mt)) { icons::paint(ui.painter(), r, Icon::Keyframe, t.hot_text); } else { - let s = 4.0; + let s = 4.7; let outline = vec![at + vec2(0.0, -s), at + vec2(s, 0.0), at + vec2(0.0, s), at + vec2(-s, 0.0)]; ui.painter().add(egui::Shape::closed_line(outline, Stroke::new(1.2, if resp.hovered() { t.text } else { t.text_dim }))); } @@ -483,8 +519,8 @@ pub(crate) fn param_row( app.auto.add(&format!("effectControls.{}.{}.stopwatch", e.effect, pkey), sw, "Toggle animation"); } x += 14.0; - ui.painter().text(pos2(x, r.center().y), Align2::LEFT_CENTER, crate::i18n::t(pd.label), Tokens::ui(12.0), t.text); let vx = r.min.x + (r.width() * 0.5).max(150.0); + row_label(ui, pos2(x, r.center().y), crate::i18n::t(pd.label), vx - 6.0 - x, Tokens::ui(12.0), t.text); let value = param.value_at(mt); let mut vui = ui.new_child( egui::UiBuilder::new() @@ -516,14 +552,17 @@ pub(crate) fn param_row( } (ParamKind::Bool, ParamValue::Bool(b)) => { let mut v = *b; + fit_to_row(&mut vui); if vui.checkbox(&mut v, "").changed() { set = Some(json!(v)); } } (ParamKind::Choice(opts), ParamValue::Choice(c)) => { let mut sel = *c as usize; - fit_dropdown_to_row(&mut vui); - egui::ComboBox::from_id_salt(id).selected_text(opts.get(sel).map_or("", |o| crate::i18n::t(o))).width(130.0).show_ui(&mut vui, |ui| { + fit_to_row(&mut vui); + // (narrower when the effect list is narrow, so it stays left of the divider) + let w = vui.available_width().min(130.0); + egui::ComboBox::from_id_salt(id).selected_text(opts.get(sel).map_or("", |o| crate::i18n::t(o))).width(w).show_ui(&mut vui, |ui| { for (i, o) in opts.iter().enumerate() { if ui.selectable_value(&mut sel, i, crate::i18n::t(o)).changed() { set = Some(json!(i)); @@ -594,7 +633,8 @@ pub(crate) fn param_row( let id = egui::Id::new(("kf", clip.0, idx, pkey, k.time.0)); let drag_off: Option = ui.data(|d| d.get_temp(id)); let kx = lane.min.x + f * lane.width() + drag_off.unwrap_or(0.0); - let kr = Rect::from_center_size(pos2(kx, y), vec2(11.0, 11.0)); + // about Premiere's size: a diamond ~9 points wide + let kr = Rect::from_center_size(pos2(kx, y), vec2(15.0, 15.0)); let resp = ui.interact(kr.expand(2.0), id, Sense::click_and_drag()); app.auto.add(&format!("effectControls.{}.{}.keyframe.{}", e.effect, pkey, k.time.0), kr, "keyframe"); // highlighted when selected (not merely under the playhead: that lit up every @@ -607,11 +647,11 @@ pub(crate) fn param_row( let col = if sel { t.hot_text } else { Color32::from_rgb(0xb0, 0xb0, 0xb0) }; match k.interp { filmcraft_project::Interpolation::Hold => { - ui.painter().rect_filled(Rect::from_center_size(kr.center(), vec2(8.0, 8.0)), 0.0, col); + ui.painter().rect_filled(Rect::from_center_size(kr.center(), vec2(9.0, 9.0)), 0.0, col); } filmcraft_project::Interpolation::Linear => icons::paint(ui.painter(), kr, Icon::Keyframe, col), _ => { - ui.painter().circle_filled(kr.center(), 4.5, col); + ui.painter().circle_filled(kr.center(), 5.5, col); } } if resp.dragged() { diff --git a/crates/ui-egui/src/panels/masks.rs b/crates/ui-egui/src/panels/masks.rs index a74489bde..5062918ea 100644 --- a/crates/ui-egui/src/panels/masks.rs +++ b/crates/ui-egui/src/panels/masks.rs @@ -101,14 +101,22 @@ pub fn effect_rows( Icon::Pen }; icons::paint(ui.painter(), glyph, gi, if selected { t.accent } else { t.text_dim }); - ui.painter().text(pos2(r.min.x + 54.0, r.center().y), Align2::LEFT_CENTER, &m.name, Tokens::ui(12.0), t.text); + // (the mode dropdown starts 118 points from the row's right end) + crate::panels::effect_controls::row_label( + ui, + pos2(r.min.x + 54.0, r.center().y), + &m.name, + r.max.x - 122.0 - (r.min.x + 54.0), + Tokens::ui(12.0), + t.text, + ); let base = format!("effectControls.{}.mask{k}", e.effect); app.auto.add(&base, r, &m.name); // mode let mr = Rect::from_min_size(pos2(r.max.x - 118.0, r.min.y + 2.0), vec2(96.0, ROW_H - 4.0)); let mut mui = ui.new_child(egui::UiBuilder::new().max_rect(mr).layout(egui::Layout::left_to_right(egui::Align::Center))); let mut mode = m.mode; - crate::panels::effect_controls::fit_dropdown_to_row(&mut mui); + crate::panels::effect_controls::fit_to_row(&mut mui); egui::ComboBox::from_id_salt(("mask-mode", clip.0, idx, k)).selected_text(crate::i18n::t(mode.label())).width(88.0).show_ui(&mut mui, |ui| { for md in MaskMode::ALL { if ui.selectable_value(&mut mode, md, crate::i18n::t(md.label())).changed() { @@ -154,7 +162,8 @@ pub fn effect_rows( crate::panels::effect_controls::row_line(ui, r, lane, &t); ui.painter().text(pos2(r.min.x + 40.0, r.center().y), Align2::LEFT_CENTER, tl!("Inverted"), Tokens::ui(12.0), t.text); let cr = Rect::from_min_size(pos2(r.min.x + (r.width() * 0.5).max(150.0), r.min.y + 2.0), vec2(20.0, ROW_H - 4.0)); - let mut cui = ui.new_child(egui::UiBuilder::new().max_rect(cr)); + let mut cui = ui.new_child(egui::UiBuilder::new().max_rect(cr).layout(egui::Layout::left_to_right(egui::Align::Center))); + crate::panels::effect_controls::fit_to_row(&mut cui); let mut inv = m.inverted; if cui.checkbox(&mut inv, "").changed() { actions.push(("masks.set".into(), json!({"clip": clip.0, "effect": idx, "mask": k, "inverted": inv}))); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index b5775263a..242fc6e44 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -442,6 +442,10 @@ pub struct UiState { pub lumetri_grid_folder: Option, /// Collapsed effect sections in Effect Controls ("clip:index"). pub collapsed_fx: Vec, + /// Width of Effect Controls' effect list, left of the keyframe area (points; 0 = the default + /// share of the panel). Dragging the divider sets it (#643). + #[serde(default)] + pub effect_controls_split: f32, pub show_menu_bar: bool, /// The header bar (Home, Import, Edit, Export, workspaces). An app that embeds FilmCraft can hide it. #[serde(default = "shown")] @@ -950,6 +954,7 @@ impl Default for UiState { expanded_fx: vec!["Video Transitions".into(), "Video Transitions/Dissolve".into()], lumetri_grid_folder: None, collapsed_fx: vec![], + effect_controls_split: 0.0, show_menu_bar: true, show_header: true, show_status_bar: true, diff --git a/crates/ui-egui/tests/keyframes_ui.rs b/crates/ui-egui/tests/keyframes_ui.rs index c97cd5066..61164c7b6 100644 --- a/crates/ui-egui/tests/keyframes_ui.rs +++ b/crates/ui-egui/tests/keyframes_ui.rs @@ -258,3 +258,42 @@ fn clicking_a_lane_keyframe_selects_only_that_keyframe() { d.click("effectControls.motion.position.addKeyframe"); assert_eq!(selection(&mut d), json!([]), "a deleted keyframe is not selected"); } + +/// Effect Controls' divider (#643): dragging it widens the effect list or the keyframe area, each +/// keeping a minimum width, and the width stays. The mouse wheel scrolls the rows over the +/// keyframe area too, not only over the effect list. +#[test] +fn effect_controls_divider_and_scrolling_anywhere() { + let (mut d, clip) = Driver::demo(); + let lane = d.rect("effectControls.lane"); + let div = d.rect("effectControls.divider"); + let (x, y) = (div[0] + div[2] / 2.0, div[1] + div[3] / 2.0); + d.ok("ui.drag", json!({"from": {"x": x, "y": y}, "to": {"x": x + 80.0, "y": y}, "steps": 6})); + let narrower = d.rect("effectControls.lane"); + assert!((narrower[0] - (lane[0] + 80.0)).abs() <= 1.0, "the keyframe area starts 80 points further right: {narrower:?} vs {lane:?}"); + assert!((narrower[0] + narrower[2] - (lane[0] + lane[2])).abs() <= 1.0, "and still ends at the panel's edge"); + d.frames(3); + assert_eq!(d.rect("effectControls.lane"), narrower, "the width stays"); + // dragged all the way right, the keyframe area keeps its minimum width + let drag_divider = |d: &mut Driver, by: f64| { + let div = d.rect("effectControls.divider"); + let (x, y) = (div[0] + div[2] / 2.0, div[1] + div[3] / 2.0); + d.ok("ui.drag", json!({"from": {"x": x, "y": y}, "to": {"x": x + by, "y": y}, "steps": 6})); + }; + drag_divider(&mut d, 3000.0); + assert!(d.rect("effectControls.lane")[2] >= 59.0, "the keyframe area can't vanish"); + // and all the way left, the effect list keeps the width its rows are laid out for + drag_divider(&mut d, -3000.0); + assert!(d.rect("effectControls.effect.motion")[2] >= 259.0, "the effect list stays wide enough for its values"); + + // enough effects to scroll; the wheel over the keyframe area scrolls the rows + for fx in ["gaussian_blur", "transform", "crop", "tint"] { + d.exec("effects.apply", json!({"clips": [clip.id], "effect": fx})); + } + let before = d.rect("effectControls.motion.scale.stopwatch"); + let lane = d.rect("effectControls.lane"); + d.ok("ui.scroll", json!({"x": lane[0] + lane[2] / 2.0, "y": lane[1] + lane[3] * 0.7, "dx": 0.0, "dy": -80.0})); + d.frames(4); + let after = d.find("effectControls.motion.scale.stopwatch"); + assert!(after.is_none_or(|r| r[1] < before[1] - 20.0), "the rows scrolled up: {before:?} → {after:?}"); +} From 110c1783c343cb25f021e0a9c31a7bb7f2b5283b Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 21:09:13 +0200 Subject: [PATCH 23/33] #620: Project Notes panel, saved in the project Problem: a project had nowhere to keep free text (to-dos, instructions for whoever opens it next, frame numbers). Fix: plain-text `Project::notes` (schema v15, no-op migration so older builds refuse instead of dropping it), `project.notes` / `project.setNotes {text, merge?}` engine commands (undoable, capped at 1 MiB; calls with one `merge` key share an undo step), and a Window > Project Notes panel whose editor (`notes.text`) commits every change, one typing session per undo step. Translations for es, ja, zh-cn (and the panel title for pt-br, uk). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/panels.rs | 44 +++++++++++++++++++++++++ crates/engine/src/panels_tests.rs | 37 +++++++++++++++++++++ crates/format/src/lib.rs | 11 +++++-- crates/project/src/lib.rs | 5 +++ crates/ui-egui/src/dock.rs | 5 ++- crates/ui-egui/src/i18n/es.tsv | 4 +++ crates/ui-egui/src/i18n/ja.tsv | 4 +++ crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/zh-cn.tsv | 4 +++ crates/ui-egui/src/panels/mod.rs | 2 ++ crates/ui-egui/src/panels/notes.rs | 53 ++++++++++++++++++++++++++++++ crates/ui-egui/tests/panels_ui.rs | 28 +++++++++++++++- docs/architecture.md | 3 +- 14 files changed, 197 insertions(+), 5 deletions(-) create mode 100644 crates/ui-egui/src/panels/notes.rs diff --git a/crates/engine/src/panels.rs b/crates/engine/src/panels.rs index 45da2e219..6246e7a31 100644 --- a/crates/engine/src/panels.rs +++ b/crates/engine/src/panels.rs @@ -4,6 +4,7 @@ //! |---|---| //! | `events.list` / `events.clear` | the Events panel: warnings and errors of commands, background jobs and the auto-save worker ([`EventLog`]) | //! | `metadata.get` / `metadata.set` | the Metadata panel: an item's clip and file properties; editable log fields (undoable) | +//! | `project.notes` / `project.setNotes` | the Project Notes panel: free plain text saved in the project (undoable) | //! //! The Progress panel uses `jobs.list` / `jobs.cancel`; the Timecode panel and the Reference //! Monitor are frontend state only. @@ -339,6 +340,38 @@ fn metadata_set(s: &mut Session, p: &Value) -> Result { Ok(json!({"item": id.0, "name": it.name, "label": it.label.name(), "metadata": it.metadata, "changed": !unchanged})) } +// ------------------------------------------------------------------------------------ project notes + +/// Longest project notes accepted (bytes of UTF-8), so a script or a pasted file cannot grow the +/// project (and every undo step) without bound. +pub const MAX_NOTES_BYTES: usize = 1 << 20; + +/// `project.notes`: the project notes. +fn notes_get(s: &mut Session, _: &Value) -> Result { + Ok(json!({"text": s.project.notes})) +} + +/// `project.setNotes {text, merge?}`: replace the project notes as one undo step. Calls with the +/// same `merge` key and no other edit in between share that step (typing in the panel). +fn notes_set(s: &mut Session, p: &Value) -> Result { + let text = p.get("text").and_then(Value::as_str).ok_or_else(|| bad("project.setNotes", "need `text` (a string)"))?; + if text.len() > MAX_NOTES_BYTES { + return Err(bad("project.setNotes", format!("the notes are too long ({} bytes; at most {MAX_NOTES_BYTES})", text.len()))); + } + let changed = s.project.notes != text; + if changed { + let body = |pr: &mut Project, _: &mut crate::EditorState| { + pr.notes = text.to_string(); + Ok(()) + }; + match str_p(p, "merge") { + Some(key) => s.edit_merged("Edit Project Notes", &format!("project.notes:{key}"), body)?, + None => s.edit("Edit Project Notes", body)?, + } + } + Ok(json!({"text": s.project.notes, "changed": changed})) +} + // ------------------------------------------------------------------------------------ registry pub(crate) fn commands() -> Vec { @@ -370,5 +403,16 @@ pub(crate) fn commands() -> Vec { run: metadata_set, journal: true, }, + q("project.notes", "Get Project Notes", "{}", notes_get), + CommandSpec { + id: "project.setNotes", + label: "Edit Project Notes", + menu: &[], + shortcut: None, + params: r#"{"text":str,"merge":str?}"#, + enabled: always, + run: notes_set, + journal: true, + }, ] } diff --git a/crates/engine/src/panels_tests.rs b/crates/engine/src/panels_tests.rs index 357e995a4..8be530abe 100644 --- a/crates/engine/src/panels_tests.rs +++ b/crates/engine/src/panels_tests.rs @@ -156,3 +156,40 @@ fn events_report_background_jobs() { s.poll_persistence(); assert_eq!(s.log.entries.len(), 6); } + +/// #620: project notes are saved in the project file; typing (one `merge` key) is one undo step. +#[test] +fn project_notes_are_saved_and_typing_is_one_undo_step() { + let mut s = demo(); + assert_eq!(s.execute("project.notes", json!({})).unwrap()["text"], ""); + let undo0 = s.history.undo.len(); + for text in ["T", "To", "To do: fix the logo at 00:01:02:03"] { + s.execute("project.setNotes", json!({"text": text, "merge": "1"})).unwrap(); + } + assert_eq!(s.history.undo.len(), undo0 + 1, "one typing session, one undo step"); + assert_eq!(s.history.undo.last().unwrap().0, "Edit Project Notes"); + // the same text again changes nothing and adds no step + let r = s.execute("project.setNotes", json!({"text": "To do: fix the logo at 00:01:02:03"})).unwrap(); + assert_eq!(r["changed"], json!(false)); + assert_eq!(s.history.undo.len(), undo0 + 1); + + let loaded = filmcraft_format::decode(&filmcraft_format::encode(&s.project, false)).unwrap(); + assert_eq!(loaded.project.notes, "To do: fix the logo at 00:01:02:03"); + // an older file without notes still loads, with none + let mut doc: Value = serde_json::from_slice(&filmcraft_format::encode(&filmcraft_project::Project::new("old"), false)).unwrap(); + doc["schema_version"] = json!(14); + assert_eq!(filmcraft_format::decode(&serde_json::to_vec(&doc).unwrap()).unwrap().project.notes, ""); + + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.project.notes, ""); +} + +#[test] +fn project_notes_refuse_bad_input() { + let mut s = demo(); + assert!(s.execute("project.setNotes", json!({})).is_err()); + assert!(s.execute("project.setNotes", json!({"text": 5})).is_err()); + let huge = "x".repeat(crate::panels::MAX_NOTES_BYTES + 1); + assert!(s.execute("project.setNotes", json!({"text": huge})).is_err()); + assert_eq!(s.project.notes, ""); +} diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 238649afd..70af3c747 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -37,6 +37,8 @@ //! `audio` objects read as stream zero; older builds must refuse new multi-stream projects. //! - **v14** (M7.9): narrations (`Project::narrations`, Text to Speech). No-op step; older builds //! would drop them when saving. +//! - **v15**: project notes (`Project::notes`, Window ▸ Project Notes). No-op step; older builds +//! would drop them when saving. #![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))] @@ -58,7 +60,7 @@ pub type Migration = fn(Value) -> Result; /// `MIGRATIONS[i]` upgrades schema `i + 1` to `i + 2`. Append one function per schema bump; never /// edit a shipped one. pub const MIGRATIONS: &[Migration] = - &[v1_to_v2, v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, v6_to_v7, v7_to_v8, v8_to_v9, v9_to_v10, v10_to_v11, v11_to_v12, v12_to_v13, v13_to_v14]; + &[v1_to_v2, v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, v6_to_v7, v7_to_v8, v8_to_v9, v9_to_v10, v10_to_v11, v11_to_v12, v12_to_v13, v13_to_v14, v14_to_v15]; /// The schema version this build writes (and the newest it reads). pub const SCHEMA_VERSION: u32 = MIGRATIONS.len() as u32 + 1; @@ -354,13 +356,18 @@ fn v13_to_v14(doc: Value) -> Result { Ok(doc) } +/// v14 → v15: project notes. Existing data needs no change. +fn v14_to_v15(doc: Value) -> Result { + Ok(doc) +} + #[cfg(test)] mod tests { use super::*; #[test] fn schema_version_matches_table() { - assert_eq!(SCHEMA_VERSION, 14); + assert_eq!(SCHEMA_VERSION, 15); } #[test] diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index e8a988568..aca499f9f 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -1253,6 +1253,10 @@ pub struct Project { /// made from, keyed by that item. Schema v14. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub narrations: BTreeMap, + /// Project notes (Window ▸ Project Notes): free plain text kept with the project, for to-dos, + /// instructions for whoever opens it next, frame numbers and the like. Schema v15. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub notes: String, } /// How a sequence is shown in the Timeline panel: its zoom, scroll position and track heights. @@ -1348,6 +1352,7 @@ impl Project { search_bins: Vec::new(), source_graphics: BTreeMap::new(), narrations: BTreeMap::new(), + notes: String::new(), } } diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 05acf45b7..d2242b260 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -37,10 +37,11 @@ pub enum PanelKind { Progress, ReferenceMonitor, Timecode, + ProjectNotes, } impl PanelKind { - pub const ALL: [PanelKind; 27] = [ + pub const ALL: [PanelKind; 28] = [ PanelKind::Project, PanelKind::MediaBrowser, PanelKind::Libraries, @@ -68,6 +69,7 @@ impl PanelKind { PanelKind::Progress, PanelKind::ReferenceMonitor, PanelKind::Timecode, + PanelKind::ProjectNotes, ]; pub fn title(self) -> &'static str { match self { @@ -98,6 +100,7 @@ impl PanelKind { PanelKind::Progress => "Progress", PanelKind::ReferenceMonitor => "Reference Monitor", PanelKind::Timecode => "Timecode", + PanelKind::ProjectNotes => "Project Notes", } } pub fn id(self) -> String { diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 08f899ecb..0b03b9883 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3512,3 +3512,7 @@ Composite on the GPU when available Componer en la GPU cuando esté disponible Text to Speech Texto a voz Edit Narration Editar narración + Project Notes Notas del proyecto + Get Project Notes Obtener notas del proyecto + Edit Project Notes Editar notas del proyecto + Notes for this project: to-dos, instructions, frame numbers… Saved with the project. Notas de este proyecto: tareas pendientes, instrucciones, números de fotograma… Se guardan con el proyecto. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 8dbf44199..ed58e821b 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3505,3 +3505,7 @@ Edit Narration ナレーションを編集 Clear Transition トランジションを消去 Set Transition Duration トランジションのデュレーションを設定 + Project Notes プロジェクトメモ + Get Project Notes プロジェクトメモを取得 + Edit Project Notes プロジェクトメモを編集 + Notes for this project: to-dos, instructions, frame numbers… Saved with the project. このプロジェクトのメモ: やること、指示、フレーム番号など… プロジェクトと一緒に保存されます。 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 1e9c995ea..fc7b9fd50 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -355,3 +355,4 @@ # System appearance mode menu command. Next Appearance Mode Próximo modo de aparência + Project Notes Notas do projeto diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 6fa303f4d..13312b311 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -355,3 +355,4 @@ Zoom In Збільшити Zoom Out Зменшити Zoom to Sequence Умістити секвенцію + Project Notes Нотатки проєкту diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index 731067f98..e39c7f50a 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -3494,3 +3494,7 @@ Edit Narration 编辑旁白 Clear Transition 清除过渡 Set Transition Duration 设置过渡持续时间 + Project Notes 项目备注 + Get Project Notes 获取项目备注 + Edit Project Notes 编辑项目备注 + Notes for this project: to-dos, instructions, frame numbers… Saved with the project. 此项目的备注:待办事项、说明、帧号等… 随项目一起保存。 diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index bcb386fbb..0e7b20d14 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -29,6 +29,7 @@ pub mod mixer; pub mod monitor; pub mod monitor_view; pub mod multicam; +pub mod notes; pub mod panel_state; pub mod presets; pub mod project; @@ -101,6 +102,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rect) PanelKind::Text => text::show(app, ui, rect), PanelKind::EssentialSound => essential_sound::show(app, ui, rect), PanelKind::TextToSpeech => tts::show(app, ui, rect), + PanelKind::ProjectNotes => notes::show(app, ui, rect), other => crate::dock::placeholder(ui, rect, &app.tokens, &tlf!("{panel} — coming in a later milestone", panel = crate::i18n::t(other.title()))), } } diff --git a/crates/ui-egui/src/panels/notes.rs b/crates/ui-egui/src/panels/notes.rs new file mode 100644 index 000000000..740db1f46 --- /dev/null +++ b/crates/ui-egui/src/panels/notes.rs @@ -0,0 +1,53 @@ +//! Project Notes panel (#620): free plain text kept in the project file, for to-dos, +//! instructions for whoever opens the project next, frame numbers and the like. Every change is +//! a `project.setNotes`; one typing session (from focusing the editor until it loses focus) is +//! one undo step. +//! +//! Automation ids: `notes.text` (the editor; its label is the start of the notes). Agents read +//! and write the notes with `project.notes` / `project.setNotes`. + +use egui::{Rect, vec2}; +use serde_json::json; + +use crate::FilmcraftApp; + +/// Longest automation label (characters): the full notes are `project.notes`. +const LABEL_CHARS: usize = 200; + +pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { + let t = app.tokens; + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink2(vec2(8.0, 6.0)))); + let ui = &mut child; + let mut text = app.session.project.notes.clone(); + let height = ui.available_height(); + let r = egui::ScrollArea::vertical() + .id_salt("project-notes-scroll") + .auto_shrink([false, false]) + .show(ui, |ui| { + ui.add( + egui::TextEdit::multiline(&mut text) + .id_salt("project-notes") + .hint_text(tl!("Notes for this project: to-dos, instructions, frame numbers… Saved with the project.")) + .text_color(t.text) + .desired_width(f32::INFINITY) + .min_size(vec2(0.0, height)), + ) + }) + .inner; + // each focus of the editor starts a new undo step; the edits while it keeps focus join it + let typing = egui::Id::new("project-notes-typing"); + if r.gained_focus() { + ui.data_mut(|d| { + let n = d.get_temp::(typing).unwrap_or(0).wrapping_add(1); + d.insert_temp(typing, n); + }); + } + if r.changed() { + let key = ui.data(|d| d.get_temp::(typing)).unwrap_or(0); + if let Err(e) = app.session.execute("project.setNotes", json!({"text": text, "merge": key.to_string()})) { + app.ui.status = e.to_string(); + } + } + let label: String = app.session.project.notes.chars().take(LABEL_CHARS).collect(); + app.auto.add("notes.text", r.rect, &label); +} diff --git a/crates/ui-egui/tests/panels_ui.rs b/crates/ui-egui/tests/panels_ui.rs index e395f4a8a..d548d6cb9 100644 --- a/crates/ui-egui/tests/panels_ui.rs +++ b/crates/ui-egui/tests/panels_ui.rs @@ -158,7 +158,7 @@ fn every_panel_opens_from_the_window_menu() { let menu = d.ok("ui.menu.list", json!({})); let window: Vec = menu.as_array().unwrap().iter().filter(|m| m["path"] == json!(["Window"])).filter_map(|m| m["label"].as_str().map(str::to_string)).collect(); - for p in ["Events", "Lumetri Scopes", "Metadata", "Progress", "Reference Monitor", "Timecode"] { + for p in ["Events", "Lumetri Scopes", "Metadata", "Progress", "Project Notes", "Reference Monitor", "Timecode"] { assert!(window.iter().any(|w| w == p), "{p} not in Window: {window:?}"); } for (panel, key) in [ @@ -168,6 +168,7 @@ fn every_panel_opens_from_the_window_menu() { ("Events", "events.clearAll"), ("Progress", "progress.showFinished"), ("ReferenceMonitor", "reference.gang"), + ("ProjectNotes", "notes.text"), ] { d.ok("ui.menu.invoke", json!({"id": format!("window.panel.{panel}")})); d.frames(3); @@ -267,6 +268,31 @@ fn metadata_panel_edits_a_field_with_undo() { assert_eq!(d.label("metadata.field.TapeName"), "A001"); } +/// #620: notes typed into Project Notes land in the project; one typing session is one undo step. +#[test] +fn project_notes_panel_types_into_the_project_with_undo() { + let mut d = Driver::demo(); + d.ok("ui.menu.invoke", json!({"id": "window.panel.ProjectNotes"})); + d.frames(3); + let undo0 = d.app().session.history.undo.len(); + d.click("notes.text"); + d.ok("ui.type", json!({"text": "Fix logo"})); + d.frames(2); + d.ok("ui.key", json!({"key": "Enter"})); + d.ok("ui.type", json!({"text": "at 1:02"})); + d.frames(3); + assert_eq!(d.app().session.project.notes, "Fix logo\nat 1:02"); + assert_eq!(d.app().session.history.undo.len(), undo0 + 1); + assert_eq!(d.label("notes.text"), "Fix logo\nat 1:02"); + d.exec("edit.undo", json!({})); + d.frames(2); + assert_eq!(d.app().session.project.notes, ""); + // an agent writes them, the panel shows them + d.exec("project.setNotes", json!({"text": "From the script"})); + d.frames(2); + assert_eq!(d.label("notes.text"), "From the script"); +} + #[test] fn timecode_panel_rows_and_modes() { let mut d = Driver::demo(); diff --git a/docs/architecture.md b/docs/architecture.md index a64d130f1..f9e005f7c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -729,7 +729,8 @@ All of these dispatch the same command ids. - **Panels with engine data** (M8.9 / M12.6): Lumetri Scopes (`scopes.read` returns the same scopes as numbers), Metadata (`metadata.get` / `metadata.set`, one undo step per edit, stored in `ProjectItem::metadata`), Events (`events.list` / `events.clear`), Progress (`jobs.list` / - `jobs.cancel`). Timecode and Reference Monitor are frontend-only views. Their settings are + `jobs.cancel`), Project Notes (`project.notes` / `project.setNotes`, plain text in + `Project::notes`, schema v15; one typing session is one undo step). Timecode and Reference Monitor are frontend-only views. Their settings are `UiState::panels` (`ui.set {"panels": {...}}`). - **Project panel and Media Browser** (M12.7). View settings (List / Icon / Freeform, thumbnail and font size, Preview Area, Hover Scrub), the List view's columns, widths and sort, and ten view From 6f650a363abc204626cc6c17fc989eb6c7b7f9bd Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 21:13:42 +0200 Subject: [PATCH 24/33] #456: a bin can be dragged into another bin and back out Problem: a bin stayed wherever it was created. `project.moveToBin` only moved project items, and bin rows and cards in the Project panel could not be dragged, so the only way to nest bins was to create them inside each other. Fix: `project.moveToBin` also takes bin ids in `items` (as `project.delete` does) and moves the bin with everything in it, skipping the root and any move that would put a bin inside itself. In the Project panel, bin rows (List view) and bin cards (Icon view) can be dragged: dropping one on another bin nests it there, and dropping it on empty space moves it into the bin the view shows. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands.rs | 2 +- crates/project/src/lib.rs | 54 ++++++++++++++++++---- crates/ui-egui/src/panels/mod.rs | 12 +++++ crates/ui-egui/src/panels/project_views.rs | 44 +++++++++++++++--- crates/ui-egui/tests/project_panel_ui.rs | 35 ++++++++++++++ 5 files changed, 132 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 33e2ee85f..a53dc12c3 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -2765,7 +2765,7 @@ fn build() -> Vec { s.fix_state(); Ok(json!({"items": items.len(), "bins": bins.len()})) }), - cmd!("project.moveToBin", "Move to Bin", [], None, r#"{"items":[id]?,"bin":binId|null}"#, always, |s, p| { + cmd!("project.moveToBin", "Move to Bin", [], None, r#"{"items":[id|binId]?,"bin":binId|null}"#, always, |s, p| { let items: Vec = match p.get("items").and_then(Value::as_array) { Some(a) => a.iter().filter_map(|v| v.as_u64().map(ItemId)).collect(), None => s.state.project_selection.clone(), diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index e8a988568..b8ff0759f 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -1380,20 +1380,35 @@ impl Project { id } - /// Move items into a bin (the root when `bin` is None). Returns how many moved; unknown items - /// are skipped. Fails (returns None) when the bin does not exist. + /// Move items, and bins named by id, into a bin (the root when `bin` is None). A bin goes with + /// everything in it. Returns how many moved; unknown ids are skipped, and so is a bin that + /// would land in itself or in one of its own sub-bins. Fails (returns None) when the target + /// bin does not exist. pub fn move_to_bin(&mut self, items: &[ItemId], bin: Option) -> Option { let target = bin.unwrap_or(self.root.id); self.root.find_bin(target)?; let mut moved = 0; for &id in items { - if !self.items.contains_key(&id) || !self.root.remove_item(id) { - continue; - } - if let Some(b) = self.root.find_bin_mut(target) { - b.children.push(BinEntry::Item(id)); - moved += 1; + let entry = if self.items.contains_key(&id) { + if !self.root.remove_item(id) { + continue; + } + BinEntry::Item(id) + } else { + let b = BinId(id.0); + // never the root, nor a bin that holds the target (or is it) + if b == self.root.id || self.root.find_bin(b).is_none_or(|x| x.find_bin(target).is_some()) { + continue; + } + let Some(sub) = self.root.remove_bin(b) else { continue }; + BinEntry::Bin(sub) + }; + match self.root.find_bin_mut(target) { + Some(t) => t.children.push(entry), + // cannot happen (the target is outside what was taken out), but never lose it + None => self.root.children.push(entry), } + moved += 1; } Some(moved) } @@ -1994,6 +2009,29 @@ mod tests { assert_eq!(p.root.parent_of(i), None); } + #[test] + fn a_bin_moves_into_another_bin_with_its_contents() { + let mut p = Project::new("x"); + let outer = p.add_bin("Footage", None); + let inner = p.add_bin("B-Roll", None); + let deep = p.add_bin("Drone", Some(inner)); + let i = p.add_item("a", Label::Iris, ItemKind::AdjustmentLayer { width: 10, height: 10, rate: FrameRate::FPS_24, duration: Tick(1) }, Some(deep)); + assert_eq!(p.move_to_bin(&[ItemId(inner.0)], Some(outer)), Some(1)); + let o = p.root.find_bin(outer).unwrap(); + assert!(o.children.iter().any(|c| matches!(c, BinEntry::Bin(b) if b.id == inner))); + assert!(!p.root.children.iter().any(|c| matches!(c, BinEntry::Bin(b) if b.id == inner))); + assert_eq!(p.root.parent_of(i), Some(deep)); + assert!(o.find_bin(deep).is_some()); + // never into itself or its own sub-bin, and the root never moves + assert_eq!(p.move_to_bin(&[ItemId(outer.0)], Some(outer)), Some(0)); + assert_eq!(p.move_to_bin(&[ItemId(outer.0)], Some(deep)), Some(0)); + assert_eq!(p.move_to_bin(&[ItemId(p.root.id.0)], Some(outer)), Some(0)); + assert!(p.root.find_bin(outer).unwrap().find_bin(deep).is_some()); + // and back out to the root + assert_eq!(p.move_to_bin(&[ItemId(inner.0)], None), Some(1)); + assert!(p.root.children.iter().any(|c| matches!(c, BinEntry::Bin(b) if b.id == inner))); + } + #[test] fn project_clone_shares_sequences_until_mutated() { fn sequence_arc(p: &Project, id: ItemId) -> &std::sync::Arc { diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index bcb386fbb..5ba2abf85 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -111,6 +111,8 @@ pub mod source_range; #[derive(Clone, Debug)] enum DragPayload { Item(ItemId), + /// A bin (by id) dragged in the Project panel. + Bin(u64), Source(source_drag::SourceDrag), Effect(String), /// A graphics template (id, name) from Essential Graphics ▸ Browse. @@ -133,6 +135,15 @@ pub fn dragged_source(ui: &egui::Ui) -> Option { pub fn start_drag_item(ui: &egui::Ui, item: ItemId) { ui.ctx().data_mut(|d| d.insert_temp(payload_id(), Some(DragPayloadBox(DragPayload::Item(item))))); } +pub fn start_drag_bin(ui: &egui::Ui, bin: u64) { + ui.ctx().data_mut(|d| d.insert_temp(payload_id(), Some(DragPayloadBox(DragPayload::Bin(bin))))); +} +pub fn dragged_bin(ui: &egui::Ui) -> Option { + match payload(ui) { + Some(DragPayload::Bin(b)) => Some(b), + _ => None, + } +} pub fn start_drag_template(ui: &egui::Ui, id: &str, name: &str) { ui.ctx().data_mut(|d| d.insert_temp(payload_id(), Some(DragPayloadBox(DragPayload::Template(id.to_string(), name.to_string()))))); } @@ -175,6 +186,7 @@ pub fn drag_ghost(app: &FilmcraftApp, ui: &egui::Ui) { if let Some(p) = ctx.pointer_hover_pos() { let label = match &pl { DragPayload::Item(i) => app.session.project.item(*i).map(|x| x.name.clone()).unwrap_or_default(), + DragPayload::Bin(b) => app.session.project.root.find_bin(filmcraft_project::BinId(*b)).map(|x| x.name.clone()).unwrap_or_default(), DragPayload::Source(s) => { let name = app.session.project.item(s.item).map(|i| i.name.as_str()).unwrap_or("Source"); let mode = match (s.video, s.audio) { diff --git a/crates/ui-egui/src/panels/project_views.rs b/crates/ui-egui/src/panels/project_views.rs index 46d800234..76cafdbee 100644 --- a/crates/ui-egui/src/panels/project_views.rs +++ b/crates/ui-egui/src/panels/project_views.rs @@ -5,7 +5,7 @@ use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use filmcraft_engine::project_panel::{self as pp, Card, SortSpec, ViewMode}; -use filmcraft_project::{Bin, BinEntry, ItemId, ItemKind}; +use filmcraft_project::{Bin, BinEntry, BinId, ItemId, ItemKind}; use filmcraft_time::Tick; use serde_json::json; @@ -331,21 +331,46 @@ pub fn clear_params(selection: &[filmcraft_project::ItemId], selected_bin: Optio } /// Drop project items dragged onto a bin: move them (the selection when the dragged item is in it). +/// A dragged bin nests in it, unless that would put the bin inside itself. fn accept_bin_drop(app: &FilmcraftApp, ui: &egui::Ui, r: Rect, bin: u64, actions: &mut Actions) -> bool { - let Some(item) = crate::panels::dragged_project_item(ui) else { return false }; + let items: Vec = if let Some(item) = crate::panels::dragged_project_item(ui) { + let sel = &app.session.state.project_selection; + if sel.contains(&item) { sel.iter().map(|i| i.0).collect() } else { vec![item.0] } + } else if let Some(dragged) = crate::panels::dragged_bin(ui) { + let root = &app.session.project.root; + if root.find_bin(BinId(dragged)).is_none_or(|d| d.find_bin(BinId(bin)).is_some()) { + return false; + } + vec![dragged] + } else { + return false; + }; if !ui.rect_contains_pointer(r) { return false; } ui.painter().rect_stroke(r, 3.0, Stroke::new(1.5, app.tokens.accent), StrokeKind::Inside); if ui.input(|i| i.pointer.any_released()) { - let sel = &app.session.state.project_selection; - let items: Vec = if sel.contains(&item) { sel.iter().map(|i| i.0).collect() } else { vec![item.0] }; actions.push(("project.moveToBin".into(), json!({"items": items, "bin": bin}))); crate::panels::clear_drag(ui); } true } +/// A bin dropped on empty space moves into the bin the view shows (out of the bin it was nested in). +fn accept_view_drop(app: &FilmcraftApp, ui: &egui::Ui, r: Rect, v: &View, actions: &mut Actions) { + let Some(dragged) = crate::panels::dragged_bin(ui) else { return }; + let root = &app.session.project.root; + // already shown here, or the view is inside the dragged bin + let here = root.find_bin(v.bin).is_some_and(|b| b.children.iter().any(|c| matches!(c, BinEntry::Bin(x) if x.id.0 == dragged))); + let inside = root.find_bin(BinId(dragged)).is_none_or(|d| d.find_bin(v.bin).is_some()); + if here || inside || !ui.rect_contains_pointer(r) || !ui.input(|i| i.pointer.any_released()) { + return; + } + let bin = (v.bin != root.id).then_some(v.bin.0); + actions.push(("project.moveToBin".into(), json!({"items": [dragged], "bin": bin}))); + crate::panels::clear_drag(ui); +} + /// The inline rename field at `r`; true while it is shown. fn rename_field(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, item: Option, bin: Option, pre: &str) -> bool { let Some(rn) = app.ui.project_panel.rename.clone() else { return false }; @@ -521,6 +546,7 @@ fn empty_space(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &View, bg: &egui::R ui.allocate_rect(r, Sense::hover()); app.auto.add(&format!("{}.empty", v.prefix), r.intersect(ui.clip_rect()), "Empty area"); marquee(app, ui, v, bg, ui.max_rect().min, actions); + accept_view_drop(app, ui, r, v, actions); if bg.clicked() { app.ui.project_panel.selected_bin = None; actions.push(("project.select".into(), json!({"items": []}))); @@ -614,7 +640,7 @@ fn list_bin(app: &mut FilmcraftApp, ui: &mut egui::Ui, bin: &Bin, depth: usize, for bid in bins { let Some(b) = app.session.project.root.find_bin(bid).cloned() else { continue }; let open = app.ui.expanded_bins.contains(&b.id.0) || !filter.is_empty(); - let (r, resp) = ui.allocate_exact_size(vec2(lc.width, lc.row_h), Sense::click()); + let (r, resp) = ui.allocate_exact_size(vec2(lc.width, lc.row_h), Sense::click_and_drag()); let selected = app.ui.project_panel.selected_bin == Some(b.id.0); if selected { ui.painter().rect_filled(r, 0.0, t.row_selected); @@ -633,6 +659,9 @@ fn list_bin(app: &mut FilmcraftApp, ui: &mut egui::Ui, bin: &Bin, depth: usize, app.auto.add(&format!("{}.bin.{}", v.prefix, b.id.0), r.intersect(ui.clip_rect()), &b.name); app.auto.add(&format!("{}.bin.{}.toggle", v.prefix, b.id.0), tri, "Expand"); accept_bin_drop(app, ui, r, b.id.0, actions); + if resp.drag_started() { + crate::panels::start_drag_bin(ui, b.id.0); + } if resp.clicked() { let on_tri = resp.interact_pointer_pos().is_some_and(|p| p.x < x + 12.0); if on_tri || app.ui.project_panel.selected_bin == Some(b.id.0) && !resp.double_clicked() { @@ -844,7 +873,7 @@ fn card(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, id: ItemId, pre: &st fn bin_card(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, b: &Bin, v: &View, actions: &mut Actions) { let t = app.tokens; let th = Rect::from_min_size(r.min, vec2(r.width(), r.width() * 9.0 / 16.0)); - let resp = ui.interact(r.expand(2.0), egui::Id::new((&v.prefix, "bincard", b.id.0)), Sense::click()); + let resp = ui.interact(r.expand(2.0), egui::Id::new((&v.prefix, "bincard", b.id.0)), Sense::click_and_drag()); let selected = app.ui.project_panel.selected_bin == Some(b.id.0); let bg = Rect::from_min_max(th.min - vec2(6.0, 6.0), pos2(th.max.x + 6.0, r.max.y + 2.0)); ui.painter().rect_filled(bg, 4.0, if selected { t.tl_header_bg } else { t.panel_bg }); @@ -863,6 +892,9 @@ fn bin_card(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, b: &Bin, v: &Vie } app.auto.add(&format!("{}.bin.{}", v.prefix, b.id.0), th, &b.name); accept_bin_drop(app, ui, th, b.id.0, actions); + if resp.drag_started() { + crate::panels::start_drag_bin(ui, b.id.0); + } if resp.clicked() { app.ui.project_panel.selected_bin = Some(b.id.0); actions.push(("project.select".into(), json!({"items": []}))); diff --git a/crates/ui-egui/tests/project_panel_ui.rs b/crates/ui-egui/tests/project_panel_ui.rs index 3fac337e5..77ff7f1c4 100644 --- a/crates/ui-egui/tests/project_panel_ui.rs +++ b/crates/ui-egui/tests/project_panel_ui.rs @@ -485,6 +485,41 @@ fn select_all_takes_only_the_shown_bin() { assert_eq!(sorted(&d.app().session.state.project_selection), footage); } +#[test] +fn a_dragged_bin_nests_in_another_and_back_out() { + // #456: a bin stayed wherever it was created; dragging it onto another bin did nothing + let mut d = project_driver(); + d.exec("project.view.set", json!({"view": "list"})); + let outer = d.exec("file.newBin", json!({"name": "Outer"}))["bin"].as_u64().unwrap(); + let inner = d.exec("file.newBin", json!({"name": "Inner"}))["bin"].as_u64().unwrap(); + d.app().ui.expanded_bins.clear(); + d.frames(3); + let parent = + |d: &mut Driver, b: u64| filmcraft_ui_egui::panels::project::parent_bin(&d.app().session.project.root, filmcraft_project::BinId(b)).map(|p| p.0); + let root = d.app().session.project.root.id.0; + let drag = |d: &mut Driver, from: [f32; 4], to: (f32, f32)| { + let start = (from[0] + 80.0, from[1] + from[3] / 2.0); + d.ok("ui.drag", json!({"from": {"x": start.0, "y": start.1}, "to": {"x": to.0, "y": to.1}, "steps": 12})); + d.frames(3); + }; + // onto the Outer row: Inner nests in it + let (from, to) = (d.rect(&format!("project.bin.{inner}")), d.rect(&format!("project.bin.{outer}"))); + drag(&mut d, from, (to[0] + 80.0, to[1] + to[3] / 2.0)); + assert_eq!(parent(&mut d, inner), Some(outer)); + // a bin never goes into itself + d.app().ui.expanded_bins.push(outer); + d.frames(3); + let (from, to) = (d.rect(&format!("project.bin.{outer}")), d.rect(&format!("project.bin.{inner}"))); + drag(&mut d, from, (to[0] + 80.0, to[1] + to[3] / 2.0)); + assert_eq!(parent(&mut d, outer), Some(root)); + assert_eq!(parent(&mut d, inner), Some(outer)); + // onto empty space: back to the bin the view shows + let from = d.rect(&format!("project.bin.{inner}")); + let to = centre(d.rect("project.empty")); + drag(&mut d, from, to); + assert_eq!(parent(&mut d, inner), Some(root)); +} + #[test] fn footer_buttons_are_wired() { let mut d = project_driver(); From 78a3fea2229a762aa0d70dd0d5b82ddc90c38f64 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 21:21:26 +0200 Subject: [PATCH 25/33] #227: Lumetri presets can be dragged onto a Timeline clip Problem: in the Effects panel, the Lumetri Presets rows and the wide-panel thumbnail grid only sensed clicks. They applied through double-click or the Apply menu, never through a drag, unlike every other effect and preset. Fix: rows and grid cells sense drags and start an effect payload `lumetri:`. The Timeline's effect drop applies it to the clip under the pointer with `lumetri.applyPreset` (one undo step). The drag ghost shows the preset's name. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/effects.rs | 13 ++++++++++--- crates/ui-egui/src/panels/mod.rs | 14 +++++++++++--- crates/ui-egui/src/panels/timeline.rs | 2 ++ crates/ui-egui/tests/color_ui.rs | 23 +++++++++++++++++++++++ 4 files changed, 46 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/panels/effects.rs b/crates/ui-egui/src/panels/effects.rs index 76f4f8c28..df3e7d939 100644 --- a/crates/ui-egui/src/panels/effects.rs +++ b/crates/ui-egui/src/panels/effects.rs @@ -3,7 +3,8 @@ //! //! Lumetri Presets: each preset row has a thumbnail (our Lumetri on the procedural preview //! picture, `filmcraft_render::lumetri_presets`); double-click (or right-click ▸ Apply to Selected -//! Clips, `effects.presetMenu.apply`) applies it (`lumetri.applyPreset`). +//! Clips, `effects.presetMenu.apply`) applies it (`lumetri.applyPreset`), and dragging it onto a +//! Timeline clip applies it to that clip (rows and grid cells alike). //! When the panel is wide (maximized), clicking a Lumetri Presets folder shows its presets as a //! thumbnail grid to the right of the tree. Automation ids: `effects.lumetriPreset.`, //! `effects.presetGrid` (the grid area), `effects.presetGrid.`. @@ -232,7 +233,7 @@ fn lumetri_rows(app: &mut FilmcraftApp, ui: &mut egui::Ui, filter: &str) -> Opti continue; } for p in items { - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click()); + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click_and_drag()); if resp.hovered() { ui.painter().rect_filled(r, 0.0, t.hover); } @@ -242,6 +243,9 @@ fn lumetri_rows(app: &mut FilmcraftApp, ui: &mut egui::Ui, filter: &str) -> Opti ui.painter().text(pos2(tr.max.x + 8.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::t(p.name), Tokens::ui(12.0), t.text); app.auto.add(&format!("effects.lumetriPreset.{}", p.name), r, p.name); let resp = resp.on_hover_text(crate::i18n::t(p.description)); + if resp.drag_started() { + crate::panels::start_drag_lumetri_preset(ui, p.name); + } if apply_menu(app, &resp) { apply = Some(p.name.to_string()); } @@ -273,12 +277,15 @@ fn preset_grid(app: &mut FilmcraftApp, ui: &mut egui::Ui, area: Rect) -> Option< } let pic = Rect::from_min_size(cell.min, vec2(cw, ch)); let tex = preset_texture(app, ui.ctx(), p, 160); - let resp = ui.interact(cell, egui::Id::new(("lumetri-grid", p.name)), Sense::click()).on_hover_text(crate::i18n::t(p.description)); + let resp = ui.interact(cell, egui::Id::new(("lumetri-grid", p.name)), Sense::click_and_drag()).on_hover_text(crate::i18n::t(p.description)); ui.painter().rect_filled(cell, 3.0, if resp.hovered() { t.hover } else { t.field_bg }); ui.painter().image(tex, pic.shrink(2.0), Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); let label = ui.painter().layout(crate::i18n::t(p.name).to_string(), Tokens::ui(11.0), t.text, cw - 6.0); ui.painter().galley(pos2(cell.min.x + 4.0, pic.max.y + 2.0), label, t.text); app.auto.add(&format!("effects.presetGrid.{}", p.name), cell, p.name); + if resp.drag_started() { + crate::panels::start_drag_lumetri_preset(ui, p.name); + } if apply_menu(app, &resp) { apply = Some(p.name.to_string()); } diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index bcb386fbb..95c9442e2 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -145,6 +145,13 @@ pub fn dragged_template(ui: &egui::Ui) -> Option { pub fn start_drag_effect(ui: &egui::Ui, id: &str) { ui.ctx().data_mut(|d| d.insert_temp(payload_id(), Some(DragPayloadBox(DragPayload::Effect(id.to_string()))))); } +/// Prefix of a dragged effect payload that names a Lumetri preset rather than an effect id. +pub const LUMETRI_PRESET_PREFIX: &str = "lumetri:"; +/// A Lumetri preset (Effects ▸ Lumetri Presets) dragged onto a clip: an effect payload +/// `lumetri:` the Timeline applies with `lumetri.applyPreset`. +pub fn start_drag_lumetri_preset(ui: &egui::Ui, name: &str) { + start_drag_effect(ui, &format!("{LUMETRI_PRESET_PREFIX}{name}")); +} #[derive(Clone, Debug)] struct DragPayloadBox(DragPayload); @@ -185,9 +192,10 @@ pub fn drag_ghost(app: &FilmcraftApp, ui: &egui::Ui) { format!("{name} · {mode} · {:.2} s", s.range.duration.seconds()) } DragPayload::Template(_, name) => name.clone(), - DragPayload::Effect(e) => match e.strip_prefix("preset:") { - Some(name) => name.to_string(), - None => filmcraft_project::find_effect(e).map(|d| d.name.to_string()).unwrap_or_default(), + DragPayload::Effect(e) => match (e.strip_prefix("preset:"), e.strip_prefix(LUMETRI_PRESET_PREFIX)) { + (Some(name), _) => name.to_string(), + (_, Some(name)) => crate::i18n::t(name).to_string(), + _ => filmcraft_project::find_effect(e).map(|d| d.name.to_string()).unwrap_or_default(), }, }; let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("drag-ghost"))); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 17a5eef96..84b863973 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2471,6 +2471,8 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & if ctx.input(|i| i.pointer.any_released()) { let r = if let Some(name) = effect.strip_prefix("preset:") { app.session.execute("presets.apply", json!({"preset": name, "clips": [clip.0]})) + } else if let Some(name) = effect.strip_prefix(crate::panels::LUMETRI_PRESET_PREFIX) { + app.session.execute("lumetri.applyPreset", json!({"name": name, "clips": [clip.0]})) } else if transition_kind.is_some() { app.session.execute("effects.apply", params) } else { diff --git a/crates/ui-egui/tests/color_ui.rs b/crates/ui-egui/tests/color_ui.rs index 956e7f211..1ea047c84 100644 --- a/crates/ui-egui/tests/color_ui.rs +++ b/crates/ui-egui/tests/color_ui.rs @@ -324,3 +324,26 @@ fn lumetri_presets_in_the_effects_panel() { let e = lumetri(&mut d, clip); assert_eq!(e["params"]["temperature"]["value"], json!("Float(35.0)"), "{e}"); } + +#[test] +fn lumetri_presets_drag_onto_a_timeline_clip() { + let mut d = Driver::demo(); + d.ok("ui.panel.show", json!({"panel": "Effects"})); + d.frames(3); + // the first clip is selected; the preset is dropped on the second, which gets it alone + let selected = select_first_clip(&mut d); + let seq = d.exec("sequence.inspect", json!({})); + let target = seq["video"][0]["items"][1]["clip"].as_u64().unwrap_or_else(|| panic!("no second clip: {seq}")); + d.click("effects.folder.Lumetri Presets"); + d.click("effects.folder.Lumetri Presets/Monochrome"); + d.frames(2); + assert!(d.element(&format!("timeline.clip.{target}")).is_some(), "target clip not on screen"); + d.ok("ui.drag", json!({"from": {"id": "effects.lumetriPreset.Neutral Mono"}, "to": {"id": format!("timeline.clip.{target}")}, "steps": 8})); + d.frames(3); + let e = lumetri(&mut d, target); + assert_eq!(e["params"]["saturation"]["value"], json!("Float(0.0)"), "dropped preset on the target clip: {e}"); + assert_eq!(lumetri(&mut d, selected), Value::Null, "the selected clip it was not dropped on is untouched"); + // one undo step removes it again + d.exec("edit.undo", json!({})); + assert_eq!(lumetri(&mut d, target), Value::Null); +} From cca7d5ef0ea32ce269590356feda3e28e94e7518 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 21:23:42 +0200 Subject: [PATCH 26/33] #483: media dropped past the last track goes on a new track Problem: dropping a project item in the empty space above the top video track or below the last audio track did nothing: the Timeline only accepted drops on an existing track row, and timeline.place could only target existing tracks. Fix: timeline.place accepts "new" as `track` / `audioTrack`. It adds a track after the last one of its kind for each stream the item carries, then places the clip there, as one undo step (the new tracks are removed again if the placement fails). The Timeline shows the drop ghost in the empty space and places on a new video track (above) or audio track (below); the other stream goes on the track of the same number, or on a new one when that track does not exist. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands.rs | 5 +- crates/engine/src/sequence_tools.rs | 48 +++++++++++++ crates/engine/src/sequence_tools_tests.rs | 32 +++++++++ crates/ui-egui/src/panels/timeline.rs | 84 +++++++++++++++++++++-- 4 files changed, 161 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 33e2ee85f..d49361f05 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -2110,9 +2110,12 @@ fn build() -> Vec { "Place Clip", [], None, - r#"{"item":id,"track":"V1"|id|"A1" (sound only)?,"audioTrack":"A1"|id?,"time":ticks|"frame":i64|"seconds":f64,"insert":bool,"sourceIn":ticks?,"duration":ticks?,"video":bool=true,"audio":bool=true}"#, + r#"{"item":id,"track":"V1"|id|"A1" (sound only)|"new" (a video track after the last)?,"audioTrack":"A1"|id|"new"?,"time":ticks|"frame":i64|"seconds":f64,"insert":bool,"sourceIn":ticks?,"duration":ticks?,"video":bool=true,"audio":bool=true}"#, has_seq, |s, p| { + if crate::sequence_tools::wants_new_track(p) { + return crate::sequence_tools::place_on_new_tracks(s, p); + } let stream_flag = |key: &str| match p.get(key) { None => Ok(true), Some(Value::Bool(v)) => Ok(*v), diff --git a/crates/engine/src/sequence_tools.rs b/crates/engine/src/sequence_tools.rs index a0027913d..dc6c57953 100644 --- a/crates/engine/src/sequence_tools.rs +++ b/crates/engine/src/sequence_tools.rs @@ -723,6 +723,54 @@ pub(crate) fn add_tracks(s: &mut Session, p: &Value) -> Result { Ok(json!({"video": video, "audio": audio, "submix": submix})) } +/// `timeline.place` names `"new"` as its `track` or `audioTrack`. +pub(crate) fn wants_new_track(p: &Value) -> bool { + ["track", "audioTrack"].iter().any(|k| p.get(*k).and_then(Value::as_str).is_some_and(|v| v.eq_ignore_ascii_case("new"))) +} + +/// `timeline.place` with `"track":"new"` and/or `"audioTrack":"new"` (#483: media dropped in the +/// empty space above the video tracks or below the audio tracks): a track is added after the +/// last one of its kind for each stream the item carries (and the call enables), then the clip +/// goes on it. One undo step; when the placement fails, the new tracks go away again. +pub(crate) fn place_on_new_tracks(s: &mut Session, p: &Value) -> Result { + let cmd = "timeline.place"; + let item = crate::commands::item_p(p, "item").ok_or_else(|| bad(cmd, "need `item`"))?; + let pi = s.project.item(item).ok_or_else(|| bad(cmd, "no such item"))?; + let is_new = |k: &str| p.get(k).and_then(Value::as_str).is_some_and(|v| v.eq_ignore_ascii_case("new")); + let enabled = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(true); + let new_v = is_new("track") && pi.has_video() && enabled("video"); + let new_a = is_new("audioTrack") && pi.has_audio() && enabled("audio"); + if !new_v && !new_a { + return Err(bad(cmd, "no new track to add: the item has no picture or sound for it")); + } + let added = add_tracks(s, &json!({"video": u64::from(new_v), "audio": u64::from(new_a)}))?; + let mark = s.history.undo.len(); + let mut q = p.clone(); + for (key, kind) in [("track", "video"), ("audioTrack", "audio")] { + if is_new(key) { + q[key] = added.get(kind).and_then(|ids| ids.get(0)).cloned().unwrap_or(Value::Null); + } + } + let place = crate::commands::find(cmd).ok_or_else(|| EngineError::UnknownCommand(cmd.into()))?.run; + match place(s, &q) { + Ok(v) => { + // the new tracks and the clip are one undo step + if mark >= 1 + && s.history.undo.len() == mark + 1 + && let (Some((label, _)), Some((_, before))) = (s.history.undo.pop(), s.history.undo.pop()) + { + s.history.undo.push((label, before)); + } + Ok(v) + } + Err(e) => { + s.undo(); + s.history.redo.pop(); + Err(e) + } + } +} + fn delete_tracks(s: &mut Session, p: &Value) -> Result { let seq_id = s.state.active_sequence.ok_or(EngineError::NoSequence)?; let q = s.active_sequence().ok_or(EngineError::NoSequence)?; diff --git a/crates/engine/src/sequence_tools_tests.rs b/crates/engine/src/sequence_tools_tests.rs index 9e164ce2c..d275f9b3e 100644 --- a/crates/engine/src/sequence_tools_tests.rs +++ b/crates/engine/src/sequence_tools_tests.rs @@ -575,3 +575,35 @@ fn go_to_in_out_without_marks_go_to_the_sequence_ends() { s.execute("markers.goToIn", json!({})).unwrap(); assert_eq!(s.playhead(), s.active_sequence().unwrap().mark_in.unwrap()); } + +/// #483: media dropped in the empty space past the last track goes on a new track (with +/// `"new"`), one undo step; a placement that fails leaves no new track behind. +#[test] +fn place_on_new_tracks_adds_them_in_one_undo_step() { + let (mut s, item) = fresh(); + let before = s.active_sequence().unwrap().clone(); + let (nv, na) = (before.video_tracks.len(), before.audio_tracks.len()); + let undo = s.history.undo.len(); + let r = s.execute("timeline.place", json!({"item": item, "track": "new", "audioTrack": "new", "time": 0})).unwrap(); + let q = s.active_sequence().unwrap(); + assert_eq!((q.video_tracks.len(), q.audio_tracks.len()), (nv + 1, na + 1)); + let clips: Vec = r["clips"].as_array().unwrap().iter().map(|c| ClipId(c.as_u64().unwrap())).collect(); + assert_eq!(clips.len(), 2); + assert_eq!(q.video_tracks[nv].items.iter().map(|i| i.id).collect::>(), [clips[0]], "the picture is on the new top video track"); + assert_eq!(q.audio_tracks[na].items.iter().map(|i| i.id).collect::>(), [clips[1]], "the sound is on the new last audio track"); + assert_eq!(s.history.undo.len(), undo + 1, "one undo step"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(*s.active_sequence().unwrap(), before); + + // only the picture gets a new track; the sound follows the given track + s.execute("timeline.place", json!({"item": item, "track": "new", "audioTrack": "A1", "time": 0})).unwrap(); + let q = s.active_sequence().unwrap(); + assert_eq!((q.video_tracks.len(), q.audio_tracks.len()), (nv + 1, na)); + assert_eq!((q.video_tracks[nv].items.len(), q.audio_tracks[0].items.len()), (1, 1)); + s.execute("edit.undo", json!({})).unwrap(); + + // a placement that fails after the tracks were added: nothing changes + assert!(s.execute("timeline.place", json!({"item": item, "track": "new", "audioTrack": "A99", "time": 0})).is_err()); + assert_eq!(*s.active_sequence().unwrap(), before); + assert_eq!(s.history.undo.len(), undo); +} diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 17a5eef96..2b1e8fef2 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -189,6 +189,26 @@ impl Layout { } } +/// #483: where a drop at `y` adds a track: the empty space above the top video track (a video +/// track) or below the last audio track (an audio track), never over a track, the caption tracks +/// or the Mix row. Gives the kind and the band the new track would take (`vh` / `ah` high). +fn new_track_at(seq: &Sequence, layout: &Layout, y: f32, vh: f32, ah: f32) -> Option<(TrackKind, Rect)> { + if layout.row_at(y).is_some() { + return None; + } + let cap_n = seq.caption_tracks.len(); + let tracks_top = layout.content.min.y + if cap_n > 0 { cap_n as f32 * super::timeline_captions::ROW_H + DIVIDER_H } else { 0.0 }; + let band = |y0: f32, y1: f32| Rect::from_min_max(pos2(layout.content.min.x, y0), pos2(layout.content.max.x, y1)); + if y < layout.split_y { + let top = layout.rows.iter().filter(|r| r.kind == TrackKind::Video).map(|r| r.rect.min.y).fold(layout.split_y, f32::min); + (y >= tracks_top && y < top).then(|| (TrackKind::Video, band((top - vh).max(tracks_top), top))) + } else { + let bottom = layout.rows.iter().filter(|r| r.kind == TrackKind::Audio).map(|r| r.rect.max.y).fold(layout.split_y + DIVIDER_H, f32::max); + let mix = layout.content.max.y - MASTER_H; + (y >= bottom && y < mix).then(|| (TrackKind::Audio, band(bottom, (bottom + ah).min(mix)))) + } +} + /// How far the Timeline reaches, in seconds: ten minutes past the end of the sequence. Premiere /// Pro's Timeline does the same (on a 6 s sequence its view stops with 00:10:06 at the right /// edge). The view cannot be scrolled past it and the scroll bar spans it. @@ -2380,21 +2400,34 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & let dur = source .map(|s| s.range.duration) .unwrap_or_else(|| app.session.project.item(item).map(|i| i.duration()).filter(|d| d.0 > 0 && !is_still).unwrap_or(still)); - if let Some(row) = &row { - let r = Rect::from_min_max(pos2(layout.x_of(t), row.rect.min.y + 1.0), pos2(layout.x_of(t + dur), row.rect.max.y - 1.0)); + // #483: in the empty space above the top video track or below the last audio track, a new track + let new_track = if row.is_some() { None } else { new_track_at(seq, layout, p.y, app.ui.timeline.video_track_h, app.ui.timeline.audio_track_h) }.filter( + |(kind, _)| { + let carries = app.session.project.item(item).is_some_and(|i| if *kind == TrackKind::Video { i.has_video() } else { i.has_audio() }); + carries && source.is_none_or(|s| if *kind == TrackKind::Video { s.video } else { s.audio }) + }, + ); + if let Some(band) = row.as_ref().map(|r| r.rect).or(new_track.map(|(_, band)| band)) { + let r = Rect::from_min_max(pos2(layout.x_of(t), band.min.y + 1.0), pos2(layout.x_of(t + dur), band.max.y - 1.0)); ui.painter().rect_filled(r, 3.0, Color32::from_white_alpha(40)); ui.painter().rect_stroke(r, 3.0, Stroke::new(1.5, Color32::WHITE), StrokeKind::Inside); if mods.command { ui.painter().text(r.left_top() + vec2(4.0, -2.0), Align2::LEFT_BOTTOM, tl!("Insert"), Tokens::ui(10.0), Color32::WHITE); } } + let target = match (&row, new_track) { + (Some(row), _) => Some(match row.kind { + TrackKind::Video => (json!(row.track.0), json!(seq.audio_tracks.get(row.index).or(seq.audio_tracks.first()).map(|t| t.id.0))), + TrackKind::Audio => (json!(seq.video_tracks.get(row.index).or(seq.video_tracks.first()).map(|t| t.id.0)), json!(row.track.0)), + }), + // the other stream goes on the track of the same number, or on a new one as well + (None, Some((TrackKind::Video, _))) => Some((json!("new"), seq.audio_tracks.get(seq.video_tracks.len()).map_or(json!("new"), |t| json!(t.id.0)))), + (None, Some((TrackKind::Audio, _))) => Some((seq.video_tracks.get(seq.audio_tracks.len()).map_or(json!("new"), |t| json!(t.id.0)), json!("new"))), + (None, None) => None, + }; if ctx.input(|i| i.pointer.any_released()) - && let Some(row) = row + && let Some((vt, at)) = target { - let (vt, at) = match row.kind { - TrackKind::Video => (Some(row.track.0), seq.audio_tracks.get(row.index).or(seq.audio_tracks.first()).map(|t| t.id.0)), - TrackKind::Audio => (seq.video_tracks.get(row.index).or(seq.video_tracks.first()).map(|t| t.id.0), Some(row.track.0)), - }; let mut params = json!({"item": item.0, "track": vt, "audioTrack": at, "time": t.0, "insert": mods.command}); if let Some(source) = source { params["sourceIn"] = json!(source.range.start.0); @@ -2648,3 +2681,40 @@ mod thumbnail_tiles_tests { assert!(thumbnail_tiles(ThumbnailMode::Continuous, 0.0, 0.0, 80.0, (0.0, 800.0), dur, FRAME).is_empty()); } } + +#[cfg(test)] +mod new_track_tests { + use super::*; + use filmcraft_project::{Project, SequenceSettings}; + + /// #483: a drop above the top video track adds a video track, one below the last audio track an + /// audio track; over a track, between the areas or on the Mix row it adds none. + #[test] + fn empty_space_past_the_tracks_adds_a_track() { + let mut p = Project::new("t"); + let id = p.new_sequence("s", SequenceSettings::default(), 1, 1, None); + let seq = p.sequence(id).cloned().expect("sequence"); + // video area 0..200 (V1 at 150..200), divider, audio area 205..466 (A1 at 205..255), Mix 466..500 + let row = |kind, track: &filmcraft_project::Track, y0: f32| Row { + track: track.id, + kind, + index: 0, + rect: Rect::from_min_max(pos2(0.0, y0), pos2(800.0, y0 + 50.0)), + lane: false, + }; + let layout = Layout { + content: Rect::from_min_max(pos2(0.0, 0.0), pos2(800.0, 500.0)), + ruler: Rect::from_min_max(pos2(0.0, -20.0), pos2(800.0, 0.0)), + rows: vec![row(TrackKind::Video, &seq.video_tracks[0], 150.0), row(TrackKind::Audio, &seq.audio_tracks[0], 205.0)], + pps: 100.0, + scroll: 0.0, + split_y: 200.0, + }; + let at = |y| new_track_at(&seq, &layout, y, 50.0, 50.0); + assert_eq!(at(40.0), Some((TrackKind::Video, Rect::from_min_max(pos2(0.0, 100.0), pos2(800.0, 150.0))))); + assert_eq!(at(300.0), Some((TrackKind::Audio, Rect::from_min_max(pos2(0.0, 255.0), pos2(800.0, 305.0))))); + for y in [160.0, 202.0, 230.0, 480.0] { + assert_eq!(at(y), None, "y = {y}"); + } + } +} From 334bccde88c5d428931b987a4622b53f1db1e113 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 15:23:52 -0400 Subject: [PATCH 27/33] Revert "Merge pull request #674 from vbasky/fix/542-limiter-lookahead" This reverts commit 62a0eea51958727812c8dd2d8a14f3e81a49f860, reversing changes made to 23ac3447985fccf003be076d32b5aaccb2106999. --- crates/audio-dsp/src/effects/dynamics.rs | 44 +++--------------------- crates/render/src/audio_fx.rs | 3 +- crates/render/src/tests.rs | 10 ------ 3 files changed, 5 insertions(+), 52 deletions(-) diff --git a/crates/audio-dsp/src/effects/dynamics.rs b/crates/audio-dsp/src/effects/dynamics.rs index 40dec64cb..5807d1ba8 100644 --- a/crates/audio-dsp/src/effects/dynamics.rs +++ b/crates/audio-dsp/src/effects/dynamics.rs @@ -263,8 +263,6 @@ pub struct Limiter { true_peak: bool, /// Look-ahead (samples) and detector alignment delay. look: usize, - /// Delay-line capacity (samples of look-ahead) allocated at construction for 0…30 ms. - max_look: usize, det_delay: usize, bank: PolyphaseBank, interp: Vec, @@ -287,21 +285,17 @@ impl Limiter { ParamSpec::new("input_gain", "Input Boost", 0.0, 24.0, 0.0, Unit::Decibels), ParamSpec::new("ceiling", "Ceiling", -24.0, 0.0, -1.0, Unit::Decibels), ParamSpec::log("release", "Release", 1.0, 1000.0, 60.0, Unit::Milliseconds), - ParamSpec::new("lookahead", "Look-Ahead", 0.0, 30.0, 5.0, Unit::Milliseconds), ParamSpec::toggle("true_peak", "True Peak", true), ]; - /// Default look-ahead when the parameter is left at its DSP default. + /// Fixed look-ahead time. pub const LOOKAHEAD_MS: f32 = 5.0; - /// Longest look-ahead the delay line is sized for (Hard Limiter's 0…30 ms range). - pub const MAX_LOOKAHEAD_MS: f32 = 30.0; pub fn new(sample_rate: f32, channels: usize) -> Self { let channels = channels.max(1); let bank = PolyphaseBank::new(true_peak_factor(sample_rate as f64).max(2)); - let max_look = ((Self::MAX_LOOKAHEAD_MS * 0.001 * sample_rate).round() as usize).max(1); - let look = ((Self::LOOKAHEAD_MS * 0.001 * sample_rate).round() as usize).max(1).min(max_look); + let look = ((Self::LOOKAHEAD_MS * 0.001 * sample_rate).round() as usize).max(1); let det_delay = bank.delay(); - let dlen = max_look + det_delay + 1; + let dlen = look + det_delay + 1; let mut s = Limiter { pv: ParamValues::new(Self::PARAMS), sr: sample_rate, @@ -310,7 +304,6 @@ impl Limiter { rel: 0.0, true_peak: true, look, - max_look, det_delay, bank, interp: vec![Interpolator::default(); channels], @@ -333,15 +326,6 @@ impl Limiter { self.ceiling = db_to_gain(self.pv.v("ceiling")); self.rel = coef(self.pv.v("release"), self.sr); self.true_peak = self.pv.on("true_peak"); - let look = ((self.pv.v("lookahead") * 0.001 * self.sr).round() as usize).max(1).min(self.max_look); - if look != self.look { - self.look = look; - self.minq.clear(); - self.box_ring = vec![1.0; look + 1]; - self.box_pos = 0; - self.box_sum = (look + 1) as f64; - self.rel_state = 1.0; - } if snap { self.in_gain.snap(); } @@ -379,7 +363,7 @@ impl AudioEffect for Limiter { for i in 0..n { let g_in = self.in_gain.tick(); let det_pos = (self.wpos + dlen - self.det_delay) % dlen; - let out_pos = (self.wpos + dlen - (self.look + self.det_delay)) % dlen; + let out_pos = (self.wpos + 1) % dlen; // oldest = look + det_delay samples ago let mut det = 0.0f32; for ch in 0..nch { let x = channels[ch][i] * g_in; @@ -571,24 +555,4 @@ mod tests { let peak = tail.iter().fold(0.0f32, |a, v| a.max(v.abs())); assert!((lin_to_db(peak) + 6.0).abs() < 0.3, "{}", lin_to_db(peak)); } - - #[test] - fn limiter_lookahead_changes_latency_and_transient_shape() { - let mut a = Limiter::new(SR, 1); - let mut b = Limiter::new(SR, 1); - a.set_param("lookahead", 0.0); - b.set_param("lookahead", 30.0); - a.set_param("ceiling", -6.0); - b.set_param("ceiling", -6.0); - assert!(b.latency() > a.latency(), "{} vs {}", b.latency(), a.latency()); - let mut xa: Vec = (0..4800).map(|i| 0.08 * (2.0 * std::f32::consts::PI * 997.0 * i as f32 / SR).sin()).collect(); - for s in &mut xa[1200..1248] { - *s = 0.9; - } - let mut xb = xa.clone(); - a.process(&mut [&mut xa]); - b.process(&mut [&mut xb]); - let max = xa.iter().zip(&xb).map(|(x, y)| (x - y).abs()).fold(0.0f32, f32::max); - assert!(max > 1e-4, "look-ahead of 0 ms and 30 ms produced the same waveform (max Δ {max})"); - } } diff --git a/crates/render/src/audio_fx.rs b/crates/render/src/audio_fx.rs index adf9d35ed..db84a1f6c 100644 --- a/crates/render/src/audio_fx.rs +++ b/crates/render/src/audio_fx.rs @@ -44,12 +44,11 @@ pub(crate) fn mapping(id: &str) -> Option { }, "hard_limiter" => Mapping { dsp: "limiter", - preroll: |e, t| (f(e, "lookahead", t) as f64 / 1000.0 + f(e, "release", t) as f64 / 1000.0 * 5.0).clamp(0.05, 2.0), + preroll: |e, t| (f(e, "release", t) as f64 / 1000.0 * 5.0).clamp(0.05, 2.0), apply: |d, e, t| { d.set_param("ceiling", f(e, "max", t)); d.set_param("input_gain", f(e, "boost", t).max(0.0)); d.set_param("release", f(e, "release", t)); - d.set_param("lookahead", f(e, "lookahead", t)); }, }, "delay" => Mapping { diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index ecb02d9d0..31f5edfc0 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -301,16 +301,6 @@ fn audio_fx_limiter_holds_ceiling() { assert!(peak <= ceiling * 1.01 && peak > ceiling * 0.7, "peak {peak} vs ceiling {ceiling}"); } -#[test] -fn audio_fx_limiter_lookahead_changes_the_mix() { - let (p0, seq0, map0) = tone_with(&[("amplify", &[("gain", 30.0)]), ("hard_limiter", &[("max", -6.0), ("lookahead", 0.0)])]); - let (p1, seq1, map1) = tone_with(&[("amplify", &[("gain", 30.0)]), ("hard_limiter", &[("max", -6.0), ("lookahead", 30.0)])]); - let a = mix(&p0, seq0, &map0, 0, 48_000); - let b = mix(&p1, seq1, &map1, 0, 48_000); - let max = a.iter().zip(&b).map(|(x, y)| (x - y).abs()).fold(0f32, f32::max); - assert!(max > 1e-4, "look-ahead of 0 ms and 30 ms mixed the same clip"); -} - #[test] fn audio_fx_chain_is_continuous_and_random_access_matches() { let (p, seq, map) = tone_with(&[("delay", &[("delay", 0.05), ("feedback", 50.0), ("mix", 50.0)])]); From 4cdb15466e4acb2ee81c91d3072ad7ad992c91a6 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 15:24:43 -0400 Subject: [PATCH 28/33] Captions: a TTML frame count that saturates the clock is rejected, not read as the end of time (#705 follow-up) Co-Authored-By: Claude Opus 5.5 --- crates/captions/src/ttml.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/captions/src/ttml.rs b/crates/captions/src/ttml.rs index 1987ba328..6c98a7ff7 100644 --- a/crates/captions/src/ttml.rs +++ b/crates/captions/src/ttml.rs @@ -287,8 +287,12 @@ fn parse_time(s: &str, tm: Timing) -> Option { "f" => { let n: i128 = vi.parse().ok()?; let fd = tm.rate.tick_of(1).0 as i128; - let whole = tm.rate.tick_of(n as i64).0 as i128; - whole + decimal_ticks("0", vf, fd)? + let whole = tm.rate.tick_of(i64::try_from(n).ok()?); + // tick_of saturates; a saturated count overflowed rather than named a real time + if whole == Tick(i64::MAX) { + return None; + } + whole.0 as i128 + decimal_ticks("0", vf, fd)? } "t" => { let n: i128 = vi.parse().ok()?; From b43e3828a9d6ef7d3c93ed84f5ce5b2c9c18bc3a Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 15:24:43 -0400 Subject: [PATCH 29/33] Revert "Merge pull request #685 from vbasky/fix/linked-clip-delete" This reverts commit 6145dcc568f8a06a9d113ef5137b2b1114b3cb92, reversing changes made to f34a00c0208a04d34958250e5a4951fa272b099f. --- crates/engine/src/commands.rs | 8 +++--- crates/engine/src/tests.rs | 39 --------------------------- crates/ui-egui/src/panels/timeline.rs | 11 +++++--- crates/ui-egui/tests/keyboard_ui.rs | 25 ----------------- 4 files changed, 12 insertions(+), 71 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index df6c80f4f..c1f2f56de 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1257,7 +1257,7 @@ fn build() -> Vec { let caps = s.state.caption_selection.clone(); return crate::captions::delete(s, &caps, false); } - let sel = if p.get("clips").is_some() || p.get("clip").is_some() { with_links(s, &clips_p(s, p)) } else { s.state.selection.clone() }; + let sel = with_links(s, &clips_p(s, p)); s.edit_sequence("Clear", |q, _, st| { edit::delete_items(q, &sel); st.selection.clear(); @@ -1270,7 +1270,7 @@ fn build() -> Vec { let caps = s.state.caption_selection.clone(); return crate::captions::delete(s, &caps, true); } - let sel = if p.get("clips").is_some() || p.get("clip").is_some() { with_links(s, &clips_p(s, p)) } else { s.state.selection.clone() }; + let sel = with_links(s, &clips_p(s, p)); s.edit_sequence("Ripple Delete", |q, _, st| { let spans = edit::ripple_delete_items(q, &sel)?; if st.ripple_sequence_markers { @@ -2163,13 +2163,13 @@ fn build() -> Vec { Ok(json!({"clips": ids.iter().map(|c| c.0).collect::>()})) } ), - cmd!("timeline.select", "Select Clips", [], None, r#"{"clips":[id],"transitions":[id]?,"add":bool,"toggle":bool,"linked":bool?}"#, has_seq, |s, p| { + cmd!("timeline.select", "Select Clips", [], None, r#"{"clips":[id],"transitions":[id]?,"add":bool,"toggle":bool}"#, has_seq, |s, p| { if let Some(ids) = p.get("transitions") { return select_transitions(s, ids, bool_p(p, "add").unwrap_or(false), bool_p(p, "toggle").unwrap_or(false)); } let clips: Vec = p.get("clips").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_u64().map(ClipId)).collect()).unwrap_or_default(); - let clips = if bool_p(p, "linked").unwrap_or(true) { with_links(s, &clips) } else { clips }; + let clips = with_links(s, &clips); // selecting clips leaves trim mode (Premiere: clip and edit point selections are exclusive) s.state.edit_points.clear(); s.state.trim_shift = Default::default(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f2b50dd8a..9852734d7 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -139,45 +139,6 @@ fn trim_linked_and_ripple_delete() { assert_eq!(q.video_tracks[0].items[0].start, s.sequence_rate().tick_of(12), "the trim gap remains"); } -#[test] -fn clear_respects_individually_selected_linked_clips_and_undo() { - for audio in [false, true] { - let mut s = demo(); - let video = s.active_sequence().unwrap().video_tracks[0].items[0].clone(); - let sound = s.active_sequence().unwrap().audio_tracks[0].items.iter().find(|i| i.link == video.link).unwrap().clone(); - let (selected, partner) = if audio { (sound.id, video.id) } else { (video.id, sound.id) }; - s.execute("timeline.select", json!({"clips": [selected.0], "linked": false})).unwrap(); - assert_eq!(s.state.selection, vec![selected]); - s.execute("edit.clear", json!({})).unwrap(); - assert!(s.active_sequence().unwrap().find_item(selected).is_none()); - assert!(s.active_sequence().unwrap().find_item(partner).is_some()); - s.execute("edit.undo", json!({})).unwrap(); - assert!(s.active_sequence().unwrap().find_item(selected).is_some()); - s.execute("timeline.select", json!({"clips": [selected.0]})).unwrap(); - assert_eq!(s.state.selection.len(), 2); - s.execute("edit.clear", json!({})).unwrap(); - assert!(s.active_sequence().unwrap().find_item(partner).is_none()); - } -} - -#[test] -fn ripple_delete_respects_individual_selection_and_explicit_clear_keeps_link_behavior() { - let mut s = demo(); - let video = s.active_sequence().unwrap().video_tracks[0].items[0].clone(); - let sound = s.active_sequence().unwrap().audio_tracks[0].items.iter().find(|i| i.link == video.link).unwrap().id; - let tracks: Vec<_> = s.active_sequence().unwrap().all_tracks().map(|t| t.id.0).collect(); - for track in tracks { - s.execute("timeline.setTrack", json!({"track": track, "syncLock": false})).unwrap(); - } - s.execute("timeline.select", json!({"clips": [video.id.0], "linked": false})).unwrap(); - s.execute("edit.rippleDelete", json!({})).unwrap(); - assert!(s.active_sequence().unwrap().find_item(video.id).is_none()); - assert!(s.active_sequence().unwrap().find_item(sound).is_some()); - s.execute("edit.undo", json!({})).unwrap(); - s.execute("edit.clear", json!({"clips": [video.id.0]})).unwrap(); - assert!(s.active_sequence().unwrap().find_item(sound).is_none()); -} - #[test] fn slide_moves_linked_audio_with_the_video() { let mut s = demo(); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 17a5eef96..e270db5f8 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2046,9 +2046,14 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & // select (shift toggles; alt selects one side of a link) let sel = &app.session.state.selection; if mods.shift { - let _ = app.session.execute("timeline.select", json!({"clips": [clip.0], "toggle": true, "linked": !mods.alt})); - } else if mods.alt || !sel.contains(&clip) { - let _ = app.session.execute("timeline.select", json!({"clips": [clip.0], "linked": !mods.alt})); + let _ = app.session.execute("timeline.select", json!({"clips": [clip.0], "toggle": true})); + } else if !sel.contains(&clip) { + if mods.alt { + app.session.state.selection = vec![clip]; + app.session.state.transition_selection.clear(); + } else { + let _ = app.session.execute("timeline.select", json!({"clips": [clip.0]})); + } } if resp.drag_started() { let clips = app.session.state.selection.clone(); diff --git a/crates/ui-egui/tests/keyboard_ui.rs b/crates/ui-egui/tests/keyboard_ui.rs index 930ae086e..52ece3995 100644 --- a/crates/ui-egui/tests/keyboard_ui.rs +++ b/crates/ui-egui/tests/keyboard_ui.rs @@ -71,31 +71,6 @@ impl Driver { } } -#[test] -fn alt_click_isolates_already_selected_linked_clip_before_delete() { - for audio in [false, true] { - let mut d = Driver::new(); - let video = d.app().session.active_sequence().unwrap().video_tracks[0].items[0].clone(); - let sound = d.app().session.active_sequence().unwrap().audio_tracks[0].items.iter().find(|i| i.link == video.link).unwrap().clone(); - let (selected, partner) = if audio { (sound.id, video.id) } else { (video.id, sound.id) }; - d.exec("timeline.select", json!({"clips": [selected.0]})); - assert_eq!(d.app().session.state.selection.len(), 2); - let r = d.app().auto.find(&format!("timeline.clip.{}", selected.0)).unwrap().rect; - d.harness.input_mut().events.push(egui::Event::ModifiersChanged(egui::Modifiers::ALT)); - d.frames(1); - d.ok("ui.click", json!({"x": r[0] + r[2] * 0.5, "y": r[1] + r[3] * 0.3, "modifiers": {"alt": true}})); - d.frames(3); - assert_eq!(d.app().session.state.selection, vec![selected]); - d.harness.input_mut().events.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); - d.frames(1); - d.key("Delete"); - assert!(d.app().session.active_sequence().unwrap().find_item(selected).is_none()); - assert!(d.app().session.active_sequence().unwrap().find_item(partner).is_some()); - d.exec("edit.undo", json!({})); - assert!(d.app().session.active_sequence().unwrap().find_item(selected).is_some()); - } -} - /// Ids added for keyboard parity (engine + UI). fn new_ids() -> Vec { let mut v: Vec = filmcraft_engine::keyboard::commands().iter().map(|c| c.id.to_string()).collect(); From e78223db6d1c7ff0bb30c96594f4b7642493212a Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 15:24:44 -0400 Subject: [PATCH 30/33] Revert "Merge pull request #662 from MrCloudy2/fix/export-hevc-stall-480" This reverts commit aa8ad402cc4e38509b1a30218741b995a893aca1, reversing changes made to 5f9b5e123498007a069d9b724fa051e45f5fa1e3. --- Cargo.lock | 1 - crates/codecs/Cargo.toml | 1 - crates/codecs/src/gop.rs | 161 +++---------------------------------- crates/media/src/cancel.rs | 54 ------------- 4 files changed, 9 insertions(+), 208 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 68dc628b1..27b7f514d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1829,7 +1829,6 @@ dependencies = [ "filmcraft-vp9", "image", "log", - "rayon", "serde_json", "symphonia", "thiserror 2.0.21", diff --git a/crates/codecs/Cargo.toml b/crates/codecs/Cargo.toml index 7caf5eb98..3a490e2c4 100644 --- a/crates/codecs/Cargo.toml +++ b/crates/codecs/Cargo.toml @@ -31,7 +31,6 @@ filmcraft-av1 = { workspace = true } filmcraft-apv = { workspace = true } filmcraft-aac = { workspace = true } filmcraft-opus = { workspace = true } -rayon = { workspace = true } thiserror = { workspace = true } log = { workspace = true } # Decode timing counters (`gop_stats`); std's Instant panics on wasm32. diff --git a/crates/codecs/src/gop.rs b/crates/codecs/src/gop.rs index 4f4428875..b23b130e5 100644 --- a/crates/codecs/src/gop.rs +++ b/crates/codecs/src/gop.rs @@ -109,9 +109,8 @@ impl std::ops::Sub for GopStats { } } -/// A container's video sample table, in decode (file) order. `Sync`: a cache decodes on its own -/// threads ([`isolated`]). -pub trait VideoSamples: Sync { +/// A container's video sample table, in decode (file) order. +pub trait VideoSamples { fn count(&self) -> usize; /// Presentation timestamp of sample `i` (track units). fn pts(&self, i: usize) -> i64; @@ -426,57 +425,6 @@ const MIN_FRAMES: usize = 64; /// rather than restarting at the sync sample. const CONTINUE_THROUGH: usize = 48; -/// Threads that run the caches' decoding (see [`isolated`]); `None` where none can be made. -#[cfg(not(target_arch = "wasm32"))] -fn decode_pool() -> Option<&'static rayon::ThreadPool> { - static POOL: std::sync::OnceLock> = std::sync::OnceLock::new(); - POOL.get_or_init(|| { - let n = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1); - rayon::ThreadPoolBuilder::new().num_threads(n).thread_name(|i| format!("decode-{i}")).build().ok() - }) - .as_ref() -} - -/// Run a cache's decoding, its lock held by the caller, on [`decode_pool`], the caller waiting -/// without doing other rayon work meanwhile (#480). -/// -/// That decoding starts rayon work (a decoder's slices, cropping and rotating the frames). On the -/// pool of the render jobs that ask the cache for frames, a worker that took part of it could, -/// waiting inside it, pick up another of those jobs and block on the lock: the lock holder then -/// waits for its work on the blocked worker's stack, and that worker for the lock, for good (an -/// export of rotated iPhone clips stopped making frames). On the decode pool that work only ever -/// runs beside other caches' decoding, which never waits for a cache's lock. A caller that is a -/// rayon worker would also run other jobs of its pool while it waits for the decode pool, some -/// of them asking for a cache's lock while it holds this one: it waits on a plain thread instead. -fn isolated(f: impl FnOnce() -> crate::Result + Send) -> crate::Result { - let ctx = filmcraft_media::cancel::Context::current(); - let job = move || ctx.run(f); - #[cfg(not(target_arch = "wasm32"))] - if let Some(pool) = decode_pool() { - if rayon::current_thread_index().is_none() { - return pool.install(job); - } - let slot = Mutex::new(Some(job)); - let waited = std::thread::scope(|scope| { - let spawned = std::thread::Builder::new().name("decode-wait".into()).spawn_scoped(scope, || { - let job = slot.lock().unwrap_or_else(PoisonError::into_inner).take(); - job.map(|job| pool.install(job)) - }); - spawned.map(|h| h.join()) - }); - return match waited { - Ok(Ok(Some(r))) => r, - Ok(Err(_)) => Err(CodecError::Decode("internal error while decoding".into())), - // no thread to wait on: decode here, as before - Ok(Ok(None)) | Err(_) => match slot.into_inner().unwrap_or_else(PoisonError::into_inner) { - Some(job) => job(), - None => Err(CodecError::Decode("internal error while decoding".into())), - }, - }; - } - job() -} - impl GopCache { pub fn new(explicit_color: Option) -> Self { // unit tests count decoder restarts: each of their caches gets a pool of its own @@ -534,11 +482,7 @@ impl GopCache { } fn store(&self, st: &mut State, pts: i64, f: VideoFrame, draft: bool) { - self.store_finished(st, pts, self.finish(f), draft); - } - - /// Cache a frame [`Self::finish`] has been applied to. - fn store_finished(&self, st: &mut State, pts: i64, f: VideoFrame, draft: bool) { + let f = self.finish(f); st.last_used = Instant::now(); if draft { st.drafts.insert(pts); @@ -660,22 +604,6 @@ impl GopCache { if st.intra { return self.intra_frame(st, s, i, want_pts); } - // with the lock held: see `isolated` - let st = &mut *st; - isolated(|| self.decode_until(st, s, i, want_pts, late_before, draft)) - } - - /// Decode (the lock held) until the frame with `want_pts` (sample `i`) is cached; return it. - fn decode_until( - &self, - st: &mut State, - s: &dyn VideoSamples, - i: usize, - want_pts: i64, - late_before: Option, - draft: bool, - ) -> crate::Result> { - let n = s.count(); let mut key = s.sync_before(i); // Continue the running decoder when it has passed the wanted sample's sync sample and // either has not reached the sample yet or has been fed it without outputting it yet @@ -728,14 +656,14 @@ impl GopCache { let out = timed(|| decoder.decode(&data, s.pts(k)))?; st.next += 1; DECODED.fetch_add(1, Ordering::Relaxed); - self.store_output(st, out); + self.store_output(&mut st, out); if st.cached(want_pts, draft).is_some() { break; } } if st.cached(want_pts, draft).is_none() { let out = st.decoder.as_mut().map(|d| timed(|| d.flush())).unwrap_or_default(); - self.store_output(st, out); + self.store_output(&mut st, out); st.next = usize::MAX; } // nearest decoded frame at or before the wanted pts (robust to decoder pts quirks) @@ -820,17 +748,14 @@ impl GopCache { DECODED.fetch_add(1, Ordering::Relaxed); timed(|| dec.decode(&data, want_pts)) }); - // crop and rotate before taking the lock: that work runs on rayon (see `isolated`) - let res = res.map(|out| { - count_frames(&out); - out.into_iter().map(|d| (d.pts, self.finish(d.frame), d.draft)).collect::>() - }); let mut st = self.shared.state.lock().unwrap_or_else(|e| e.into_inner()); if st.spare.len() < 16 { st.spare.push(dec); } - for (pts, frame, draft) in res? { - self.store_finished(&mut st, pts, frame, draft); + let res = res?; + count_frames(&res); + for d in res { + self.store(&mut st, d.pts, d.frame, d.draft); } st.at_or_before(want_pts, true).ok_or_else(|| CodecError::Decode("frame not produced".into())) } @@ -962,74 +887,6 @@ mod tests { assert!(s.decodes.load(Ordering::Relaxed) <= 60 + 24 + 1); } - /// A decoder that, like a slice-threaded one, splits each picture's work over rayon. - struct ParDec(Box); - - impl VideoDecoder for ParDec { - fn decode(&mut self, sample: &[u8], pts: i64) -> crate::Result> { - use rayon::prelude::*; - let rows: u64 = - (0..64u32).into_par_iter().with_max_len(1).map(|r| (0..20_000u64).fold(u64::from(r), |a, x| a.wrapping_mul(31).wrapping_add(x))).sum(); - std::hint::black_box(rows); - self.0.decode(sample, pts) - } - fn flush(&mut self) -> Vec { - self.0.flush() - } - fn reset(&mut self) { - self.0.reset() - } - fn name(&self) -> &str { - "par" - } - } - - struct ParSamples(Samples); - - impl VideoSamples for ParSamples { - fn count(&self) -> usize { - self.0.count() - } - fn pts(&self, i: usize) -> i64 { - self.0.pts(i) - } - fn sync_before(&self, i: usize) -> usize { - self.0.sync_before(i) - } - fn sample_at(&self, t: i64) -> Option { - self.0.sample_at(t) - } - fn read(&self, i: usize) -> crate::Result> { - self.0.read(i) - } - fn make_decoder(&self) -> crate::Result> { - Ok(Box::new(ParDec(self.0.make_decoder()?))) - } - } - - /// #480: render jobs on rayon asking one source for frames while its decoding (a decoder's - /// slices, rotating the frames) runs on rayon too. A worker that took part of that work and, - /// waiting inside it, picked up another frame request blocked on the cache's lock for good: - /// an export of rotated iPhone clips stopped making frames. - #[test] - fn rayon_frame_requests_while_decoding_on_rayon_never_deadlock() { - use rayon::prelude::*; - let s = Arc::new(ParSamples(samples(240, 30, 4, false))); - let (tx, rx) = std::sync::mpsc::channel(); - std::thread::spawn(move || { - for round in 0..40usize { - // export-like: many frame workers, frames of several GOPs of a fresh source at once - let c = GopCache::new(None).with_rotation(1); - (0..96usize).into_par_iter().with_max_len(1).for_each(|k| { - let i = (k * 37 + round * 53) % 240; - assert_eq!(index_of(&c.frame(&*s, i as i64 * 1000).expect("frame")), i); - }); - } - let _ = tx.send(()); - }); - assert!(rx.recv_timeout(Duration::from_secs(120)).is_ok(), "frame requests deadlocked"); - } - #[test] fn running_decoder_continues_through_a_nearby_sync_sample() { let s = samples(200, 30, 0, false); diff --git a/crates/media/src/cancel.rs b/crates/media/src/cancel.rs index 5c2b5f28b..bfb391dd2 100644 --- a/crates/media/src/cancel.rs +++ b/crates/media/src/cancel.rs @@ -96,64 +96,10 @@ pub fn cancelled() -> bool { CURRENT.with(|c| c.borrow().as_ref().is_some_and(|f| f.load(Ordering::Relaxed))) } -/// The hints of the work running on this thread ([`with_cancel`], [`with_catch_up`], -/// [`with_draft`], [`with_background`]), to carry them to another thread doing part of that work. -#[derive(Clone, Default)] -pub struct Context { - cancel: Option>, - catch_up: Option, - draft: bool, - background: bool, -} - -impl Context { - /// This thread's hints. - pub fn current() -> Self { - Context { cancel: CURRENT.with(|c| c.borrow().clone()), catch_up: catch_up(), draft: draft(), background: background() } - } - - /// Run `f` with these hints, restoring this thread's however `f` ends (a pool thread must - /// not keep another job's hints after a panic). - pub fn run(self, f: impl FnOnce() -> R) -> R { - struct Restore(Context); - impl Drop for Restore { - fn drop(&mut self) { - let prev = std::mem::take(&mut self.0); - CURRENT.with(|c| *c.borrow_mut() = prev.cancel); - CATCH_UP.with(|c| c.set(prev.catch_up)); - DRAFT.with(|c| c.set(prev.draft)); - BACKGROUND.with(|c| c.set(prev.background)); - } - } - let _restore = Restore(Context::current()); - CURRENT.with(|c| *c.borrow_mut() = self.cancel); - CATCH_UP.with(|c| c.set(self.catch_up)); - DRAFT.with(|c| c.set(self.draft)); - BACKGROUND.with(|c| c.set(self.background)); - f() - } -} - #[cfg(test)] mod tests { use super::*; - #[test] - fn context_carries_the_hints_to_another_thread_and_restores_its_own() { - let flag = Arc::new(AtomicBool::new(true)); - let ctx = with_cancel(&flag, || with_draft(true, || with_background(true, || with_catch_up(Some(Tick(5)), Context::current)))); - std::thread::scope(|s| { - s.spawn(|| { - let seen = with_draft(false, || ctx.clone().run(|| (cancelled(), draft(), background(), catch_up()))); - assert_eq!(seen, (true, true, true, Some(Tick(5)))); - // a panic inside leaves this thread's own hints in place - let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| ctx.clone().run(|| panic!("job")))); - assert!(r.is_err()); - assert_eq!((cancelled(), draft(), background(), catch_up()), (false, false, false, None)); - }); - }); - } - #[test] fn flag_is_scoped_to_the_closure() { let flag = Arc::new(AtomicBool::new(false)); From 366537838412f98cb0ad1961c37e8b57fb058e5d Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 15:24:48 -0400 Subject: [PATCH 31/33] Revert "Merge pull request #689 from Ni-zav/fix/654-track-header-rename-s20261010T1632Z-b8e4" This reverts commit 826858dfecd41a3c89f9b5b9b5cb6f8b00647571, reversing changes made to 0a9c133b4e164679c251db16b187c30ae1e3ec62. --- crates/ui-egui/src/panels/timeline.rs | 32 --------------- crates/ui-egui/tests/timeline_rename_ui.rs | 46 ---------------------- 2 files changed, 78 deletions(-) delete mode 100644 crates/ui-egui/tests/timeline_rename_ui.rs diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index e270db5f8..9afd8825e 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1147,38 +1147,6 @@ fn draw_headers(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, rows: egui::Id::new(("hdr", r.track.0)), Sense::click(), ); - app.auto.add(&format!("timeline.track.{label}.name"), resp.rect, "Track name (right-click to rename)"); - let rename_id = egui::Id::new(("timeline-track-rename", r.track.0)); - if resp.secondary_clicked() { - // Start each menu opening from the committed name, not a cancelled draft. - ui.data_mut(|d| d.insert_temp(rename_id, tr.name.clone())); - } - egui::Popup::context_menu(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { - ui.set_min_width(220.0); - ui.label(tl!("Rename")); - let mut name: String = ui.data(|d| d.get_temp(rename_id)).unwrap_or_else(|| tr.name.clone()); - let input = ui.add(egui::TextEdit::singleline(&mut name).desired_width(210.0)); - app.auto.add(&format!("timeline.track.{label}.renameField"), input.rect, "New track name"); - if resp.secondary_clicked() { - input.request_focus(); - } - let save_on_enter = input.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); - let valid = !name.trim().is_empty(); - ui.horizontal(|ui| { - let apply = ui.add_enabled(valid, egui::Button::new(tl!("Apply"))); - app.auto.add(&format!("timeline.track.{label}.renameApply"), apply.rect, "Apply track name"); - if (apply.clicked() || (valid && save_on_enter)) && name.trim() != tr.name { - actions.push(("timeline.setTrack".into(), json!({"track": r.track.0, "name": name.trim()}))); - ui.close(); - } else if apply.clicked() || (valid && save_on_enter) { - ui.close(); - } - if ui.button(tl!("Cancel")).clicked() { - ui.close(); - } - }); - ui.data_mut(|d| d.insert_temp(rename_id, name)); - }); if resp.double_clicked() { let h = if r.kind == TrackKind::Video { &mut app.ui.timeline.video_track_h } else { &mut app.ui.timeline.audio_track_h }; *h = if *h < 50.0 { 64.0 } else { 30.0 }; diff --git a/crates/ui-egui/tests/timeline_rename_ui.rs b/crates/ui-egui/tests/timeline_rename_ui.rs deleted file mode 100644 index aa1ef4c94..000000000 --- a/crates/ui-egui/tests/timeline_rename_ui.rs +++ /dev/null @@ -1,46 +0,0 @@ -//! Right-click renaming a Timeline track uses timeline.setTrack, which is undoable. - -use egui_kittest::Harness; -use filmcraft_engine::Session; -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 click(h: &mut Harness<'static, FilmcraftApp>, id: &str, button: egui::PointerButton) { - let [x, y, w, height] = h.state().auto.find(id).expect("visible control").rect; - let at = egui::pos2(x + w * 0.5, y + height * 0.5); - h.input_mut().events.push(egui::Event::PointerMoved(at)); - step(h); - h.input_mut().events.push(egui::Event::PointerButton { pos: at, button, pressed: true, modifiers: Default::default() }); - step(h); - h.input_mut().events.push(egui::Event::PointerButton { pos: at, button, pressed: false, modifiers: Default::default() }); - step(h); -} - -#[test] -fn rename_from_video_track_header_is_undoable() { - let mut s = Session::default(); - s.execute("file.openDemoProject", json!({})).expect("demo project"); - let mut h = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_cc| FilmcraftApp::new(s)); - for _ in 0..5 { - step(&mut h); - } - let original = h.state().session.active_sequence().expect("active sequence").video_tracks[0].name.clone(); - let id = h.state().session.active_sequence().expect("active sequence").video_tracks[0].id.0; - click(&mut h, "timeline.track.V1.name", egui::PointerButton::Secondary); - assert!(h.state().auto.find("timeline.track.V1.renameField").is_some(), "track rename popup should open"); - h.ctx.data_mut(|d| d.insert_temp(egui::Id::new(("timeline-track-rename", id)), "B-roll".to_string())); - step(&mut h); - click(&mut h, "timeline.track.V1.renameApply", egui::PointerButton::Primary); - assert_eq!(h.state().session.active_sequence().expect("active sequence").video_tracks[0].name, "B-roll"); - h.state_mut().session.execute("edit.undo", json!({})).expect("undo rename"); - assert_eq!(h.state().session.active_sequence().expect("active sequence").video_tracks[0].name, original); -} From b86dcf7bb3205ec562f0151c8fd74776a20b30d9 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 21:24:52 +0200 Subject: [PATCH 32/33] #227: transitions travel with the clips they sit on when moved Problem: moving a clip dropped the transitions at its edges (#227, point 8 of #374). `edit::move_items` deletes the moved clips before placing them again, and that deletion removes every transition that refers to them, so a fade on a clip's edge, or the cut between two clips moved together, vanished. Fix: before the move, collect the transitions whose clips all move onto one track by one offset, and put them back on that track shifted by the offset once the clips have landed (overwrite and insert moves). A crossfade whose partner stays behind is still dropped. Co-Authored-By: Claude Opus 5.5 --- crates/edit/src/lib.rs | 30 +++++++++++++++++++++++++++++- crates/edit/src/tests.rs | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index 2f5a8382b..200fe63b8 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -487,17 +487,45 @@ pub fn move_items(seq: &mut Sequence, moves: &[(ClipId, TrackId, Tick)], insert_ it.start = (*start).max(Tick::ZERO); placed.push((*dest, it)); } - // carry transitions with moved clips? Premiere drops transitions whose partner is not moved. + let carried = carried_transitions(&work, &placed); + // a transition whose partner stays behind is dropped, as in Premiere delete_items(&mut work, &moves.iter().map(|m| m.0).collect::>()); if insert_mode { insert(&mut work, placed, ctx)?; } else { overwrite(&mut work, placed, ctx)?; } + for (dest, tr) in carried { + let t = track_mut(&mut work, dest)?; + if [tr.from, tr.to].into_iter().flatten().all(|c| t.item(c).is_some()) { + t.transitions.push(tr); + t.sort(); + } + } *seq = work; Ok(()) } +/// The transitions that travel with a move (#227, #374): those whose clips all move, onto one +/// track and by one offset (a fade at a moved clip's edge, or the cut between two clips that move +/// together), shifted to where those clips land. +fn carried_transitions(seq: &Sequence, placed: &[(TrackId, TrackItem)]) -> Vec<(TrackId, Transition)> { + let landing = |c: ClipId| { + let (dest, it) = placed.iter().find(|(_, it)| it.id == c)?; + let (_, old) = seq.find_item(c)?; + Some((*dest, it.start - old.start)) + }; + let mut carried = Vec::new(); + for tr in seq.all_tracks().filter(|t| !t.locked).flat_map(|t| t.transitions.iter()) { + let lands: Option> = [tr.from, tr.to].into_iter().flatten().map(landing).collect(); + let Some((&(dest, delta), rest)) = lands.as_deref().and_then(<[_]>::split_first) else { continue }; + if rest.iter().all(|l| *l == (dest, delta)) { + carried.push((dest, Transition { start: tr.start + delta, ..tr.clone() })); + } + } + carried +} + fn media_len(ctx: &EditCtx, item: &TrackItem) -> Option { if item.frame_hold.is_some() { return None; diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index ae258cb0f..187bded0e 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -464,6 +464,39 @@ fn transitions_stay_on_their_cuts() { fx.seq.check().unwrap(); } +#[test] +fn transitions_travel_with_moved_clips() { + // V1: a [0,10) fading in, b [10,20), c [20,30); b→c crossfade, c fading out + let mut fx = Fx::new(); + let (v1, v2) = (fx.v(0), fx.v(1)); + let a = fx.put(v1, 0, 10, 10); + let b = fx.put(v1, 10, 10, 10); + let c = fx.put(v1, 20, 10, 10); + let tr = |id, start, from, to| Transition { + id: TransitionId(id), + effect: filmcraft_project::find_effect("cross_dissolve").unwrap().instance(), + start: f(start), + duration: f(4), + from, + to, + align: Default::default(), + reverse: false, + }; + fx.seq.track_mut(v1).unwrap().transitions = vec![tr(1, 0, None, Some(a)), tr(2, 18, Some(b), Some(c)), tr(3, 26, Some(c), None)]; + let at = |fx: &Fx, track: TrackId| -> Vec<(u64, i64)> { fx.seq.track(track).unwrap().transitions.iter().map(|t| (t.id.0, R.frame_at(t.start))).collect() }; + let mut n = fx.next; + // a fade at a clip's edge goes with it, to another track too + move_items(&mut fx.seq, &[(a, v2, f(40))], false, &mut Fx::ctx(&mut n)).unwrap(); + assert_eq!((at(&fx, v1), at(&fx, v2)), (vec![(2, 18), (3, 26)], vec![(1, 40)])); + // a cut whose two clips move together keeps its transition, in an insert move as well + move_items(&mut fx.seq, &[(b, v1, f(60)), (c, v1, f(70))], true, &mut Fx::ctx(&mut n)).unwrap(); + assert_eq!(at(&fx, v1), vec![(2, 68), (3, 76)]); + // a crossfade whose partner stays behind is dropped; the other clip's fade stays with it + move_items(&mut fx.seq, &[(c, v1, f(90))], false, &mut Fx::ctx(&mut n)).unwrap(); + assert_eq!(at(&fx, v1), vec![(3, 96)]); + fx.seq.check().unwrap(); +} + #[test] fn rate_stretch_and_speed() { let mut fx = Fx::new(); From fad5255dc91f7620f1f08ade598361f3d5b7ba50 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 16:08:58 -0400 Subject: [PATCH 33/33] media: importable tests after the items (clippy items_after_test_module, #677 follow-up) Co-Authored-By: Claude Opus 5.5 --- crates/media/src/lib.rs | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index d65a06304..bfc94de79 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -180,20 +180,6 @@ pub fn is_importable(path: &Path) -> bool { STILL_EXTENSIONS.contains(&ext.as_str()) || AUDIO_EXTENSIONS.contains(&ext.as_str()) || VIDEO_EXTENSIONS.contains(&ext.as_str()) } -#[cfg(test)] -mod importable_tests { - use super::*; - use std::path::Path; - - #[test] - fn camera_extensions_match_regardless_of_case() { - for name in ["clip.mov", "clip.MOV", "clip.Mov", "shot.MP4", "IMG_0001.JPG", "sound.WAV", "take.M2TS"] { - assert!(is_importable(Path::new(name)), "{name}"); - } - assert!(!is_importable(Path::new("notes.txt"))); - } -} - /// A pluggable opener (containers/codecs register themselves here from higher-level crates). pub type Opener = fn(name: &str, bytes: Arc<[u8]>) -> Option>; @@ -244,3 +230,17 @@ impl MediaSource for OfflineSource { Ok(AudioBuffer::silence(sample_rate, self.info.audio().map_or(2, |a| a.channels as usize), frames)) } } + +#[cfg(test)] +mod importable_tests { + use super::*; + use std::path::Path; + + #[test] + fn camera_extensions_match_regardless_of_case() { + for name in ["clip.mov", "clip.MOV", "clip.Mov", "shot.MP4", "IMG_0001.JPG", "sound.WAV", "take.M2TS"] { + assert!(is_importable(Path::new(name)), "{name}"); + } + assert!(!is_importable(Path::new("notes.txt"))); + } +}