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;