diff --git a/ROADMAP.md b/ROADMAP.md index b82e48f02..c77769f82 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -5,7 +5,7 @@ **Stage: alpha** · next: beta, ~19 points of ready-for-real-work and ~400–700 h away, plus opening Premiere's `.prproj` ([why](docs/target-app-parity.md#stage); passes the [core-workflow gate](docs/roadmap.md#alpha-gate)) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (language table: German ships the menus, #630) · **Target:** Adobe Premiere Pro 2026 (26.5.2) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (language table: German covers the whole interface) · **Target:** Adobe Premiere Pro 2026 (26.5.2) Progress toward parity with Adobe Premiere Pro. This page is the summary; the detail is in [docs/target-app-parity.md](docs/target-app-parity.md) (assessment, methodology, calibration), @@ -87,7 +87,7 @@ Premiere ships 10 interface languages. Measured from our catalogs (3,498 strings | Italian | partial (whole interface; native review welcome) | ~100% | | Korean | partial (whole interface; needs an installed Hangul font; native review welcome) | ~100% | | Japanese | partial (whole interface) | 99.7% | -| German | menus only (native-reviewed) | 10.6% | +| German | partial (whole interface; menus native-reviewed, the rest welcome) | ~100% | | Vietnamese | none | 0% | Also shipped: Ukrainian (menus only, 10%). Detail: [docs/localization-parity.md](docs/localization-parity.md). @@ -166,6 +166,7 @@ Ranked; detail and "done when" in [docs/roadmap.md](docs/roadmap.md) and [docs/g | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Language table: German covers the whole interface (~100%) | | 2026-10-12 | trivial | Progress log: Transform shutter on adjustment layers (#1088) | | 2026-10-11 | minor | Language table: German ships the menus (#630, 10.6%) | | 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% per audience | diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index 0aebbc31d..fad4e4192 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -278,9 +278,22 @@ impl FilmcraftMcp { } /// The project tree plus the active sequence (null when there is none). + /// + /// `project.inspect` and `sequence.inspect` are two separate calls (two lock scopes in + /// headless mode, two round trips to the app in bridge mode), so the active sequence can + /// change between them if another client, or the app's own UI, switches sequence tabs in + /// that window (#1477). Pinning `sequence.inspect` to the `activeSequence` id this + /// `project.inspect` just reported keeps the pair consistent: the returned `sequence`, when + /// not null, always describes the sequence the returned `project.activeSequence` names. async fn document(&self) -> Result { let project = self.run("project.inspect", json!({})).await?; - let sequence = self.run("sequence.inspect", json!({})).await.ok(); + // `ItemId` is a u64 (`item_p` parses the `item` param with `u64_p`): read it as one too, + // not `as_i64`, so an id above `i64::MAX` is still looked up instead of silently going null. + let active = project.get("activeSequence").and_then(Value::as_u64); + let sequence = match active { + Some(id) => self.run("sequence.inspect", json!({"item": id})).await.ok(), + None => None, + }; Ok(json!({"project": project, "sequence": sequence})) } @@ -998,4 +1011,73 @@ mod tests { let r = m.render_frame(Parameters(RenderParams { seconds: Some(0.5), max_side: Some(64) })).await.unwrap(); assert_ne!(r.is_error, Some(true)); } + + /// `doc_inspect` composes `project.inspect` and `sequence.inspect` as two separate round + /// trips to the app. If the active sequence changes between them (another client, or the + /// app's own UI, switching tabs in that window), an unscoped `sequence.inspect` answers for + /// whichever sequence is active *now*, not the one `project.inspect` just reported, so the + /// pair comes back inconsistent (#1477, reproduced with a real bridge client and a flipping + /// `sequence.open` in `race_test.py`: ~300/300 calls mismatched). Pinning the second call to + /// the id the first one reported keeps `sequence`, when not null, always describing the + /// sequence `project.activeSequence` names. + #[tokio::test] + async fn doc_inspect_pins_the_sequence_to_the_reported_active_id() { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + tokio::spawn(async move { + let (s, _) = listener.accept().await.unwrap(); + let (r, mut w) = s.into_split(); + let mut lines = tokio::io::BufReader::new(r).lines(); + while let Ok(Some(line)) = lines.next_line().await { + let req: Value = serde_json::from_str(&line).unwrap(); + let result = match req["params"]["command"].as_str().unwrap_or_default() { + "project.inspect" => json!({"activeSequence": 21}), + // The app's own active sequence has since moved on to 54; only an explicit + // `item` still gets 21's data (an unscoped call would wrongly answer for 54). + "sequence.inspect" => json!({"id": req["params"]["params"]["item"].as_i64().unwrap_or(54)}), + _ => Value::Null, + }; + let line = format!("{}\n", json!({"id": req["id"], "ok": true, "result": result})); + w.write_all(line.as_bytes()).await.unwrap(); + } + }); + let m = FilmcraftMcp::bridge(&addr).unwrap(); + let v = m.document().await.unwrap(); + assert_eq!(v["project"]["activeSequence"], json!(21), "{v}"); + assert_eq!(v["sequence"]["id"], json!(21), "sequence must describe the reported active id, not whatever is active now: {v}"); + } + + /// `activeSequence` is a `u64` (`ItemId`'s representation; `item_p` parses the `item` param + /// with `u64_p`), so an id above `i64::MAX` must still round-trip through `document()`: + /// reading it with `as_i64` would silently return `None` and make `sequence` go null instead + /// of inspecting the (valid) id project.inspect reported. + #[tokio::test] + async fn doc_inspect_handles_an_active_sequence_id_above_i64_max() { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt}; + const HUGE: u64 = i64::MAX as u64 + 1; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + tokio::spawn(async move { + let (s, _) = listener.accept().await.unwrap(); + let (r, mut w) = s.into_split(); + let mut lines = tokio::io::BufReader::new(r).lines(); + while let Ok(Some(line)) = lines.next_line().await { + let req: Value = serde_json::from_str(&line).unwrap(); + let result = match req["params"]["command"].as_str().unwrap_or_default() { + "project.inspect" => json!({"activeSequence": HUGE}), + // Echoes back whatever `item` it was actually asked for, so the test can + // check that `document()` asked for `HUGE` rather than nothing at all. + "sequence.inspect" => json!({"id": req["params"]["params"]["item"]}), + _ => Value::Null, + }; + let line = format!("{}\n", json!({"id": req["id"], "ok": true, "result": result})); + w.write_all(line.as_bytes()).await.unwrap(); + } + }); + let m = FilmcraftMcp::bridge(&addr).unwrap(); + let v = m.document().await.unwrap(); + assert_eq!(v["project"]["activeSequence"].as_u64(), Some(HUGE), "{v}"); + assert_eq!(v["sequence"]["id"].as_u64(), Some(HUGE), "an id above i64::MAX must still be inspected, not dropped to a null sequence: {v}"); + } } diff --git a/crates/color/src/lut.rs b/crates/color/src/lut.rs index 67590eb12..73879a5b0 100644 --- a/crates/color/src/lut.rs +++ b/crates/color/src/lut.rs @@ -308,7 +308,11 @@ impl Lut { if let Some(sh) = &self.shaper { let _ = writeln!(s, "LUT_1D_SIZE {}", sh.size()); if both { - if sh.domain_min != [0.0; 3] || sh.domain_max != [1.0; 3] { + let uniform = sh.domain_min.iter().all(|v| *v == sh.domain_min[0]) && sh.domain_max.iter().all(|v| *v == sh.domain_max[0]); + if !uniform { + // with both tables, DOMAIN_MIN/DOMAIN_MAX belong to the shaper + write_domain(&mut s, sh.domain_min, sh.domain_max); + } else if sh.domain_min != [0.0; 3] || sh.domain_max != [1.0; 3] { let _ = writeln!(s, "LUT_1D_INPUT_RANGE {} {}", sh.domain_min[0], sh.domain_max[0]); } } else { @@ -650,4 +654,16 @@ mod tests { let mesh257 = (0..257).map(|i| i.to_string()).collect::>().join(" ") + "\n"; assert!(Lut::parse_3dl(&mesh257).is_err()); } + + #[test] + fn cube_shaper_per_channel_domain_round_trips() { + let mut text = String::from("LUT_1D_SIZE 2\nLUT_3D_SIZE 2\nDOMAIN_MAX 1 2 3\n"); + for _ in 0..10 { + text.push_str("0 0 0\n"); + } + let a = Lut::parse_cube(&text).unwrap(); + assert_eq!(a.shaper.as_ref().unwrap().domain_max, [1.0, 2.0, 3.0]); + let b = Lut::parse_cube(&a.to_cube()).unwrap(); + assert_eq!(b.shaper.unwrap().domain_max, [1.0, 2.0, 3.0]); + } } diff --git a/crates/engine/src/clip_ops_tests.rs b/crates/engine/src/clip_ops_tests.rs index 262b1ec80..940c00af5 100644 --- a/crates/engine/src/clip_ops_tests.rs +++ b/crates/engine/src/clip_ops_tests.rs @@ -1059,3 +1059,26 @@ fn linked_rate_stretch_keeps_one_speed_when_a_neighbour_blocks_the_sound() { assert!((pic.speed - sound.speed).abs() < 1e-12); assert!((r["speed"].as_f64().unwrap() - 60.0 / 70.0).abs() < 1e-9); } + +/// #2358: Toggle Animation on a parameter an older effect instance lacks (it was added to the +/// effect later) creates it from its default and animates it, instead of "no such param". +#[test] +fn toggle_animation_creates_a_parameter_missing_from_an_older_instance() { + let mut s = crate::Session::default(); + s.execute("file.openDemoProject", serde_json::json!({})).unwrap(); + let seq = s.state.active_sequence.unwrap(); + let clip = s.active_sequence().unwrap().video_tracks[0].items[0].id; + // an older project: Motion without its Rotation entry + { + let p = std::sync::Arc::make_mut(&mut s.project); + let q = p.sequence_mut(seq).unwrap(); + let (_, it) = q.find_item_mut(clip).unwrap(); + let m = it.effects.iter_mut().find(|e| e.effect == "motion").unwrap(); + m.params.remove("rotation"); + } + // `"mask": null` is no mask, as in effects.setParam + s.execute("effects.toggleAnimation", serde_json::json!({"clip": clip.0, "effect": "motion", "param": "rotation", "mask": null})).unwrap(); + let it = s.active_sequence().unwrap().find_item(clip).unwrap().1.clone(); + let rot = it.effect("motion").unwrap().param("rotation").unwrap(); + assert!(rot.is_animated(), "the stopwatch is on"); +} diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index f7737f571..162fb18ca 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -2860,10 +2860,17 @@ fn build() -> Vec { "Track Settings", [], None, - r#"{"track":"V1"|id,"locked":bool?,"syncLock":bool?,"enabled":bool?,"muted":bool?,"solo":bool?,"name":str?,"volumeDb":f64?,"pan":f64?}"#, + r#"{"track":"V1"|id,"locked":bool?,"syncLock":bool?,"enabled":bool?,"muted":bool?,"solo":bool?,"name":str?,"volumeDb":f64?,"pan":f64?,"label":"Violet|Iris|…"|""|null?}"#, has_seq, |s, p| { let tr = track_p(s, p, "track", "timeline.setTrack")?.ok_or_else(|| bad("timeline.setTrack", "need `track`"))?; + // colour label: a label name, or "" / "None" / null for no colour + let label = match p.get("label") { + None => None, + Some(Value::Null) => Some(None), + Some(Value::String(n)) if n.is_empty() || n.eq_ignore_ascii_case("none") => Some(None), + Some(v) => Some(Some(v.as_str().and_then(Label::from_name).ok_or_else(|| bad("timeline.setTrack", format!("unknown label {v}")))?)), + }; let p = p.clone(); s.edit_sequence("Track Settings", |q, _, _| { let t = q.track_mut(tr).ok_or(filmcraft_edit::EditError::NoTrack(tr))?; @@ -2891,6 +2898,9 @@ fn build() -> Vec { if let Some(v) = f64_p(&p, "pan") { t.pan = v.clamp(-100.0, 100.0); } + if let Some(l) = label { + t.label = l; + } Ok(()) })?; Ok(Value::Null) @@ -3111,6 +3121,14 @@ fn build() -> Vec { _ => None, } .ok_or_else(|| bad("effects.toggleAnimation", "no such effect"))?; + // a parameter an older instance lacks (added to the effect later) starts from its + // default, as the UI shows it, without touching an existing one + if p.get("mask").is_none_or(Value::is_null) + && !e.params.contains_key(&pid) + && let Some(d) = e.def().and_then(|d| d.param(&pid)) + { + e.params.insert(pid.clone(), filmcraft_project::Param::new(d.default.clone())); + } crate::masks::target_param(e, p, &pid).ok_or_else(|| bad("effects.toggleAnimation", "no such param"))?.toggle_animation(mt); Ok(()) })?; @@ -4259,6 +4277,7 @@ pub fn inspect_sequence(s: &Session, id: ItemId, q: &filmcraft_project::Sequence let tr = |t: &filmcraft_project::Track| { json!({ "id": t.id.0, "name": t.name, "locked": t.locked, "syncLock": t.sync_lock, "enabled": t.enabled, "muted": t.muted, "solo": t.solo, + "label": t.label.map(|l| l.name()), "items": t.items.iter().map(|i| json!({ "clip": i.id.0, "item": i.item.0, "name": i.name, "start": i.start.0, "duration": i.duration.0, "startFrame": rate.frame_at(i.start), "durationFrames": rate.frame_at(i.duration), "sourceIn": i.source_in.0, "speed": i.speed, "reverse": i.reverse, diff --git a/crates/engine/src/graphic_templates.rs b/crates/engine/src/graphic_templates.rs index f5053756b..00b66c72f 100644 --- a/crates/engine/src/graphic_templates.rs +++ b/crates/engine/src/graphic_templates.rs @@ -775,7 +775,12 @@ fn set_char_style(s: &mut Session, p: &Value) -> Result { }; let tl = s.playhead(); let (start, end) = (u64_p(p, "start").map(|v| v as usize), u64_p(p, "end").map(|v| v as usize)); - let runs = s.edit_sequence("Character Style", move |q, _, _| { + // A pointer gesture shares one undo step only within the same clip, layer and range. + let merge = bool_p(p, "merge").unwrap_or(false).then(|| format!("graphics.setCharStyle:{}:{ei}:{start:?}:{end:?}", clip.0)); + if bool_p(p, "begin").unwrap_or(false) { + s.history.merge_key = None; + } + let runs = s.edit_sequence_as("Character Style", merge.as_deref(), move |q, _, _| { let (_, it) = q.find_item_mut(clip).ok_or(filmcraft_edit::EditError::NoItem(clip))?; let mt = it.source_time_at(tl.clamp(it.start, it.end() - Tick(1))); let e = &mut it.effects[ei]; @@ -1217,7 +1222,7 @@ pub fn commands() -> Vec { "graphics.setCharStyle", "Character Style", &[], - r##"{"clip":id?,"layer":n|name?,"start":char=0,"end":char=len,"style":{"font":str,"fontStyle":str,"size":px,"color":"#rrggbb","bold":bool,"italic":bool,"underline":bool,"tracking":n,"baselineShift":px,"caps":"normal|all caps|small caps"},"clear":bool? (remove styles from the range)}"##, + r##"{"clip":id?,"layer":n|name?,"start":char=0,"end":char=len,"style":{"font":str,"fontStyle":str,"size":px,"color":"#rrggbb","bold":bool,"italic":bool,"underline":bool,"tracking":n,"baselineShift":px,"caps":"normal|all caps|small caps"},"clear":bool? (remove styles from the range),"merge":bool?,"begin":bool? (start a new undo gesture)}"##, has_graphic, set_char_style, ), diff --git a/crates/engine/src/graphic_templates_tests.rs b/crates/engine/src/graphic_templates_tests.rs index 98a55a588..e49c0ec8f 100644 --- a/crates/engine/src/graphic_templates_tests.rs +++ b/crates/engine/src/graphic_templates_tests.rs @@ -380,3 +380,52 @@ fn copy_paste_duplicates_a_graphic_clip() { assert!((pasted[0].start.seconds() - 620.0).abs() < 0.05, "at the playhead: {:?}", pasted[0].start); assert_eq!(texts(&s, pasted[0].id), vec!["Lyric".to_string()]); } + +#[test] +fn character_style_drag_merges_only_its_target_and_range() { + let mut s = demo("gt-char-drag"); + let c = clip_of(&s.execute("graphics.newText", json!({"text":"Alpha Bravo"})).unwrap()); + let before = s.project.clone(); + let n = s.history.undo.len(); + for i in 0..8 { + s.execute("graphics.setCharStyle", json!({"clip":c.0,"start":0,"end":5,"style":{"size":105+i*5},"merge":true,"begin":i==0})).unwrap(); + } + assert_eq!(s.history.undo.len(), n + 1, "one drag, one undo step"); + let after = s.project.clone(); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(*s.project, *before); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(*s.project, *after); + s.execute("graphics.setCharStyle", json!({"clip":c.0,"start":0,"end":5,"style":{"size":150},"merge":true,"begin":true})).unwrap(); + assert_eq!(s.history.undo.len(), n + 2, "a new gesture on the same range starts a new step"); + s.execute("graphics.setCharStyle", json!({"clip":c.0,"start":6,"end":11,"style":{"size":160},"merge":true})).unwrap(); + assert_eq!(s.history.undo.len(), n + 3, "another character range does not merge"); + let before = s.project.clone(); + assert!(s.execute("graphics.setCharStyle", json!({"clip":c.0,"start":3,"end":3,"style":{"size":170},"merge":true})).is_err()); + assert_eq!(*s.project, *before); + assert_eq!(s.history.undo.len(), n + 3, "an invalid edit adds no undo step"); +} + +#[test] +fn character_style_merge_preserves_default_calls_and_separate_clips() { + let mut s = demo("gt-char-merge-targets"); + let a = clip_of(&s.execute("graphics.newText", json!({"text":"First", "newClip":true})).unwrap()); + let b = clip_of(&s.execute("graphics.newText", json!({"text":"Other", "newClip":true})).unwrap()); + assert_ne!(a, b); + let n = s.history.undo.len(); + for c in [a, b] { + s.execute("graphics.setCharStyle", json!({"clip":c.0,"start":0,"end":5,"style":{"size":120},"merge":true})).unwrap(); + } + assert_eq!(s.history.undo.len(), n + 2, "distinct clips do not merge"); + for size in [130, 140] { + s.execute("graphics.setCharStyle", json!({"clip":b.0,"style":{"size":size}})).unwrap(); + } + assert_eq!(s.history.undo.len(), n + 4, "default calls retain separate undo steps"); + let added = s.execute("graphics.newText", json!({"clip":b.0,"text":"Layer"})).unwrap(); + assert_eq!(clip_of(&added), b); + let n = s.history.undo.len(); + for layer in [0, 1] { + s.execute("graphics.setCharStyle", json!({"clip":b.0,"layer":layer,"start":0,"end":5,"style":{"size":150},"merge":true})).unwrap(); + } + assert_eq!(s.history.undo.len(), n + 2, "distinct layers do not merge"); +} diff --git a/crates/engine/src/multicam.rs b/crates/engine/src/multicam.rs index 66e36308f..6145bbbd3 100644 --- a/crates/engine/src/multicam.rs +++ b/crates/engine/src/multicam.rs @@ -243,17 +243,19 @@ fn can_synchronize(s: &Session) -> std::result::Result<(), String> { if sel.len() < 2 { Err("select at least two clips to synchronize".into()) } else { Ok(()) } } fn has_items(s: &Session) -> std::result::Result<(), String> { - if s.state.project_selection.iter().any(|i| sync::project_clip(&s.project, *i).is_some()) { + if selected_items(s).iter().any(|i| sync::project_clip(&s.project, *i).is_some()) { Ok(()) } else { - Err("select clips in the Project panel".into()) + Err("select clips in the Project panel or the Timeline".into()) } } +/// Two clips or more: Merge Clips then opens its dialog, which says what is wrong with a +/// selection it can't merge (two video clips, no audio clip) instead of a greyed-out menu item. fn has_two_items(s: &Session) -> std::result::Result<(), String> { - if s.state.project_selection.iter().filter(|i| sync::project_clip(&s.project, **i).is_some()).count() >= 2 { + if selected_items(s).iter().filter(|i| sync::project_clip(&s.project, **i).is_some()).count() >= 2 { Ok(()) } else { - Err("select a video clip and audio clips in the Project panel".into()) + Err("select a video clip and audio clips in the Project panel or the Timeline".into()) } } @@ -262,10 +264,30 @@ fn has_two_items(s: &Session) -> std::result::Result<(), String> { fn items_p(s: &Session, p: &Value) -> 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(), + None => selected_items(s), } } +/// The items Merge Clips and Create Multi-Camera Source Sequence work on without `items`: the +/// clips selected in the Project panel, or, with none there, the media of the clips selected in +/// the Timeline (with their linked partners), once each in selection order (#1362). +pub fn selected_items(s: &Session) -> Vec { + if s.state.project_selection.iter().any(|i| sync::project_clip(&s.project, *i).is_some()) { + return s.state.project_selection.clone(); + } + let Some(q) = s.active_sequence() else { return Vec::new() }; + let mut out = Vec::new(); + for c in with_links(s, &s.state.selection) { + if let Some((_, it)) = q.find_item(c) + && sync::project_clip(&s.project, it.item).is_some() + && !out.contains(&it.item) + { + out.push(it.item); + } + } + out +} + fn method_p(p: &Value, cmd: &str) -> Result { Method::from_params(p).map_err(|e| bad(cmd, e)) } diff --git a/crates/engine/src/multicam_tests.rs b/crates/engine/src/multicam_tests.rs index 1a8c3528b..c9221726f 100644 --- a/crates/engine/src/multicam_tests.rs +++ b/crates/engine/src/multicam_tests.rs @@ -328,6 +328,59 @@ const H: u32 = 90; /// Three 4-second cameras (different scenes, own audio), a multi-camera source sequence by In /// points, and a sequence holding one multi-camera clip on V1/A1. Returns (session, camera items, /// source sequence, edit sequence). +/// #1362: with nothing selected in the Project panel, Create Multi-Camera Source Sequence and Merge +/// Clips take the media of the clips selected in the Timeline. +#[test] +fn multicam_from_clips_selected_in_the_timeline() { + let dir = tmp_dir("mc-timeline"); + let files: Vec = ["a.mov", "b.mov", "c.mov"].iter().map(|f| dir.join(f)).collect(); + for (f, sc) in files.iter().zip([DemoScene::OceanSunset, DemoScene::CityNight, DemoScene::Aurora]) { + make_movie(f, sc, W, H, 48); + } + let mut s = Session::default(); + let refs: Vec<&Path> = files.iter().map(|p| p.as_path()).collect(); + let items = import(&mut s, &refs); + let mut p = (*s.project).clone(); + let edit = + p.new_sequence("Edit", filmcraft_project::SequenceSettings { width: W, height: H, frame_rate: FrameRate::FPS_24, ..Default::default() }, 1, 1, None); + s.project = std::sync::Arc::new(p); + s.state.active_sequence = Some(edit); + for (k, item) in items.iter().enumerate() { + s.set_playhead(FrameRate::FPS_24.tick_of(k as i64 * 48)); + s.execute("source.open", json!({"item": item.0})).unwrap(); + s.execute("source.overwrite", json!({})).unwrap(); + } + s.execute("project.select", json!({"items": []})).unwrap(); + s.execute("timeline.select", json!({"clips": []})).unwrap(); + assert!(!s.is_enabled("clip.createMulticam"), "nothing selected anywhere"); + let clips: Vec = s.active_sequence().unwrap().video_tracks[0].items.iter().map(|c| c.id.0).collect(); + assert_eq!(clips.len(), 3); + s.execute("timeline.select", json!({"clips": clips})).unwrap(); + assert!(s.is_enabled("clip.createMulticam") && s.is_enabled("clip.mergeClips")); + // Merge Clips opens on them, and says why it can't merge three video clips + assert!(s.execute("clip.mergeClips", json!({"method": "in"})).unwrap_err().to_string().contains("one video clip")); + let r = s.execute("clip.createMulticam", json!({"method": "in"})).unwrap(); + let src = ItemId(r["sequence"].as_u64().unwrap()); + let cameras: Vec = s.project.sequence(src).unwrap().video_tracks.iter().filter_map(|t| t.items.first().map(|c| c.item)).collect(); + assert_eq!(cameras, items, "one camera per selected clip's media, in selection order"); + + // one video clip and an audio-only one, selected in the Timeline: Merge Clips takes them + s.state.active_sequence = Some(edit); + let wav = dir.join("room.wav"); + std::fs::write(&wav, crate::voiceover::write_wav_f32(&vec![0.0; 48_000 * 2], 48_000)).unwrap(); + let audio = import(&mut s, &[wav.as_path()])[0]; + s.set_playhead(FrameRate::FPS_24.tick_of(200)); + s.execute("source.open", json!({"item": audio.0})).unwrap(); + s.execute("source.overwrite", json!({})).unwrap(); + let q = s.active_sequence().unwrap(); + let a = q.audio_tracks.iter().flat_map(|t| &t.items).find(|c| c.item == audio).unwrap().id.0; + s.execute("project.select", json!({"items": []})).unwrap(); + s.execute("timeline.select", json!({"clips": [clips[0], a], "linked": false})).unwrap(); + assert!(s.is_enabled("clip.mergeClips")); + let r = s.execute("clip.mergeClips", json!({"method": "in"})).unwrap(); + assert!(r["item"].as_u64().is_some(), "{r}"); +} + fn mc_session(audio: &str) -> (Session, Vec, ItemId, ItemId) { let dir = tmp_dir("mc-edit"); let files: Vec = ["a.mov", "b.mov", "c.mov"].iter().map(|f| dir.join(f)).collect(); diff --git a/crates/engine/src/previews.rs b/crates/engine/src/previews.rs index 5b75c02f4..859ba1bb9 100644 --- a/crates/engine/src/previews.rs +++ b/crates/engine/src/previews.rs @@ -535,8 +535,7 @@ impl PreviewStore { } } let path = self.dir()?.join(name); - let bytes = std::fs::read(&path).ok()?; - let src = pool.open_bytes(name, bytes.into()).ok()?; + let src = open_preview_file(pool, name, &path)?; let mut g = self.sources.lock().unwrap_or_else(|e| e.into_inner()); g.push((name.to_string(), src.clone())); if g.len() > 6 { @@ -546,6 +545,21 @@ impl PreviewStore { } } +/// Open a rendered preview file. Native builds read it in place (the container's index now, +/// frames on demand): previews are full-resolution ProRes, gigabytes a minute, and reading each +/// one whole filled memory during render and playback (#2185). +#[cfg(any(unix, windows))] +pub(crate) fn open_preview_file(_pool: &MediaPool, name: &str, path: &std::path::Path) -> Option { + let reader = filmcraft_media::reader::FileReader::open(path).ok()?; + filmcraft_media::reader::open_reader_within(name, std::sync::Arc::new(reader), &filmcraft_codecs::reader_openers(), &[], 0).ok() +} + +#[cfg(not(any(unix, windows)))] +pub(crate) fn open_preview_file(pool: &MediaPool, name: &str, path: &std::path::Path) -> Option { + let bytes = std::fs::read(path).ok()?; + pool.open_bytes(name, bytes.into()).ok() +} + fn need_state(seg: &Segment) -> BarState { match seg.need { Need::None => BarState::None, @@ -639,7 +653,6 @@ pub fn render(s: &mut Session, mode: RenderMode, p: &Value) -> Result { let provider = s.media.full_res_provider(project.clone(), s.services.clone()); let (prog, res) = (job.progress.clone(), job.result.clone()); let nseg = todo.len(); - let pool = s.media.clone(); let run = move || { let t0 = web_time::Instant::now(); let mut bytes = 0u64; @@ -679,8 +692,6 @@ pub fn render(s: &mut Session, mode: RenderMode, p: &Value) -> Result { break; } store.add_video(&name); - // open it now so the first playback doesn't wait for the file read - let _ = store.open(&pool, &name); } Err(e) => { let _ = std::fs::remove_file(&part); diff --git a/crates/engine/src/previews_tests.rs b/crates/engine/src/previews_tests.rs index dfc6b7a48..95c6289c2 100644 --- a/crates/engine/src/previews_tests.rs +++ b/crates/engine/src/previews_tests.rs @@ -282,3 +282,29 @@ fn float_wav_round_trip() { fn bar_state_serializes_lowercase() { assert_eq!(serde_json::to_value(BarState::Green).unwrap(), json!("green")); } + +/// #2185: preview files are read in place, never whole. Rendered previews still play from their +/// files, and a file only a whole-file reader could open is refused rather than loaded. +#[test] +fn preview_files_are_streamed_not_read_whole() { + let (mut s, _) = session("stream"); + let v = s.execute("sequence.renderEffectsInToOut", json!({"wait": true})).unwrap(); + job_ok(&s, &v); + let seq = s.state.active_sequence.unwrap(); + assert!(s.previews.frame(&s.media, &s.project, seq, 5, 1.0).is_some(), "a rendered frame plays from its file"); + // a still image opens only from its whole bytes: as a preview file it is not opened + let dir = s.previews.dir().unwrap(); + let png = dir.join("still.png"); + // a 1×1 PNG + const PNG: &[u8] = &[ + 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 13, 73, 68, 65, 84, + 120, 218, 99, 100, 96, 248, 95, 15, 0, 2, 135, 1, 128, 235, 71, 186, 146, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130, + ]; + std::fs::write(&png, PNG).unwrap(); + assert!(s.media.open_bytes("still.png", PNG.into()).is_ok(), "the image opens from its bytes"); + // (the web, without a file reader, still reads preview files whole) + if cfg!(any(unix, windows)) { + assert!(crate::previews::open_preview_file(&s.media, "still.png", &png).is_none()); + } + let _ = std::fs::remove_dir_all(dir); +} diff --git a/crates/engine/src/settings.rs b/crates/engine/src/settings.rs index 79ecd6051..f1f39c484 100644 --- a/crates/engine/src/settings.rs +++ b/crates/engine/src/settings.rs @@ -109,6 +109,9 @@ pub struct AppearancePrefs { /// "UI Scale": `auto` (default: the scale the operating system reports) or a fixed /// percentage of [`UI_SCALES`] (`100` = one point per physical pixel). pub ui_scale: String, + /// "Animate zoom, scrolling and menus" (#2325): off, the Timeline zooms and scrolls in one + /// step and menus, collapsing sections and tooltips appear without a fade. + pub animations: bool, } impl Default for AppearancePrefs { @@ -122,6 +125,7 @@ impl Default for AppearancePrefs { accessible_contrast: false, highlight_color: DEFAULT_HIGHLIGHT.into(), ui_scale: "auto".into(), + animations: true, } } } @@ -1053,6 +1057,7 @@ static CATEGORIES: &[Category] = &[ b("appearance.accessibleContrast", "Accessible color contrast", true), f("appearance.highlightColor", "Highlight Color", Kind::Color, true), f("appearance.uiScale", "UI Scale", Kind::Choice(UI_SCALES), true), + b("appearance.animations", "Animate zoom, scrolling and menus", true), ], }, Category { diff --git a/crates/engine/src/track_params_tests.rs b/crates/engine/src/track_params_tests.rs index 6c243dfa5..707ad38cc 100644 --- a/crates/engine/src/track_params_tests.rs +++ b/crates/engine/src/track_params_tests.rs @@ -114,3 +114,38 @@ fn place_on_an_audio_track_places_the_sound_there_and_nothing_else() { s.execute("timeline.place", json!({"item": with_picture.0, "seconds": 600.0})).unwrap(); assert_eq!(counts(&s), [2, 1, 2, 2]); } + +/// Track colour labels (#1186): set, shown by `sequence.inspect`, saved with the project, +/// cleared with "" / null, one undo step each; unknown names are refused. +#[test] +fn a_track_carries_a_colour_label() { + let mut s = demo(); + let label = |s: &mut Session, kind: &str| s.execute("sequence.inspect", json!({})).unwrap()[kind][0]["label"].clone(); + assert_eq!(label(&mut s, "video"), json!(null)); + s.execute("timeline.setTrack", json!({"track": "V1", "label": "Forest"})).unwrap(); + s.execute("timeline.setTrack", json!({"track": "A1", "label": "Mango"})).unwrap(); + assert_eq!(label(&mut s, "video"), json!("Forest")); + assert_eq!(label(&mut s, "audio"), json!("Mango")); + // saved with the project, and a project from before the field still loads + let text = serde_json::to_string(&*s.project).unwrap(); + let back: filmcraft_project::Project = serde_json::from_str(&text).unwrap(); + let q = back.sequence(s.state.active_sequence.unwrap()).unwrap(); + assert_eq!(q.video_tracks[0].label, Some(filmcraft_project::Label::Forest)); + let mut old = serde_json::to_value(&q.video_tracks[0]).unwrap(); + old.as_object_mut().unwrap().remove("label"); + let old: filmcraft_project::Track = serde_json::from_value(old).unwrap(); + assert_eq!(old.label, None); + // a plain track does not write the key at all + assert!(serde_json::to_value(&q.video_tracks[1]).unwrap().get("label").is_none()); + // clear, undo, refuse + s.execute("timeline.setTrack", json!({"track": "V1", "label": ""})).unwrap(); + assert_eq!(label(&mut s, "video"), json!(null)); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(label(&mut s, "video"), json!("Forest")); + s.execute("timeline.setTrack", json!({"track": "V1", "label": null})).unwrap(); + assert_eq!(label(&mut s, "video"), json!(null)); + assert!(matches!(s.execute("timeline.setTrack", json!({"track": "V1", "label": "Chartreuse"})), Err(EngineError::BadParams { .. }))); + // other settings leave the label alone + s.execute("timeline.setTrack", json!({"track": "A1", "muted": true})).unwrap(); + assert_eq!(label(&mut s, "audio"), json!("Mango")); +} diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index fb55786db..5d95c5b80 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -760,6 +760,9 @@ pub struct Track { /// Audio Track Mixer state: automation mode and lanes, sends, output, record arm. #[serde(default)] pub mixer: MixerStrip, + /// Colour label tinting the track's header and lane background (`None` = no colour). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option