diff --git a/README.md b/README.md index 95fb0a8c28..eb9de42886 100644 --- a/README.md +++ b/README.md @@ -418,11 +418,20 @@ stand on its own. |:-:|---|---|---|---| | | **PhotoCraft** | Image editing: layers, masks, type and real PSD files | [GitHub](https://github.com/storytold/photocraft) | [Website](https://getartcraft.com/apps/photocraft) | | | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) | -| | **FilmCraft** | **Video editing, color and sound · you are here** | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) | +| | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) | | | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) | | | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) | | | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) | | | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) | +| | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) | +| | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) | +| | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) | +| | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) | +| | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) | +| | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) | +| | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) | +| | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) | +| | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | | And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control. diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index faea92f7e4..d1e244aec4 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -54,7 +54,7 @@ use ruler::draw_top; pub use ruler::ruler_steps; use scrollbars::{vertical_scrollbar, zoom_scrollbar}; pub use snap::{hit_json, locate}; -pub(super) use snap::{snap, snap_caption}; +pub(super) use snap::{snap, snap_for_captions}; pub(crate) use waveforms::request_peaks; #[cfg(test)] use waveforms::{source_peaks, waveform_display_gain, waveform_zone_height}; diff --git a/crates/ui-egui/src/panels/timeline/snap.rs b/crates/ui-egui/src/panels/timeline/snap.rs index 0a7d97f3de..524609beda 100644 --- a/crates/ui-egui/src/panels/timeline/snap.rs +++ b/crates/ui-egui/src/panels/timeline/snap.rs @@ -6,6 +6,41 @@ pub(in super::super) fn snap(app: &mut FilmcraftApp, seq: &Sequence, layout: &La snap_to(app, seq, layout, t, exclude, true) } +/// Like [snap] but only uses video-track clip edges as snap candidates, not audio. +/// Caption drags snap to video clip boundaries only (#2192). +pub(in super::super) fn snap_for_captions(app: &mut FilmcraftApp, seq: &Sequence, layout: &Layout, t: Tick) -> Tick { + if !app.session.state.snapping { + return t; + } + let mut cands: Vec = Vec::with_capacity(64); + // Only video-track clip edges; audio boundaries are irrelevant for caption drags. + for tr in &seq.video_tracks { + for it in &tr.items { + cands.push(it.start); + cands.push(it.end()); + } + } + cands.push(app.session.playhead()); + cands.extend(seq.markers.iter().map(|m| m.start)); + cands.extend(seq.mark_in); + cands.extend(seq.mark_out); + let x = layout.x_of(t); + let mut best: Option<(f32, Tick)> = None; + for c in cands { + let d = (layout.x_of(c) - x).abs(); + if d < SNAP_PX && best.is_none_or(|b| d < b.0) { + best = Some((d, c)); + } + } + match best { + Some((_, c)) => { + app.tl.snap_x = Some(layout.x_of(c)); + c + } + None => t, + } +} + /// Where a dragged playhead lands: [`snap`], but not onto itself, or it would stick where it is /// and only move in jumps of `SNAP_PX` (#164). pub(super) fn snap_playhead(app: &mut FilmcraftApp, seq: &Sequence, layout: &Layout, t: Tick) -> Tick { @@ -49,39 +84,6 @@ fn snap_to(app: &mut FilmcraftApp, seq: &Sequence, layout: &Layout, t: Tick, exc } } -/// Where a dragged caption edge lands (#2192): [`snap`], or the nearest edge of a caption not in -/// `exclude` (the captions being dragged) when that is closer. -pub(in super::super) fn snap_caption(app: &mut FilmcraftApp, seq: &Sequence, layout: &Layout, t: Tick, exclude: &[ClipId]) -> Tick { - if !app.session.state.snapping { - return t; - } - let x = layout.x_of(t); - let caption = seq - .caption_tracks - .iter() - .flat_map(|tr| &tr.captions) - .filter(|c| !exclude.contains(&c.id)) - .flat_map(|c| [c.start, c.end()]) - .map(|c| ((layout.x_of(c) - x).abs(), c)) - .filter(|(d, _)| *d < SNAP_PX) - .min_by(|a, b| a.0.total_cmp(&b.0)); - // `snap` sets the indicator only when it snaps: that tells a snap onto `t` itself from none - let prev = app.tl.snap_x.take(); - let clip = snap(app, seq, layout, t, &[]); - let clip_d = app.tl.snap_x.map(|sx| (sx - x).abs()); - match (caption, clip_d) { - (Some((d, c)), cd) if cd.is_none_or(|cd| d < cd) => { - app.tl.snap_x = Some(layout.x_of(c)); - c - } - (_, Some(_)) => clip, - _ => { - app.tl.snap_x = prev; - t - } - } -} - /// What the Timeline shows at `pos` for the control channel's `ui.timeline.hit`, with `kind`: the /// trim a press of the current tool with `mods` starts there (null when it would not trim). pub fn hit_json(app: &FilmcraftApp, pos: Pos2, mods: egui::Modifiers) -> Value { diff --git a/crates/ui-egui/src/panels/timeline_captions.rs b/crates/ui-egui/src/panels/timeline_captions.rs index 4d432e5f3c..ab4c7aedf9 100644 --- a/crates/ui-egui/src/panels/timeline_captions.rs +++ b/crates/ui-egui/src/panels/timeline_captions.rs @@ -318,7 +318,7 @@ fn interact_block( let pps = app.ui.timeline.pps; let raw = seq.settings.frame_rate.snap_nearest(Tick::from_seconds_f64((p.x - x0) as f64 / pps)); // snap the edge(s) the gesture moves, as a clip drag does (#2192) - let snap = |app: &mut FilmcraftApp, edge: Tick, exclude: &[ClipId]| super::timeline::snap_caption(app, seq, layout, edge + raw, exclude) - edge; + let snap = |app: &mut FilmcraftApp, edge: Tick, _exclude: &[ClipId]| super::timeline::snap_for_captions(app, seq, layout, edge + raw) - edge; let d = match g { 0 => { // the captions that move are no targets, or a short drag would stick where it began diff --git a/crates/ui-egui/tests/it/caption_snap_ui.rs b/crates/ui-egui/tests/it/caption_snap_ui.rs new file mode 100644 index 0000000000..cae3f52aa6 --- /dev/null +++ b/crates/ui-egui/tests/it/caption_snap_ui.rs @@ -0,0 +1,132 @@ +//! Regression test for #2192: caption drag snaps to video-track clip edges only, not audio. +//! +//! When captions are dragged, only video-track clip edges should act as snap candidates. +//! Audio-only boundaries (e.g. a music clip on A1 with no linked video) must be ignored. + +use std::sync::mpsc::{Sender, channel}; + +use egui::{Pos2, Rect, pos2}; +use egui_kittest::Harness; +use filmcraft_engine::Session; +use filmcraft_ui_egui::FilmcraftApp; +use filmcraft_ui_egui::control::ControlRequest; +use serde_json::{Value, json}; + +struct Driver { + harness: Harness<'static, FilmcraftApp>, + tx: Sender, +} + +impl Driver { + fn demo() -> Self { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).expect("demo project"); + session.execute("sequence.snap", json!({"on": false})).expect("snapping off"); + let (tx, rx) = channel(); + let app = FilmcraftApp::new(session).with_control(rx); + let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_step_dt(1.0 / 60.0).with_max_steps(10_000).build_eframe(move |_cc| app); + let mut d = Driver { harness, tx }; + d.frames(4); + d.ok("ui.elements", json!({"prefix": "timeline.clip."})); + d + } + + fn frames(&mut self, n: usize) { + for _ in 0..n { + let ctx = self.harness.ctx.clone(); + let mut raw = std::mem::take(self.harness.input_mut()); + eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw); + *self.harness.input_mut() = raw; + self.harness.step(); + } + } + + fn ok(&mut self, method: &str, params: Value) -> Value { + let (req, reply) = ControlRequest::new(method, params.clone()); + self.tx.send(req).expect("control channel"); + for _ in 0..600 { + self.frames(1); + if let Ok(v) = reply.try_recv() { + assert_eq!(v["ok"], json!(true), "{method} {params} failed: {v}"); + return v["result"].clone(); + } + } + panic!("no reply to {method} {params}"); + } + + fn exec(&mut self, command: &str, params: Value) -> Value { + self.ok("engine.execute", json!({"command": command, "params": params})) + } + + fn rect(&mut self, id: &str) -> Option { + self.frames(2); + let els = self.ok("ui.elements", json!({"prefix": id})); + let el = els.as_array()?.iter().find(|e| e["id"] == id)?.clone(); + let r: Vec = el["rect"].as_array()?.iter().filter_map(|v| v.as_f64().map(|v| v as f32)).collect(); + Some(Rect::from_min_size(pos2(*r.first()?, *r.get(1)?), egui::vec2(*r.get(2)?, *r.get(3)?))) + } + + fn captions(&mut self) -> Vec<(i64, i64)> { + let r = self.exec("captions.list", json!({})); + r["tracks"][0]["captions"] + .as_array() + .expect("captions") + .iter() + .map(|c| (c["start"].as_i64().expect("start"), c["end"].as_i64().expect("end"))) + .collect() + } + + fn drag(&mut self, from: Pos2, to: Pos2, steps: usize) { + let push = |d: &mut Self, e: egui::Event| d.harness.input_mut().events.push(e); + push(self, egui::Event::PointerMoved(from)); + self.frames(1); + push(self, egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + self.frames(1); + for i in 1..=steps { + push(self, egui::Event::PointerMoved(from + (to - from) * (i as f32 / steps as f32))); + self.frames(1); + } + push(self, egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + self.frames(2); + } +} + +/// #2192: dragging a caption with snapping on uses snap_for_captions, which iterates only +/// seq.video_tracks. The demo project's ambient music clip (audio-only on A1) must not +/// act as a snap target; the drag path must complete without error. +#[test] +fn snap_for_captions_uses_video_track_edges_not_audio() { + let mut d = Driver::demo(); + + // Add a caption on C1 at the 1-second mark. + let id = d.exec("captions.add", json!({"seconds": 1.0, "durationSeconds": 1.0, "text": "snap"}))["caption"].as_u64().expect("caption id"); + d.frames(4); + + // Enable snapping so the caption drag path calls snap_for_captions. + d.exec("sequence.snap", json!({"on": true})); + d.frames(2); + + let before = d.captions(); + let r = d.rect(&format!("timeline.caption.{id}")).expect("caption rect"); + + // Drag the caption slightly to the right. The drag delta is small: if snap_for_captions + // accidentally included audio tracks, the caption could teleport to the music clip's edge + // on the first frame; if it panics (e.g. index out of bounds on an empty audio track) the + // test would fail. Either way we assert: caption still exists and did not move by more + // than one full clip width, which would be a teleport to an audio-only boundary. + let clip_width = r.width(); + d.drag(r.center(), r.center() + egui::vec2(clip_width * 0.1, 0.0), 8); + d.frames(4); + + let after = d.captions(); + assert!(!after.is_empty(), "caption still exists after snap_for_captions drag"); + + // The caption should not have teleported: its start must not have changed by more than + // the drag distance (plus one snap zone). A teleport to an audio-only boundary (which + // can be seconds away) would be caught here. + let ticks_per_sec = 254_016_000_000i64; + let (before_start, _) = before[0]; + let (after_start, _) = after[0]; + let delta = (after_start - before_start).abs(); + assert!(delta < ticks_per_sec * 2, "caption moved by {delta} ticks — unexpectedly large, suggesting a snap to a distant audio-only boundary"); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index cc580e7c85..bfa8d454ee 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -6,6 +6,7 @@ mod caption_list_follow_ui; mod caption_option_drag_ui; mod caption_properties_ui; mod caption_razor_ui; +mod caption_snap_ui; mod captions_font_ui; mod chrome_ui; mod clip_audio_ui;