Merge main into #2452 (resolve append conflicts)

# Conflicts:
#	crates/ui-egui/src/i18n/es.tsv
#	crates/ui-egui/src/i18n/fr.tsv
#	crates/ui-egui/src/i18n/id.tsv
#	crates/ui-egui/src/i18n/it.tsv
#	crates/ui-egui/src/i18n/ja.tsv
#	crates/ui-egui/src/i18n/ko.tsv
#	crates/ui-egui/src/i18n/pt-br.tsv
#	crates/ui-egui/src/i18n/zh-cn.tsv
#	crates/ui-egui/tests/graphics_ui.rs
This commit is contained in:
Brandon Thomas
2026-10-19 12:54:10 -04:00
50 changed files with 4498 additions and 66 deletions
+3 -2
View File
@@ -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 |
+83 -1
View File
@@ -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<Value, AutomationError> {
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}");
}
}
+17 -1
View File
@@ -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::<Vec<_>>().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]);
}
}
+23
View File
@@ -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");
}
+20 -1
View File
@@ -2860,10 +2860,17 @@ fn build() -> Vec<CommandSpec> {
"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<CommandSpec> {
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<CommandSpec> {
_ => 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,
+7 -2
View File
@@ -775,7 +775,12 @@ fn set_char_style(s: &mut Session, p: &Value) -> Result<Value> {
};
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<CommandSpec> {
"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,
),
@@ -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");
}
+27 -5
View File
@@ -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<ItemId> {
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<ItemId> {
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> {
Method::from_params(p).map_err(|e| bad(cmd, e))
}
+53
View File
@@ -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<PathBuf> = ["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<u64> = 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<ItemId> = 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, ItemId) {
let dir = tmp_dir("mc-edit");
let files: Vec<PathBuf> = ["a.mov", "b.mov", "c.mov"].iter().map(|f| dir.join(f)).collect();
+16 -5
View File
@@ -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<SharedSource> {
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<SharedSource> {
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<Value> {
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<Value> {
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);
+26
View File
@@ -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);
}
+5
View File
@@ -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 {
+35
View File
@@ -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"));
}
+4
View File
@@ -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<Label>,
}
impl Track {
@@ -780,6 +783,7 @@ impl Track {
pan: 0.0,
effects: Vec::new(),
mixer: MixerStrip::default(),
label: None,
}
}
pub fn end(&self) -> Tick {
+29 -4
View File
@@ -416,11 +416,18 @@ pub fn format_time(t: Tick, rate: FrameRate, drop_frame: bool, display: TimeDisp
format!("{}{}+{:02}", if neg { "-" } else { "" }, a / per_ft, a % per_ft)
}
TimeDisplay::AudioSamples => {
let secs = t.0.div_euclid(TICKS_PER_SECOND);
let rem = Tick(t.0.rem_euclid(TICKS_PER_SECOND)).to_units_floor(sample_rate);
format!("{:02}:{:02}:{:02}:{rem:05}", secs / 3600, (secs / 60) % 60, secs % 60)
let neg = t.0 < 0;
let a = t.0.unsigned_abs();
let tps = TICKS_PER_SECOND.unsigned_abs();
let secs = a / tps;
let rem = Tick((a % tps) as i64).to_units_floor(sample_rate);
format!("{}{:02}:{:02}:{:02}:{rem:05}", if neg { "-" } else { "" }, secs / 3600, (secs / 60) % 60, secs % 60)
}
TimeDisplay::Seconds => {
let s = format!("{:.3}", t.seconds());
// A tiny negative time rounds to zero but keeps its sign: drop it.
if s == "-0.000" { "0.000".to_string() } else { s }
}
TimeDisplay::Seconds => format!("{:.3}", t.seconds()),
}
}
@@ -507,6 +514,24 @@ mod tests {
use super::*;
use proptest::prelude::*;
#[test]
fn seconds_display_has_no_negative_zero() {
let f = |t: i64| format_time(Tick(t), FrameRate::default(), false, TimeDisplay::Seconds, 48000);
assert_eq!(f(-1), "0.000");
assert_eq!(f(-2), "0.000");
assert_eq!(f(-(TICKS_PER_SECOND / 2500)), "0.000");
assert_eq!(f(-TICKS_PER_SECOND), "-1.000");
assert_eq!(f(-TICKS_PER_SECOND / 1000), "-0.001");
}
#[test]
fn audio_samples_negative_time_has_leading_minus() {
let r = FrameRate::new(30000, 1001);
let f = |t| format_time(Tick(t), r, false, TimeDisplay::AudioSamples, 48000);
assert_eq!(f(-TICKS_PER_SECOND), "-00:00:01:00000");
assert_eq!(f(-(TICKS_PER_SECOND + TICKS_PER_SECOND / 2)), "-00:00:01:24000");
}
/// Damaged files and projects can carry zero / negative rates and timebases, and users or
/// agents can type huge timecodes: these used to panic (division by zero, overflow,
/// crossed `clamp` bounds).
+4 -4
View File
@@ -18,7 +18,8 @@ the committed span and undo. Set `FILMCRAFT_UI_SNAPSHOT_DIR` to render screensho
## Localisation
Edit > Language offers English, Japanese, Spanish, Brazilian Portuguese, Ukrainian, Simplified
Chinese, German, Russian, French, Indonesian, Korean and Italian (which cover the whole interface). The language is stored in the engine's `general.interfaceLanguage` preference,
Chinese, German, Russian, French, Indonesian, Korean and Italian (all but Ukrainian and Russian cover
the whole interface; those two cover the core menus). The language is stored in the engine's `general.interfaceLanguage` preference,
restored on startup, and also available in Settings > General.
Its default, System Language (`system`), follows the operating system: the first of the user's
preferred languages that the interface has (the host supplies them through
@@ -32,11 +33,10 @@ fills translated templates, and `i18n::t` translates names from registries. Plac
(including user filenames containing braces) are inserted literally. Catalog translations are
original work using ordinary language, without proprietary localisation resources.
Spanish, Japanese, Simplified Chinese, French and Brazilian Portuguese cover menus, panels, dialogs, settings and registry labels. Searches accept both the
Spanish, Japanese, Simplified Chinese, French, Brazilian Portuguese and German cover menus, panels, dialogs, settings and registry labels. Searches accept both the
translated label and its English source, including Unicode capitals. Project content, command ids
and preference values retain their original values. Engine errors, CLI and MCP messages remain
English. Ukrainian and Russian currently cover the core menus, German every menu; they fall back
to English elsewhere.
English. Ukrainian and Russian currently cover the core menus; they fall back to English elsewhere.
Verification: `cargo test -p filmcraft-ui-egui` checks catalog syntax, duplicate keys, placeholders,
literal/menu/registry coverage and UI behaviour; `cargo xtask ci` runs the workspace gates. Visual
+11
View File
@@ -97,6 +97,11 @@ impl FilmcraftApp {
Action::Activate { panel } => Some(*panel),
_ => None,
};
// a panel closed while floating remembers where; closed while docked, it forgets (#1985)
let closing = match &action {
Action::Close { panel } => Some((*panel, self.ui.floating.iter().find(|g| g.panels.contains(panel)).map(|g| g.rect))),
_ => None,
};
self.commit_dock(|shared| {
crate::dock_shared::check_last_docked(shared, &action)?;
shared.apply(action).map_err(|error| error.to_string())
@@ -104,6 +109,12 @@ impl FilmcraftApp {
if let Some(panel) = reveal {
self.pending_panel_raise = Some(panel);
}
if let Some((panel, rect)) = closing {
self.ui.closed_floating.retain(|(p, _)| *p != panel);
if let Some(rect) = rect {
self.ui.closed_floating.push((panel, rect));
}
}
if relocating
|| self.ui.keys.maximized.is_some_and(|panel| !self.ui.dock.contains(panel) && !self.ui.floating.iter().any(|group| group.panels.contains(&panel)))
{
File diff suppressed because it is too large Load Diff
+4
View File
@@ -1266,6 +1266,7 @@
59.94 fps Non Drop-Frame 59,94 fps sin eliminación de fotogramas
ASC CDL ASC CDL
Accessible color contrast Contraste de color accesible
Animate zoom, scrolling and menus Animar el zoom, el desplazamiento y los menús
Action-Safe Margin Margen seguro de acción
Active Monitor Monitor activo
Add Añadir
@@ -3610,6 +3611,9 @@
Shows the version, community links and credits in a FilmCraft window. Muestra la versión, los enlaces de la comunidad y los créditos en una ventana de FilmCraft.
Couldn't open your web browser No se pudo abrir el navegador web
Open this address in your browser yourself: Abre esta dirección tú mismo en el navegador:
# View ▸ Reset Interface Zoom (#2253).
Reset Interface Zoom Restablecer zoom de la interfaz
Track Color Color de pista
Open a sequence to search its graphics Abre una secuencia para buscar en sus gráficos
No text graphics in this sequence. Add text with the Type tool. Esta secuencia no tiene gráficos con texto. Añade texto con la herramienta Texto.
No matching text. Ningún texto coincide.
Can't render this file because it contains an unexpected character in line 3 and column 95.
+4
View File
@@ -1266,6 +1266,7 @@
59.94 fps Non Drop-Frame 59,94 fps non-drop frame
ASC CDL ASC CDL
Accessible color contrast Contraste des couleurs accessible
Animate zoom, scrolling and menus Animer le zoom, le défilement et les menus
Action-Safe Margin Marge de sécurité de l'action
Active Monitor Moniteur actif
Add Ajouter
@@ -3577,6 +3578,9 @@
Export the labels to a preset file… Exporter les étiquettes dans un fichier de paramètre prédéfini…
Show the presets folder Afficher le dossier des paramètres prédéfinis
Delete this preset Supprimer ce paramètre prédéfini
# View ▸ Reset Interface Zoom (#2253).
Reset Interface Zoom Réinitialiser le zoom de l'interface
Track Color Couleur de la piste
Open a sequence to search its graphics Ouvrez une séquence pour rechercher dans ses graphiques
No text graphics in this sequence. Add text with the Type tool. Aucun graphique avec du texte dans cette séquence. Ajoutez du texte avec l'outil Texte.
No matching text. Aucun texte ne correspond.
Can't render this file because it contains an unexpected character in line 5 and column 83.
+4
View File
@@ -1271,6 +1271,7 @@
59.94 fps Non Drop-Frame 59,94 fps Non Drop-Frame
ASC CDL ASC CDL
Accessible color contrast Kontras warna yang aksesibel
Animate zoom, scrolling and menus Animasikan zoom, gulir, dan menu
Action-Safe Margin Margin Aman Aksi
Active Monitor Monitor Aktif
Add Tambah
@@ -3575,6 +3576,9 @@
Appearance Settings… Pengaturan Tampilan…
Add to favorites Tambahkan ke favorit
Remove from favorites Hapus dari favorit
# View ▸ Reset Interface Zoom (#2253).
Reset Interface Zoom Atur Ulang Zoom Antarmuka
Track Color Warna Trek
Open a sequence to search its graphics Buka sekuens untuk mencari di grafisnya
No text graphics in this sequence. Add text with the Type tool. Tidak ada grafis teks di sekuens ini. Tambahkan teks dengan alat Teks.
No matching text. Tidak ada teks yang cocok.
Can't render this file because it contains an unexpected character in line 6 and column 14.
+4
View File
@@ -1263,6 +1263,7 @@
59.94 fps Non Drop-Frame 59,94 fps non drop-frame
ASC CDL ASC CDL
Accessible color contrast Contrasto dei colori accessibile
Animate zoom, scrolling and menus Anima zoom, scorrimento e menu
Action-Safe Margin Margine di sicurezza azione
Active Monitor Monitor attivo
Add Aggiungi
@@ -3601,6 +3602,9 @@
Shows the version, community links and credits in a FilmCraft window. Mostra la versione, i link della community e i riconoscimenti in una finestra di FilmCraft.
Couldn't open your web browser Impossibile aprire il browser web
Open this address in your browser yourself: Apri tu questo indirizzo nel browser:
# View ▸ Reset Interface Zoom (#2253).
Reset Interface Zoom Reimposta zoom dell'interfaccia
Track Color Colore traccia
Open a sequence to search its graphics Apri una sequenza per cercare nelle sue grafiche
No text graphics in this sequence. Add text with the Type tool. Nessuna grafica con testo in questa sequenza. Aggiungi testo con lo strumento Testo.
No matching text. Nessun testo corrispondente.
Can't render this file because it contains an unexpected character in line 3 and column 95.
+4
View File
@@ -1260,6 +1260,7 @@
59.94 fps Non Drop-Frame 59.94 fps ノンドロップフレーム
ASC CDL ASC CDL
Accessible color contrast アクセシビリティに配慮したカラーコントラスト
Animate zoom, scrolling and menus ズーム、スクロール、メニューをアニメーション表示
Action-Safe Margin アクションセーフの余白
Active Monitor アクティブなモニター
Add 加算
@@ -3609,6 +3610,9 @@
Shows the version, community links and credits in a FilmCraft window. バージョン、コミュニティへのリンク、クレジットを FilmCraft のウィンドウに表示します。
Couldn't open your web browser ウェブブラウザーを開けませんでした
Open this address in your browser yourself: このアドレスをご自身でブラウザーで開いてください:
# View ▸ Reset Interface Zoom (#2253).
Reset Interface Zoom インターフェイスのズームをリセット
Track Color トラックカラー
Open a sequence to search its graphics シーケンスを開くとグラフィックスを検索できます
No text graphics in this sequence. Add text with the Type tool. このシーケンスにはテキストのグラフィックスがありません。文字ツールでテキストを追加してください。
No matching text. 一致するテキストはありません。
Can't render this file because it contains an unexpected character in line 11 and column 59.
+4
View File
@@ -76,6 +76,7 @@
About FilmCraft FilmCraft 정보
Accent Color 강조 색상
Accessible color contrast 접근성 색상 대비
Animate zoom, scrolling and menus 확대/축소, 스크롤 및 메뉴 애니메이션
Action and Title Safe Areas 액션 및 제목 안전 영역
Action Safe Area 액션 안전 영역
Action-Safe Margin 액션 세이프 여백
@@ -3596,6 +3597,9 @@
Shows the version, community links and credits in a FilmCraft window. FilmCraft 창에 버전, 커뮤니티 링크, 제작진을 보여 줍니다.
Couldn't open your web browser 웹 브라우저를 열 수 없습니다
Open this address in your browser yourself: 이 주소를 브라우저에서 직접 여세요:
# View ▸ Reset Interface Zoom (#2253).
Reset Interface Zoom 인터페이스 확대/축소 재설정
Track Color 트랙 색상
Open a sequence to search its graphics 그래픽을 검색하려면 시퀀스를 여세요
No text graphics in this sequence. Add text with the Type tool. 이 시퀀스에는 텍스트 그래픽이 없습니다. 문자 도구로 텍스트를 추가하세요.
No matching text. 일치하는 텍스트가 없습니다.
Can't render this file because it contains an unexpected character in line 2815 and column 7.
+35 -9
View File
@@ -1103,16 +1103,17 @@ mod tests {
}
}
/// German covers at least the core menus (`core_menu_labels`, the top-level menus and the native
/// macOS app menu); every entry must still be a menu label.
/// German covers the whole interface (every entry of the Spanish catalog, the native macOS app
/// menu included), but, like French, the tests require only the core menus and the native app
/// menu, so strings added later show in English until they are translated. Every entry must
/// still have a Spanish counterpart: a renamed or removed source would otherwise leave a dead
/// German entry.
#[test]
fn german_entries_cover_the_original_menu_catalog() {
let app = crate::FilmcraftApp::new(filmcraft_engine::Session::default());
let items = crate::menus::menu_items(&app);
let known = |text: &str| crate::menus::MENUS.contains(&text) || items.iter().any(|it| it.label == text || it.path.iter().any(|p| p == text));
let (entries, _) = catalog::parse_entries(include_str!("de.tsv"));
for (_, en, _) in entries {
assert!(known(&en) || crate::menus::NATIVE_APP_MENU.contains(&en.as_str()), "not a menu label: {en}");
fn german_covers_the_menus_and_has_no_dead_entries() {
let (entries, errors) = catalog::parse_entries(include_str!("de.tsv"));
assert!(errors.is_empty(), "{errors:?}");
for (ctx, en, _) in &entries {
assert!(ctx.is_empty() && Language::Es.has(en), "German entry without a Spanish one: {ctx:?} {en:?}");
}
for en in core_menu_labels() {
assert!(Language::De.has(&en), "missing German menu label: {en}");
@@ -1123,6 +1124,21 @@ mod tests {
}
assert_eq!(Language::De.tr("Settings"), "Voreinstellungen");
assert_eq!(Language::De.tr("Quit FilmCraft"), "FilmCraft beenden");
// beyond the menus: panels, effects and their parameters, settings, status messages
for (en, de) in [
("Gaussian Blur", "Gaußscher Weichzeichner"),
("Audio Track Mixer", "Audiospur-Mischpult"),
("Sequence", "Sequenz"),
("Playhead", "Abspielposition"),
("Keyboard Shortcuts", "Tastenkürzel"),
("{n} min ago", "vor {n} Min."),
] {
assert_eq!(Language::De.tr(en), de);
}
set_current(Language::De);
assert!(matches_query("Gaussian Blur", "weichzeichner"));
assert!(matches_query("Gaussian Blur", "gaussian"));
set_current(Language::En);
}
/// French covers the whole interface (the Spanish catalog's entries and the native macOS
@@ -1318,6 +1334,16 @@ mod tests {
assert_bundled_fonts_cover(alphabet.chars().chain(Language::Fr.name().chars()).chain(letters));
}
#[test]
fn german_renders_with_bundled_fonts() {
let (entries, _) = catalog::parse_entries(include_str!("de.tsv"));
// every German letter and quotation mark, even ones no entry uses yet, and every letter,
// dash and ellipsis the entries use (symbols such as ✔ come from the English sources)
let alphabet = "ÄÖÜäöüß„“‚‘";
let letters = entries.iter().flat_map(|(_, _, de)| de.chars()).filter(|ch| ch.is_alphabetic() || "„“‚‘–—…".contains(*ch));
assert_bundled_fonts_cover(alphabet.chars().chain(Language::De.name().chars()).chain(letters));
}
#[test]
fn indonesian_renders_with_bundled_fonts() {
let (entries, _) = catalog::parse_entries(include_str!("id.tsv"));
+4
View File
@@ -1268,6 +1268,7 @@
59.94 fps Non Drop-Frame 59,94 fps sem drop-frame
ASC CDL ASC CDL
Accessible color contrast Contraste de cores acessível
Animate zoom, scrolling and menus Animar zoom, rolagem e menus
Action-Safe Margin Margem de segurança de ação
Active Monitor Monitor ativo
Add Adicionar
@@ -3578,6 +3579,9 @@
# The native macOS application menu (Settings…).
Settings Preferências
# View ▸ Reset Interface Zoom (#2253).
Reset Interface Zoom Redefinir zoom da interface
Track Color Cor da trilha
Open a sequence to search its graphics Abra uma sequência para pesquisar nos gráficos dela
No text graphics in this sequence. Add text with the Type tool. Esta sequência não tem gráficos com texto. Adicione texto com a ferramenta Texto.
No matching text. Nenhum texto corresponde.
Can't render this file because it contains an unexpected character in line 6 and column 4.
+4
View File
@@ -1254,6 +1254,7 @@
59.94 fps Non Drop-Frame 59.94 fps 非掉帧
ASC CDL ASC CDL
Accessible color contrast 无障碍颜色对比度
Animate zoom, scrolling and menus 缩放、滚动和菜单使用动画
Action-Safe Margin 动作安全边距
Active Monitor 活动监视器
Add 添加
@@ -3585,6 +3586,9 @@
Shows the version, community links and credits in a FilmCraft window. 在 FilmCraft 窗口中显示版本、社区链接和致谢。
Couldn't open your web browser 无法打开网页浏览器
Open this address in your browser yourself: 请自行在浏览器中打开此地址:
# View ▸ Reset Interface Zoom (#2253).
Reset Interface Zoom 重置界面缩放
Track Color 轨道颜色
Open a sequence to search its graphics 打开序列以搜索其中的图形
No text graphics in this sequence. Add text with the Type tool. 此序列中没有文本图形。请用文字工具添加文本。
No matching text. 没有匹配的文本。
Can't render this file because it contains an unexpected character in line 3 and column 95.
+68 -1
View File
@@ -39,6 +39,9 @@ pub mod credits;
pub mod dock;
mod dock_shared;
mod dock_ui;
/// Thumbnail textures kept before the least recently used are freed (each is up to 320 px wide).
const MAX_THUMB_TEXTURES: usize = 512;
pub mod frames;
pub mod graphics_notice;
pub mod header;
@@ -289,6 +292,9 @@ pub struct FilmcraftApp {
pub auto: automation::Registry,
/// Named textures (monitors, thumbnails) with the key they show.
textures: HashMap<String, (FrameKey, TextureHandle)>,
/// The UI pass each thumbnail texture was last used in: thumbnails beyond
/// [`MAX_THUMB_TEXTURES`] are freed, least recently used first (#1682).
thumb_used: HashMap<String, u64>,
control_rx: Option<Receiver<ControlRequest>>,
/// One explicit panel reveal; consumed by the custom renderer, never repeated per frame.
pub(crate) pending_panel_raise: Option<PanelKind>,
@@ -553,6 +559,7 @@ impl FilmcraftApp {
native_dialog: None,
auto: Default::default(),
textures: HashMap::new(),
thumb_used: HashMap::new(),
control_rx: None,
pending_panel_raise: None,
control_input_only: false,
@@ -633,6 +640,7 @@ impl FilmcraftApp {
FrameServer::default_workers(),
));
self.textures.clear();
self.thumb_used.clear();
self.tl.reset_media_caches();
// the new frame server needs the Memory settings
self.applied_prefs = None;
@@ -685,6 +693,16 @@ impl FilmcraftApp {
}
}
/// View ▸ Reset Interface Zoom: the scale Settings ▸ Appearance ▸ UI Scale asks for (the
/// system's with Auto), undoing any session zoom (#2253).
/// Returns the zoom factor set (egui reports it from the next pass on).
pub(crate) fn reset_ui_zoom(&mut self, ctx: &egui::Context) -> f32 {
let native = ctx.native_pixels_per_point().filter(|v| v.is_finite() && *v > 0.0).unwrap_or(1.0);
let zoom = self.session.prefs.appearance.ui_scale_factor().map_or(1.0, |scale| scale / native);
ctx.set_zoom_factor(zoom);
zoom
}
/// Make the UI follow the settings after they change (theme, tooltips, frame cache budget,
/// play after rendering, audio device).
pub fn apply_prefs(&mut self, ctx: &egui::Context) {
@@ -697,6 +715,11 @@ impl FilmcraftApp {
self.set_theme(ctx, k);
}
}
// checked every frame: a theme or font change installs the style again
let animation = if self.session.prefs.appearance.animations { theme::ANIMATION_TIME } else { 0.0 };
if ctx.global_style().animation_time != animation {
ctx.global_style_mut(|s| s.animation_time = animation);
}
if self.applied_prefs.as_ref() == Some(&self.session.prefs) {
return;
}
@@ -849,6 +872,13 @@ impl FilmcraftApp {
}
pub fn show_panel(&mut self, p: PanelKind) {
// closed while floating: it floats again where it was (#1985); taken before the Timeline's
// own restore below docks it
let open = self.ui.dock.contains(p) || self.ui.floating.iter().any(|group| group.panels.contains(&p));
let refloat = match self.ui.closed_floating.iter().position(|(k, _)| *k == p) {
Some(i) if !open => Some(self.ui.closed_floating.remove(i).1),
_ => None,
};
if p == PanelKind::Timeline && !self.ui.floating.iter().any(|group| group.panels.contains(&p)) {
self.ui.dock.restore_timeline();
}
@@ -873,6 +903,15 @@ impl FilmcraftApp {
};
self.ui.dock.open_near(p, near);
}
if let Some(rect) = refloat.filter(|r| r.iter().all(|v| v.is_finite())) {
let rect = [rect[0], rect[1], rect[2].max(160.0), rect[3].max(120.0)];
if let Err(e) = self.apply_dock(craft_ui::docking::Action::Float { panel: p, rect }) {
// refused (it is the only docked panel left): it stays docked, and its floating
// place is kept rather than lost
log::warn!("could not float {} again: {e}", p.id());
self.ui.closed_floating.push((p, rect));
}
}
let _ = self.apply_dock(craft_ui::docking::Action::Activate { panel: p });
self.ui.focused = p;
}
@@ -1251,12 +1290,40 @@ impl FilmcraftApp {
let name = format!("thumb-{}-{}-{}", item.0, frame, width);
if let Some(img) = self.frames.get(&key) {
let id = self.texture_for(ctx, &name, key, &img);
self.thumb_touched(ctx, &name);
return Some((id, egui::vec2(img.w as f32, img.h as f32)));
}
let scale = width as f32 / src_w.max(1) as f32;
let project = self.session.project.clone();
self.frames.request(key, rate.tick_of(frame), scale, &project, 50);
self.texture_existing(&name)
let shown = self.texture_existing(&name);
if shown.is_some() {
self.thumb_touched(ctx, &name);
}
shown
}
/// Note that thumbnail texture `name` is used in this pass, and free the least recently used
/// thumbnails once there are more than [`MAX_THUMB_TEXTURES`]: every frame shown in a timeline
/// tile or a hover scrub got a texture of its own that was never freed (#1682).
fn thumb_touched(&mut self, ctx: &egui::Context, name: &str) {
let pass = ctx.cumulative_pass_nr();
if self.thumb_used.insert(name.to_string(), pass).is_some() || self.thumb_used.len() <= MAX_THUMB_TEXTURES {
return;
}
// down to three quarters, never a thumbnail already drawn in this pass
let mut old: Vec<(u64, String)> = self.thumb_used.iter().filter(|(_, p)| **p < pass).map(|(n, p)| (*p, n.clone())).collect();
old.sort_unstable();
let excess = self.thumb_used.len().saturating_sub(MAX_THUMB_TEXTURES * 3 / 4);
for (_, n) in old.into_iter().take(excess) {
self.thumb_used.remove(&n);
self.textures.remove(&n);
}
}
/// The number of thumbnail textures held (tests, diagnostics).
pub fn thumbnail_textures(&self) -> usize {
self.textures.keys().filter(|n| n.starts_with("thumb-")).count()
}
// ---------------------------------------------------------------- files
+17
View File
@@ -53,6 +53,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[
uic!("view.zoomIn", "Zoom In", ["View"], Some("=")),
uic!("view.zoomOut", "Zoom Out", ["View"], Some("-")),
uic!("view.zoomToSequence", "Zoom to Sequence", ["View"], Some("\\")),
uic!("view.resetInterfaceZoom", "Reset Interface Zoom", ["View"], None),
uic!("view.playbackRes.full", "Full", ["View", "Playback Resolution"], None),
uic!("view.playbackRes.half", "1/2", ["View", "Playback Resolution"], None),
uic!("view.playbackRes.quarter", "1/4", ["View", "Playback Resolution"], None),
@@ -417,6 +418,12 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params:
app.ui.timeline.fit_pending = true;
return Ok(Value::Null);
}
// the whole interface back to Settings ▸ Appearance ▸ UI Scale, after Ctrl+Plus or a mouse
// mapped to page zoom scaled it (#2253)
"view.resetInterfaceZoom" => {
let zoom = app.reset_ui_zoom(ctx);
return Ok(json!({"zoom": zoom}));
}
"mode.import" => {
app.ui.mode = Mode::Import;
return Ok(Value::Null);
@@ -842,6 +849,16 @@ pub fn bindings(app: &FilmcraftApp) -> Vec<KeyBinding> {
let shifted: Vec<KeyBinding> =
v.iter().filter(|b| b.0.shift).filter_map(|(m, k, id, p)| shifted_key(*k).map(|k2| (*m, k2, id.clone(), p.clone()))).collect();
v.extend(shifted);
// `+` is the `=` key with Shift on US layouts and its own key on others and the keypad: a chord
// on `=` takes it too, unless something is bound to `+` itself. Otherwise Ctrl+Plus fell
// through to egui's interface zoom while Ctrl+- and Ctrl+0 were taken by FilmCraft
// shortcuts, so the interface grew and could not shrink back (#2253).
let plus: Vec<KeyBinding> = v
.iter()
.filter(|(m, k, _, p)| *k == egui::Key::Equals && !v.iter().any(|(m2, k2, _, p2)| *k2 == egui::Key::Plus && m2 == m && p2 == p))
.map(|(m, _, id, p)| (*m, egui::Key::Plus, id.clone(), p.clone()))
.collect();
v.extend(plus);
v.sort_by_key(|(m, ..)| std::cmp::Reverse(specificity(*m)));
v
}
@@ -197,9 +197,18 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
// Premiere lists the fixed effects (Motion, Opacity, Time Remapping / Volume…) first.
let mut order: Vec<usize> = (0..it.effects.len()).collect();
order.sort_by_key(|i| !it.effects[*i].def().is_some_and(|d| d.intrinsic));
let mut after_fixed = false;
for idx in order {
let e = &it.effects[idx];
let Some(def) = e.def() else { continue };
// the first effect added from the library after the fixed ones gets a line on top
// (#2328); drawn on its edge, so no row moves
let first_added = if def.intrinsic {
after_fixed = true;
false
} else {
std::mem::take(&mut after_fixed)
};
let key = format!("{}:{}", clip.0, idx);
let open = !app.ui.collapsed_fx.contains(&key);
let selected = app.session.state.selected_effect.as_ref().is_some_and(|s| s.clip == clip && s.index(&it.effects) == Some(idx));
@@ -210,6 +219,12 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
bui.painter().rect_filled(crate::panels::effect_controls::row_fill(r), 0.0, t.hover);
}
row_line(bui, r, &lane, &t);
if first_added {
let line = Rect::from_min_max(pos2(r.min.x, r.min.y - 1.0), pos2(r.max.x, r.min.y + 1.0));
bui.painter().rect_filled(line, 0.0, t.accent.gamma_multiply(0.8));
let which = if *kind == TrackKind::Video { "video" } else { "audio" };
app.auto.add(&format!("effectControls.addedEffects.{which}"), line, "Added effects");
}
// only the arrow twirls the effect open or shut; the rest of the header selects it
let tw = Rect::from_center_size(pos2(r.min.x + 10.0, r.center().y), vec2(10.0, 10.0));
icons::paint(bui.painter(), tw, if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_dim);
+13 -4
View File
@@ -87,8 +87,9 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
if resp.hovered() {
ui.painter().rect_filled(r, 0.0, t.hover);
}
let x = r.min.x + 46.0;
let icon_r = Rect::from_center_size(pos2(x - 10.0, r.center().y), vec2(13.0, 13.0));
// inside the top folder, or inside its sub-folder
let x = r.min.x + tree_x(if s.is_empty() { 1 } else { 2 }) + 30.0;
let icon_r = Rect::from_center_size(pos2(x - 12.0, r.center().y), vec2(13.0, 13.0));
let is_tr = matches!(d.kind, filmcraft_project::EffectKind::VideoTransition | filmcraft_project::EffectKind::AudioTransition);
ui.painter().rect_stroke(icon_r, 2.0, egui::Stroke::new(1.0, t.text_dim), egui::StrokeKind::Inside);
if is_tr {
@@ -139,6 +140,13 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
/// Where a tree row at `depth` starts (its chevron): folders at 0 (top) and 1 (sub-folders), and
/// what a folder holds one level deeper than the folder (#2329), so an effect is never level with,
/// or left of, the folder it is in. A row's icon is centred 18 points in, its label 30.
pub(crate) fn tree_x(depth: usize) -> f32 {
8.0 + depth as f32 * 16.0
}
fn folder_row(app: &mut FilmcraftApp, ui: &mut egui::Ui, key: &str, depth: usize, filter: &str) -> bool {
let t = app.tokens;
let open = !filter.is_empty() || app.ui.expanded_fx.iter().any(|k| k == key);
@@ -146,7 +154,7 @@ fn folder_row(app: &mut FilmcraftApp, ui: &mut egui::Ui, key: &str, depth: usize
if resp.hovered() {
ui.painter().rect_filled(r, 0.0, t.hover);
}
let x = r.min.x + 8.0 + depth as f32 * 16.0;
let x = r.min.x + tree_x(depth);
icons::paint(
ui.painter(),
Rect::from_center_size(pos2(x + 4.0, r.center().y), vec2(10.0, 10.0)),
@@ -240,7 +248,8 @@ fn lumetri_rows(app: &mut FilmcraftApp, ui: &mut egui::Ui, filter: &str) -> Opti
ui.painter().rect_filled(r, 0.0, t.hover);
}
let tex = preset_texture(app, ui.ctx(), p, 64);
let tr = Rect::from_min_size(pos2(r.min.x + 40.0, r.min.y + 3.0), vec2(32.0, 18.0));
// inside a Lumetri Presets sub-folder: one level deeper than it
let tr = Rect::from_min_size(pos2(r.min.x + tree_x(2) + 12.0, r.min.y + 3.0), vec2(32.0, 18.0));
ui.painter().image(tex, tr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
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);
+48 -7
View File
@@ -831,7 +831,20 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra
let p = resp.interact_pointer_pos().unwrap_or(pic.center());
let in_edit = edit_view.is_some_and(|v| v.hit(p));
match tool {
_ if in_edit => {}
// a click in the text being edited puts the caret there (Shift extends the selection),
// as a press that becomes a drag already does (#2430)
_ if in_edit => {
if let (Some(v), Some(mut ed)) = (edit_view, editing.clone())
&& let Some(tp) = text_of(&v.spec)
&& let Some((x, y)) = v.to_local(p)
{
ed.caret = text_layout(tp).hit(x, y);
if !ui.input(|i| i.modifiers.shift) {
ed.anchor = ed.caret;
}
app.ui.gfx_edit = Some(ed);
}
}
Tool::Type | Tool::VerticalType => {
if let Some(v) = views.iter().find(|v| v.is_text() && v.hit(p)) {
start_edit_at(app, ui, v, p, &mut actions);
@@ -910,8 +923,8 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra
painter.add(egui::Shape::convex_polygon(q.to_vec(), Color32::from_rgba_unmultiplied(80, 140, 255, 90), Stroke::NONE));
}
let (x, base, li) = l.caret(caret);
let line = &l.lines[li.min(l.lines.len() - 1)];
let (a, b) = (sp(&v.to_screen, x, base - line.ascent), sp(&v.to_screen, x, base + line.descent));
let (ascent, descent) = l.lines.get(li.min(l.lines.len().saturating_sub(1))).map_or((0.0, 0.0), |line| (line.ascent, line.descent));
let (a, b) = (sp(&v.to_screen, x, base - ascent), sp(&v.to_screen, x, base + descent));
painter.line_segment([a, b], Stroke::new(1.5, Color32::WHITE));
painter.line_segment([a + vec2(1.0, 0.0), b + vec2(1.0, 0.0)], Stroke::new(0.5, Color32::BLACK));
let er = Rect::from_points(&v.quad()).union(Rect::from_two_pos(a, b));
@@ -1198,6 +1211,8 @@ fn color32(c: [f32; 4]) -> Color32 {
struct Ctx<'a> {
clip: ClipId,
layer: usize,
/// The layer's index in the clip's effect list (what `effects.toggleAnimation` addresses).
eidx: usize,
e: &'a EffectInstance,
mt: Tick,
t: Tokens,
@@ -1245,12 +1260,37 @@ impl Ctx<'_> {
);
(r, v)
}
/// The stopwatch in front of an animatable property's label (#2358): turns keyframing on or
/// off at the playhead, like the one in Effect Controls. While a property animates, changing
/// it here adds or updates a keyframe at the playhead instead of changing it everywhere. A
/// Type-tool selection edits the selected characters, which have no keyframes: no stopwatch.
fn stopwatch(&mut self, ui: &egui::Ui, row: Rect, id: &str) {
let animatable = self.e.def().and_then(|d| d.param(id)).is_some_and(|d| d.animatable);
if !animatable || (self.range.is_some() && char_style_key(id).is_some()) {
return;
}
let animated = self.e.params.get(id).is_some_and(|p| p.is_animated());
let sw = Rect::from_center_size(pos2(row.min.x + 8.0, row.center().y), vec2(12.0, 12.0));
let resp = ui.interact(sw.expand(2.0), egui::Id::new(("gfxsw", self.clip.0, self.layer, id)), Sense::click()).on_hover_text(tl!("Toggle animation"));
let tint = if animated {
self.t.accent
} else if resp.hovered() {
self.t.text
} else {
self.t.text_faint
};
icons::paint(ui.painter(), sw, Icon::Stopwatch, tint);
self.autos.push((format!("graphics.prop.{id}.stopwatch"), sw, "Toggle animation".to_string()));
if resp.clicked() {
self.actions.push(("effects.toggleAnimation".into(), json!({"clip": self.clip.0, "effect": self.eidx, "param": id})));
}
}
fn number(&mut self, ui: &mut egui::Ui, label: &str, id: &str, speed: f64, range: (f64, f64), dec: usize, unit: &str) {
let v = pv(self.e, id, self.mt).as_f64().unwrap_or(0.0);
let (r, mut vui) = self.row(ui, label);
let (resp, nv) = crate::widgets::hot_number(&mut vui, egui::Id::new(("gfxp", self.clip.0, self.layer, id)), v, speed, range, dec, unit, &self.t);
self.auto(id, resp.rect, label);
let _ = r;
self.stopwatch(ui, r, id);
if let Some(nv) = nv {
self.set(id, json!(nv));
}
@@ -1265,7 +1305,8 @@ impl Ctx<'_> {
}
fn point(&mut self, ui: &mut egui::Ui, label: &str, id: &str) {
let v = pv(self.e, id, self.mt).as_vec2().unwrap_or_default();
let (_, mut vui) = self.row(ui, label);
let (r, mut vui) = self.row(ui, label);
self.stopwatch(ui, r, id);
let (rx, nx) = crate::widgets::hot_number(
&mut vui,
egui::Id::new(("gfxp", self.clip.0, self.layer, id, "x")),
@@ -1565,7 +1606,7 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
let ch = |b: usize| text.get(..b.min(text.len())).map_or(0, |s| s.chars().count());
Some((ch(ed.caret.min(ed.anchor)), ch(ed.caret.max(ed.anchor))))
});
let mut cx = Ctx { clip, layer: l, e, mt, t, actions: Vec::new(), autos: Vec::new(), range };
let mut cx = Ctx { clip, layer: l, eidx: idx[l], e, mt, t, actions: Vec::new(), autos: Vec::new(), range };
if section(ui, app, "Responsive Design - Position", &t) {
crate::panels::graphics_templates::responsive_position(app, ui, clip, &it, l, &mut cx.autos, &mut cx.actions);
}
@@ -1810,7 +1851,7 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
for (cmd, mut p) in actions {
// a drag of a property is one undo step: the first change of each press begins a new one,
// the rest fold into it (like Effect Controls, #201 / #887)
if cmd == "graphics.set" && down {
if matches!(cmd.as_str(), "graphics.set" | "graphics.setCharStyle") && down {
p["merge"] = json!(true);
p["begin"] = json!(begun != press);
wrote = true;
+4 -2
View File
@@ -70,8 +70,10 @@ pub fn route(app: &mut FilmcraftApp, id: &str, params: &Value) -> Option<Result<
if let Err(e) = filmcraft_engine::find_command(id).map_or(Ok(()), |c| (c.enabled)(&app.session)) {
return Some(Err(e));
}
let items: Vec<u64> = app.session.state.project_selection.iter().map(|i| i.0).collect();
let first = app.session.state.project_selection.first().and_then(|i| app.session.project.item(*i)).map(|i| i.name.clone()).unwrap_or_default();
// the Project panel's selection, or the media of the clips selected in the Timeline (#1362)
let selected = filmcraft_engine::multicam::selected_items(&app.session);
let items: Vec<u64> = selected.iter().map(|i| i.0).collect();
let first = selected.first().and_then(|i| app.session.project.item(*i)).map(|i| i.name.clone()).unwrap_or_default();
let name = match kind {
"merge" => tlf!("{first} - Merged", first),
"multicam" => tlf!("{first} Multicam", first),
+2 -1
View File
@@ -26,7 +26,8 @@ pub fn folder_rows(app: &mut FilmcraftApp, ui: &mut egui::Ui, filter: &str) -> O
if resp.hovered() {
ui.painter().rect_filled(r, 0.0, t.hover);
}
let x = r.min.x + 30.0;
// inside the Presets folder: icon and label one level deeper than it (#2329)
let x = r.min.x + crate::panels::effects::tree_x(1) + 18.0;
icons::paint(
ui.painter(),
Rect::from_center_size(pos2(x, r.center().y), vec2(13.0, 13.0)),
+40 -1
View File
@@ -294,6 +294,10 @@ pub fn zoom_about(v: &mut TimelineView, factor: f64, anchor_secs: f64, width: f3
v.target_scroll = (anchor_secs - x as f64 / new).max(0.0);
}
/// How strongly a track's colour label tints its header and its lane.
const TRACK_HEADER_TINT: f32 = 0.18;
const TRACK_LANE_TINT: f32 = 0.10;
fn label_color(app: &FilmcraftApp, l: filmcraft_project::Label) -> Color32 {
crate::panels::settings::label_color(app, l)
}
@@ -340,7 +344,8 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
v.fit_pending = false;
v.fit_empty = empty.then_some(v.target_pps);
}
let k = 1.0 - (-dt * 20.0).exp();
// eased, or in one step with Settings ▸ Appearance ▸ animations off (#2325)
let k = if app.session.prefs.appearance.animations { 1.0 - (-dt * 20.0).exp() } else { 1.0 };
let zooming = (v.pps - v.target_pps).abs() / v.target_pps > 0.001;
// video thumbnails keep the layout of the zoom a gesture or animation started at (#1094)
if zooming || matches!(app.tl.drag, Some(Drag::ZoomBar { mode: 1 | 2, .. })) {
@@ -455,6 +460,10 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
let bg = if r.index % 2 == 0 { t.tl_track_bg } else { t.tl_track_bg_alt };
painter.rect_filled(row, 0.0, bg);
// a track's colour label tints its lane (#1186)
if let Some(l) = seq.track(r.track).and_then(|tr| tr.label) {
painter.rect_filled(row, 0.0, label_color(app, l).gamma_multiply(TRACK_LANE_TINT));
}
painter.line_segment([pos2(row.min.x, r.rect.max.y - 0.5), pos2(row.max.x, r.rect.max.y - 0.5)], Stroke::new(1.0, t.separator));
// the top video track also gets a line above it
if r.kind == TrackKind::Video && r.index + 1 == nv {
@@ -1258,6 +1267,36 @@ fn draw_headers(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, rows:
}
let p = ui.painter().with_clip_rect(visible);
p.rect_filled(hrect, 0.0, t.tl_header_bg);
// 0. colour label (#1186): a tint over the header, and the strip at its left edge, which
// opens the colour menu
let strip = Rect::from_min_max(hrect.min, pos2(hrect.min.x + 9.0, hrect.max.y));
if let Some(l) = tr.label {
let c = label_color(app, l);
p.rect_filled(hrect, 0.0, c.gamma_multiply(TRACK_HEADER_TINT));
p.rect_filled(Rect::from_min_max(strip.min, pos2(strip.min.x + 5.0, strip.max.y)), 0.0, c);
}
let cresp = ui.interact(strip.intersect(visible), egui::Id::new(("trackColor", r.track.0)), Sense::click()).on_hover_text(tl!("Track Color"));
app.auto.add(&format!("timeline.track.{}{}.color", if r.kind == TrackKind::Video { "V" } else { "A" }, r.index + 1), strip, "Track Color");
if cresp.hovered() && tr.label.is_none() {
p.rect_filled(Rect::from_min_max(strip.min, pos2(strip.min.x + 5.0, strip.max.y)), 0.0, t.hover);
}
egui::Popup::menu(&cresp).show(|ui| {
let prefix = format!("timeline.track.{}{}.color", if r.kind == TrackKind::Video { "V" } else { "A" }, r.index + 1);
let none = ui.selectable_label(tr.label.is_none(), tl!("None"));
app.auto.add(&format!("{prefix}.None"), none.rect, "None");
if none.clicked() {
actions.push(("timeline.setTrack".into(), json!({"track": r.track.0, "label": ""})));
ui.close();
}
// the user's label names and colours, as in Edit ▸ Label
let labels = app.session.prefs.labels.clone();
for l in filmcraft_project::Label::ALL {
if crate::menus::label_choice(ui, &mut app.auto, &labels, l, &prefix).clicked() {
actions.push(("timeline.setTrack".into(), json!({"track": r.track.0, "label": l.name()})));
ui.close();
}
}
});
p.line_segment([pos2(hrect.min.x, hrect.max.y - 0.5), pos2(hrect.max.x, hrect.max.y - 0.5)], Stroke::new(1.0, t.separator));
if r.kind == TrackKind::Video && r.index + 1 == seq.video_tracks.len() {
p.line_segment([pos2(hrect.min.x, hrect.min.y + 0.5), pos2(hrect.max.x, hrect.min.y + 0.5)], Stroke::new(1.0, t.separator));
+5
View File
@@ -436,6 +436,10 @@ pub struct UiState {
pub dock: DockNode,
#[serde(default)]
pub floating: Vec<crate::dock::Floating>,
/// Where each panel closed while floating was ([x, y, width, height]): opening it again
/// brings it back there, floating, instead of docked (#1985).
#[serde(default)]
pub closed_floating: Vec<(PanelKind, [f32; 4])>,
/// Focused panel (blue outline, receives shortcuts).
pub focused: PanelKind,
pub timeline: TimelineView,
@@ -960,6 +964,7 @@ impl Default for UiState {
mode: Mode::Edit,
workspace: "Editing".into(),
floating: vec![],
closed_floating: vec![],
dock: crate::dock::workspace("Editing"),
focused: PanelKind::Timeline,
timeline: TimelineView::default(),
+5 -1
View File
@@ -507,10 +507,14 @@ pub fn apply_visuals(ctx: &egui::Context, t: &Tokens) {
s.text_styles.insert(TextStyle::Small, FontId::new(11.0, FontFamily::Proportional));
s.text_styles.insert(TextStyle::Heading, FontId::new(15.0, FontFamily::Name("semibold".into())));
s.text_styles.insert(TextStyle::Monospace, FontId::new(12.0, FontFamily::Monospace));
s.animation_time = 0.12;
s.animation_time = ANIMATION_TIME;
});
}
/// How long egui's fades and slides take (menus, collapsing sections, tooltips); 0 when
/// Settings ▸ Appearance ▸ "Animate zoom, scrolling and menus" is off (#2325).
pub const ANIMATION_TIME: f32 = 0.12;
#[cfg(test)]
mod tests {
use super::*;
@@ -357,6 +357,26 @@ fn text_committed_by_an_input_method_is_typed_on_the_monitor() {
assert_eq!(list["layers"][0]["text"], "A你好");
}
/// #2430: a click (no drag) in the text being edited on the monitor puts the caret there; before,
/// only a press that moved a little did, and typing went on at the end.
#[test]
fn a_click_in_the_edited_text_moves_the_caret() {
let mut d = Driver::new();
d.ok("ui.set", json!({"tool": "Type"}));
d.frames(3);
let pic = d.rect("program.picture");
d.ok("ui.click", json!({"x": pic[0] + pic[2] * 0.3, "y": pic[1] + pic[3] * 0.2}));
d.ok("ui.type", json!({"text": "Title"}));
d.frames(2);
let text = d.rect("program.textEdit");
// just inside the first letter: the caret goes before it
d.ok("ui.click", json!({"x": text[0] + 1.0, "y": text[1] + text[3] / 2.0}));
d.frames(2);
d.ok("ui.type", json!({"text": "X"}));
d.frames(2);
assert_eq!(d.exec("graphics.list", json!({}))["layers"][0]["text"], "XTitle");
}
#[test]
fn text_properties_switches_between_point_and_paragraph_text() {
let mut d = Driver::new();
+84
View File
@@ -231,6 +231,90 @@ fn shadow_color_drag_is_one_undo_step() {
assert_eq!(color(&mut d), before, "one undo restores the original color");
}
/// Character ranges use setCharStyle, but must share the same one-gesture undo rule as a layer.
#[test]
fn selected_character_size_drag_is_one_undo_step() {
let mut d = Driver::demo();
d.ok("ui.set", json!({"workspace":"Captions and Graphics","tool":"Type"}));
d.exec("playhead.set", json!({"seconds":2}));
d.frames(3);
let p = d.rect("program.picture");
d.ok("ui.click", json!({"x":p[0]+p[2]*0.2,"y":p[1]+p[3]*0.35}));
d.ok("ui.type", json!({"text":"Undo drag test"}));
d.ok("ui.key", json!({"key":"Cmd+A"}));
d.frames(3);
let before = d.harness.state().session.project.clone();
let n = d.harness.state().session.history.undo.len();
let r = d.rect("graphics.prop.size");
d.ok("ui.drag", json!({"from":{"x":r[0]+r[2]/2.0,"y":r[1]+r[3]/2.0},"to":{"x":r[0]+r[2]/2.0+80.0,"y":r[1]+r[3]/2.0},"steps":8}));
d.frames(3);
assert_eq!(d.harness.state().session.history.undo.len(), n + 1);
let after = d.harness.state().session.project.clone();
assert_ne!(*after, *before);
d.exec("edit.undo", json!({}));
assert_eq!(*d.harness.state().session.project, *before);
d.exec("edit.redo", json!({}));
assert_eq!(*d.harness.state().session.project, *after);
}
/// The Properties panel's stopwatches animate a layer property (#2358): Tracking (letter
/// spacing) gets keyframes from the Essential Graphics panel itself, a change at another time
/// adds a keyframe there instead of changing the value everywhere, and the stopwatch turns
/// keyframing off again.
#[test]
fn the_tracking_stopwatch_animates_letter_spacing() {
let mut d = Driver::demo();
d.ok("ui.set", json!({"workspace": "Captions and Graphics"}));
d.exec("playhead.set", json!({"seconds": 0.0}));
let r = d.exec("graphics.newText", json!({"text": "Title", "seconds": 4}));
let clip = r["clip"].as_u64().unwrap();
d.exec("graphics.selectLayer", json!({"clip": clip, "layers": [0]}));
d.frames(4);
for _ in 0..20 {
if d.rect("graphics.prop.tracking")[1] < 800.0 {
break;
}
d.ok("ui.scroll", json!({"id": "graphics.prop.scale", "dy": -200.0}));
d.frames(2);
}
let keys = |d: &mut Driver| -> Vec<(i64, f64)> {
let s = &d.harness.state().session;
let (_, it) = s.active_sequence().unwrap().find_item(filmcraft_project::ClipId(clip)).unwrap();
let e = it.effects.iter().find(|e| e.effect == "graphic_text").unwrap();
e.params.get("tracking").map(|p| p.keyframes.iter().map(|k| (k.time.0, k.value.as_f64().unwrap())).collect()).unwrap_or_default()
};
assert!(keys(&mut d).is_empty());
d.ok("ui.click", json!({"id": "graphics.prop.tracking.stopwatch"}));
d.frames(2);
assert_eq!(keys(&mut d).len(), 1, "the stopwatch sets a keyframe at the playhead");
// two seconds later, drag the Tracking number: a second keyframe, the first stays
d.exec("playhead.set", json!({"seconds": 2.0}));
d.frames(2);
let n = d.rect("graphics.prop.tracking");
let (cx, cy) = (n[0] + n[2] / 2.0, n[1] + n[3] / 2.0);
d.ok("ui.drag", json!({"from": {"x": cx, "y": cy}, "to": {"x": cx + 60.0, "y": cy}, "steps": 6}));
d.frames(4);
let k = keys(&mut d);
assert_eq!(k.len(), 2, "{k:?}");
// the clip starts at 0, where the first keyframe was set, so the second one sits at the playhead
let playhead = d.harness.state().session.playhead().0;
assert_eq!((k[0].0, k[1].0), (0, playhead), "the keyframes are where the playhead was: {k:?}");
assert_eq!(k[0].1, 0.0, "the first keyframe keeps its value: {k:?}");
assert!(k[1].1 > 10.0, "the new keyframe has the dragged value: {k:?}");
// letter spacing now animates: halfway between the keyframes the value is in between
let mid = {
let s = &d.harness.state().session;
let (_, it) = s.active_sequence().unwrap().find_item(filmcraft_project::ClipId(clip)).unwrap();
let e = it.effects.iter().find(|e| e.effect == "graphic_text").unwrap();
e.param("tracking").unwrap().value_at(filmcraft_time::Tick((k[0].0 + k[1].0) / 2)).as_f64().unwrap()
};
assert!(mid > 0.0 && mid < k[1].1, "{mid} between 0 and {}", k[1].1);
d.ok("ui.click", json!({"id": "graphics.prop.tracking.stopwatch"}));
d.frames(2);
assert!(keys(&mut d).is_empty(), "the stopwatch turns keyframing off");
d.shot("tracking-stopwatch");
}
/// Text panel ▸ Graphics (#1487): the text of every text layer in the sequence, in timeline
/// order, searchable; the timecode goes to the clip and selects the layer, and the text edits in
/// place as one undo step.
+96
View File
@@ -0,0 +1,96 @@
//! Headless UI tests of the interface zoom (#2253).
use std::sync::mpsc::{Sender, channel};
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<ControlRequest>,
}
impl Driver {
fn demo() -> Self {
let mut session = Session::default();
session.execute("file.openDemoProject", json!({})).expect("demo project");
let (tx, rx) = channel();
let app = FilmcraftApp::new(session).with_control(rx);
let harness = Harness::builder().with_size(egui::vec2(1600.0, 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
}
fn frames(&mut self, n: usize) {
for _ in 0..n {
let ctx = self.harness.ctx.clone();
let mut raw = std::mem::take(self.harness.input_mut());
eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw);
*self.harness.input_mut() = raw;
self.harness.step();
}
}
fn ok(&mut self, method: &str, params: Value) -> Value {
let (req, reply) = ControlRequest::new(method, params.clone());
self.tx.send(req).unwrap();
for _ in 0..600 {
self.frames(1);
if let Ok(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 click(&mut self, id: &str) {
self.ok("ui.click", json!({"id": id}));
self.frames(2);
}
fn app(&mut self) -> &mut FilmcraftApp {
self.harness.state_mut()
}
/// One key press with `modifiers`, as the window reports it.
fn press(&mut self, key: egui::Key, modifiers: egui::Modifiers) {
let events = &mut self.harness.input_mut().events;
events.push(egui::Event::ModifiersChanged(modifiers));
for pressed in [true, false] {
events.push(egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers });
}
events.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
self.frames(3);
}
}
/// #2253: with the Timeline focused, Ctrl+= and Ctrl+- are its track-height keys, but Ctrl+Plus
/// (the `+` key, or Shift+=) fell through to egui and scaled the whole interface, which the taken
/// Ctrl+- and Ctrl+0 could not undo. `+` now goes where `=` goes, and View ▸ Reset Interface Zoom
/// gives the configured scale back.
#[test]
fn ctrl_plus_does_what_ctrl_equals_does_and_the_interface_zoom_can_be_reset() {
let mut d = Driver::demo();
let clip = d.app().session.active_sequence().unwrap().video_tracks[0].items[0].id.0;
d.click(&format!("timeline.clip.{clip}"));
assert_eq!(d.app().ui.focused, filmcraft_ui_egui::dock::PanelKind::Timeline);
let zoom = d.harness.ctx.zoom_factor();
let height = d.app().ui.timeline.video_track_h;
d.press(egui::Key::Plus, egui::Modifiers::COMMAND);
assert_eq!(d.harness.ctx.zoom_factor(), zoom, "the interface keeps its scale");
assert!(d.app().ui.timeline.video_track_h > height, "Ctrl+Plus makes the video tracks taller, as Ctrl+= does");
// however it got scaled, View ▸ Reset Interface Zoom brings it back
d.harness.ctx.set_zoom_factor(1.75);
d.frames(2);
assert_eq!(d.harness.ctx.zoom_factor(), 1.75);
let r = d.ok("ui.menu.invoke", json!({"id": "view.resetInterfaceZoom"}));
assert_eq!(r["zoom"], json!(1.0), "the reply says the zoom it set: {r}");
d.frames(2);
assert_eq!(d.harness.ctx.zoom_factor(), 1.0);
}
+19
View File
@@ -708,3 +708,22 @@ fn mask_keyframe_menus_preserve_the_effect_and_other_masks() {
assert_eq!(effect(&d), before);
}
}
/// #2328: a line separates the fixed effects (Motion, Opacity, Time Remapping) from the effects
/// added from the library; without added effects there is none.
#[test]
fn a_line_separates_added_effects_from_the_fixed_ones() {
let (mut d, clip) = Driver::demo();
assert!(d.find("effectControls.addedEffects.video").is_none(), "only fixed effects: no line");
d.exec("effects.apply", json!({"clips": [clip.id], "effect": "gaussian_blur"}));
d.exec("effects.apply", json!({"clips": [clip.id], "effect": "tint"}));
let line = d.rect("effectControls.addedEffects.video");
let opacity = d.rect("effectControls.effect.opacity");
let blur = d.rect("effectControls.effect.gaussian_blur");
let tint = d.rect("effectControls.effect.tint");
assert!(
opacity[1] < line[1] && (line[1] + line[3] / 2.0 - blur[1]).abs() <= 0.5,
"on the edge between the fixed and the added effects: {opacity:?} {line:?} {blur:?}"
);
assert!(tint[1] > blur[1], "one line, above all the added effects");
}
+25
View File
@@ -389,3 +389,28 @@ fn multicam_view_pages_beyond_sixteen_angles_and_edit_cameras() {
assert!(!mc.cameras[1].enabled);
assert_eq!(d.exec("multicam.grid", json!({}))["angles"], 19);
}
/// #1362: with nothing selected in the Project panel, Create Multi-Camera Source Sequence works on
/// the clips selected in the Timeline.
#[test]
fn create_multicam_from_clips_selected_in_the_timeline() {
let (s, items) = session();
let mut d = Driver::new(s);
for (k, item) in items.iter().enumerate() {
d.exec("source.open", json!({"item": item}));
d.exec("playhead.set", json!({"frame": k as i64 * 96}));
d.exec("source.overwrite", json!({}));
}
let clips: Vec<u64> = d.app().session.active_sequence().unwrap().video_tracks[0].items.iter().map(|c| c.id.0).collect();
assert_eq!(clips.len(), 4);
d.exec("project.select", json!({"items": []}));
d.exec("timeline.select", json!({"clips": clips}));
d.ok("ui.menu.invoke", json!({"id": "clip.createMulticam"}));
d.frames(3);
assert_eq!(d.app().ui.sync_dialog.as_ref().map(|x| x.items.clone()), Some(items.clone()), "the dialog takes the Timeline clips' media");
d.click("mcam.method.in");
d.click("mcam.ok");
assert!(d.app().ui.sync_dialog.is_none(), "dialog closed: {}", d.app().ui.status);
let src = d.app().session.state.project_selection[0];
assert_eq!(d.app().session.project.sequence(src).unwrap().multicam.clone().unwrap().cameras.len(), 4);
}
+37
View File
@@ -744,3 +744,40 @@ fn middle_closing_an_inactive_project_tab_keeps_the_inspector_target() {
let source = d.app().ui.panels.inspector_source;
assert_eq!(d.label("info.item"), name, "closing a tab must not select its stale item; focused={focused:?}, source={source:?}");
}
/// #2329: in the Effects panel what a folder holds is indented under it: an effect's label starts
/// right of its sub-folder's, a sub-folder's right of its top folder's, and the same for presets
/// and Lumetri presets.
#[test]
fn effects_are_indented_under_their_folders() {
fn text_x(d: &mut Driver, text: &str) -> f32 {
fn find(shapes: &[egui::Shape], text: &str) -> Option<f32> {
shapes.iter().find_map(|s| match s {
egui::Shape::Text(t) if t.galley.text() == text => Some(t.pos.x),
egui::Shape::Vec(v) => find(v, text),
_ => None,
})
}
let shapes: Vec<egui::Shape> = d.harness.output().shapes.iter().map(|c| c.shape.clone()).collect();
find(&shapes, text).unwrap_or_else(|| panic!("`{text}` is not drawn"))
}
let mut d = Driver::demo();
d.ok("ui.panel.show", json!({"panel": "Effects"}));
d.frames(3);
for folder in ["Video Effects", "Video Effects/Blur & Sharpen", "Presets", "Lumetri Presets", "Lumetri Presets/Monochrome"] {
d.click(&format!("effects.folder.{folder}"));
}
d.frames(2);
let preset = d.app().session.presets.all()[0].name.clone();
let lumetri = filmcraft_render::lumetri_presets::presets().into_iter().find(|p| p.folder == "Monochrome").unwrap().name;
for (outer, inner) in [
("Video Effects", "Blur & Sharpen"),
("Blur & Sharpen", "Gaussian Blur"),
("Presets", preset.as_str()),
("Lumetri Presets", "Monochrome"),
("Monochrome", lumetri),
] {
let (a, b) = (text_x(&mut d, outer), text_x(&mut d, inner));
assert!(b >= a + 12.0, "`{inner}` ({b}) is not indented under `{outer}` ({a})");
}
}
+29
View File
@@ -190,3 +190,32 @@ fn a_newer_revision_keeps_the_frame_being_rendered() {
d.ready(key(9002));
d.ready(key(9001));
}
/// #1682: thumbnail textures (timeline tiles, hover scrubs) are capped instead of one being kept
/// for every frame ever shown.
#[test]
fn thumbnail_textures_are_capped() {
let mut d = Driver::demo();
let ctx = d.harness.ctx.clone();
let item = {
let p = &d.harness.state().session.project;
p.items.values().find(|i| i.as_media().is_some_and(|m| m.info.video.is_some())).expect("a video item").id
};
let rate = d.harness.state().session.project.item(item).unwrap().frame_rate();
// 800 distinct frames, 100 per UI pass: request, wait until rendered, then make the textures
for chunk in 0..8 {
let frames: Vec<i64> = (chunk * 100..chunk * 100 + 100).collect();
for &f in &frames {
d.harness.state_mut().thumbnail(&ctx, item, rate.tick_of(f), 32);
}
for _ in 0..2000 {
if frames.iter().all(|&f| d.harness.state_mut().thumbnail(&ctx, item, rate.tick_of(f), 32).is_some()) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
d.frames(1);
}
let n = d.harness.state().thumbnail_textures();
assert!((100..=512).contains(&n), "{n} thumbnail textures");
}
+51
View File
@@ -559,3 +559,54 @@ fn narration_selection_reveals_covered_floating_tts_once_without_editing_the_pro
assert_eq!(h.state().ui.tts, draft);
assert!(std::sync::Arc::ptr_eq(&project, &h.state().session.project));
}
/// #1985: a panel closed while floating came back docked when opened again from the Window menu;
/// it now floats again where it was. Closed while docked, it opens docked as before.
#[test]
fn a_panel_closed_while_floating_reopens_floating_where_it_was() {
let mut app = FilmcraftApp::new(Session::default());
let ctx = egui::Context::default();
menus::invoke(&mut app, &ctx, "window.floatPanel", json!({"panel": "Info", "rect": [45, 65, 420, 320]})).unwrap();
let rect = app.ui.floating.iter().find(|g| g.panels.contains(&PanelKind::Info)).unwrap().rect;
menus::invoke(&mut app, &ctx, "window.closePanel", json!({"panel": "Info"})).unwrap();
assert!(!app.ui.dock.contains(PanelKind::Info) && !app.ui.floating.iter().any(|g| g.panels.contains(&PanelKind::Info)));
menus::invoke(&mut app, &ctx, &menus::panel_command_id(PanelKind::Info), json!({})).unwrap();
let back = app.ui.floating.iter().find(|g| g.panels.contains(&PanelKind::Info)).expect("Info floats again");
assert_eq!(back.rect, rect, "where it was");
assert!(!app.ui.dock.contains(PanelKind::Info));
// docked, then closed: it opens docked
menus::invoke(&mut app, &ctx, "window.dockPanel", json!({"panel": "Info", "anchor": "Project"})).unwrap();
menus::invoke(&mut app, &ctx, "window.closePanel", json!({"panel": "Info"})).unwrap();
menus::invoke(&mut app, &ctx, &menus::panel_command_id(PanelKind::Info), json!({})).unwrap();
assert!(app.ui.dock.contains(PanelKind::Info));
assert!(!app.ui.floating.iter().any(|g| g.panels.contains(&PanelKind::Info)));
// the Timeline too, though reopening it first puts it back in its own place in the dock
menus::invoke(&mut app, &ctx, "window.floatPanel", json!({"panel": "Timeline", "rect": [60, 80, 700, 300]})).unwrap();
let rect = app.ui.floating.iter().find(|g| g.panels.contains(&PanelKind::Timeline)).unwrap().rect;
menus::invoke(&mut app, &ctx, "window.closePanel", json!({"panel": "Timeline"})).unwrap();
menus::invoke(&mut app, &ctx, &menus::panel_command_id(PanelKind::Timeline), json!({})).unwrap();
let back = app.ui.floating.iter().find(|g| g.panels.contains(&PanelKind::Timeline)).expect("the Timeline floats again");
assert_eq!(back.rect, rect);
assert!(!app.ui.dock.contains(PanelKind::Timeline));
// with nothing else docked, a reopened panel has to stay docked (the workspace keeps one
// docked panel); its floating place is kept, not lost (#2482 review)
let mut app = FilmcraftApp::new(Session::default());
menus::invoke(&mut app, &ctx, "window.floatPanel", json!({"panel": "Info", "rect": [45, 65, 420, 320]})).unwrap();
let rect = app.ui.floating.iter().find(|g| g.panels.contains(&PanelKind::Info)).unwrap().rect;
for _ in 0..64 {
let Some(p) = PanelKind::ALL.iter().copied().find(|p| app.ui.dock.contains(*p)) else { break };
if menus::invoke(&mut app, &ctx, "window.closePanel", json!({"panel": p.id()})).is_err() {
break;
}
}
let docked = PanelKind::ALL.iter().filter(|p| app.ui.dock.contains(**p)).count();
if docked > 0 {
eprintln!("SKIPPED: the dock cannot be emptied ({docked} panels stay)");
return;
}
menus::invoke(&mut app, &ctx, "window.closePanel", json!({"panel": "Info"})).unwrap();
menus::invoke(&mut app, &ctx, &menus::panel_command_id(PanelKind::Info), json!({})).unwrap();
assert!(app.ui.dock.contains(PanelKind::Info), "the only panel stays docked");
assert!(app.ui.closed_floating.iter().any(|(p, r)| *p == PanelKind::Info && *r == rect), "{:?}", app.ui.closed_floating);
}
@@ -342,3 +342,33 @@ fn repeated_home_and_end_keys_reveal_a_playhead_already_at_the_endpoint() {
fn repeated_start_and_end_commands_reveal_a_playhead_already_at_the_endpoint() {
repeated_endpoint_navigation_reveals_the_same_playhead(false);
}
/// #2325: Settings ▸ Appearance ▸ "Animate zoom, scrolling and menus" off makes a zoom and a
/// scroll land in one frame, and egui's fades (menus, collapsing sections, tooltips) instant.
#[test]
fn turning_animations_off_zooms_and_scrolls_in_one_step() {
let mut d = Driver::tall();
let animation_time = |d: &mut Driver| d.harness.ctx.global_style().animation_time;
assert!(animation_time(&mut d) > 0.0, "animated by default");
// animated: one frame later the zoom is on its way, not there
let v = &mut d.app().ui.timeline;
v.target_pps = 200.0;
v.target_scroll = 8.0;
d.frames(1);
let v = &d.app().ui.timeline;
assert!(v.pps > 50.0 && v.pps < 200.0 && v.scroll < 8.0, "eased: {} {}", v.pps, v.scroll);
assert_eq!(d.view(), (8.0, 200.0, 0.0, 0.0));
d.exec("prefs.set", json!({"key": "appearance.animations", "value": false}));
d.frames(2);
assert_eq!(animation_time(&mut d), 0.0);
let v = &mut d.app().ui.timeline;
v.target_pps = 50.0;
v.target_scroll = 3.0;
d.frames(1);
let v = &d.app().ui.timeline;
assert_eq!((v.pps, v.scroll), (50.0, 3.0), "in one step");
// and back on
d.exec("prefs.set", json!({"key": "appearance.animations", "value": true}));
d.frames(2);
assert!(animation_time(&mut d) > 0.0);
}
+161
View File
@@ -0,0 +1,161 @@
//! Headless UI test of track colour labels (#1186): the strip at the left edge of a track header
//! opens the colour menu; the label tints the header and the lane and is an undoable track setting.
//!
//! Set `FILMCRAFT_UI_SNAPSHOT_DIR=<dir>` to also render offscreen with wgpu and write `track-color-*.png`.
#![allow(dead_code)]
use std::sync::mpsc::{Sender, channel};
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<ControlRequest>,
snapshots: Option<std::path::PathBuf>,
}
impl Driver {
fn demo() -> Self {
let mut session = Session::default();
session.execute("file.openDemoProject", json!({})).expect("demo project");
let (tx, rx) = channel();
let app = FilmcraftApp::new(session).with_control(rx);
let snapshots = std::env::var_os("FILMCRAFT_UI_SNAPSHOT_DIR").map(std::path::PathBuf::from);
let mut b = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000);
if snapshots.is_some() {
b = b.wgpu();
}
let harness = b.build_eframe(move |_cc| app);
let mut d = Driver { harness, tx, snapshots };
d.frames(4);
d
}
fn frames(&mut self, n: usize) {
for _ in 0..n {
let ctx = self.harness.ctx.clone();
let mut raw = std::mem::take(self.harness.input_mut());
eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw);
*self.harness.input_mut() = raw;
self.harness.step();
}
}
fn call(&mut self, method: &str, params: Value) -> Value {
let (req, reply) = ControlRequest::new(method, params.clone());
self.tx.send(req).unwrap();
for _ in 0..600 {
self.frames(1);
if let Ok(v) = reply.try_recv() {
return v;
}
}
panic!("no reply to {method} {params}");
}
fn ok(&mut self, method: &str, params: Value) -> Value {
let v = self.call(method, params.clone());
assert_eq!(v["ok"], json!(true), "{method} {params} failed: {v}");
v["result"].clone()
}
fn exec(&mut self, command: &str, params: Value) -> Value {
self.ok("engine.execute", json!({"command": command, "params": params}))
}
fn click(&mut self, id: &str) {
self.ok("ui.click", json!({"id": id}));
self.frames(2);
}
fn element(&mut self, id: &str) -> Option<[f32; 4]> {
let v = self.ok("ui.elements", json!({"prefix": id}));
v.as_array()?.iter().find(|e| e["id"] == id).map(|e| {
let r = &e["rect"];
[r[0].as_f64().unwrap() as f32, r[1].as_f64().unwrap() as f32, r[2].as_f64().unwrap() as f32, r[3].as_f64().unwrap() as f32]
})
}
fn ids(&mut self, prefix: &str) -> Vec<String> {
let v = self.ok("ui.elements", json!({"prefix": prefix}));
v.as_array().unwrap().iter().filter_map(|e| e["id"].as_str().map(str::to_string)).collect()
}
/// Offscreen render of the window (or the bounding box of the elements under `prefix`).
fn snapshot(&mut self, name: &str, prefix: Option<&str>) {
let Some(dir) = self.snapshots.clone() else { return };
let crop = prefix.map(|p| {
let v = self.ok("ui.elements", json!({"prefix": p}));
let mut bb = [f32::MAX, f32::MAX, f32::MIN, f32::MIN];
for e in v.as_array().unwrap() {
let r = &e["rect"];
let (x, y, w, h) = (r[0].as_f64().unwrap() as f32, r[1].as_f64().unwrap() as f32, r[2].as_f64().unwrap() as f32, r[3].as_f64().unwrap() as f32);
bb = [bb[0].min(x), bb[1].min(y), bb[2].max(x + w), bb[3].max(y + h)];
}
bb
});
self.frames(2);
let img = match self.harness.render() {
Ok(i) => i,
Err(e) => {
eprintln!("snapshot {name} skipped: {e}");
return;
}
};
let ppp = img.width() as f32 / 1600.0;
let img = match crop {
Some(bb) => {
let x0 = ((bb[0] - 24.0) * ppp).max(0.0) as u32;
let y0 = ((bb[1] - 30.0) * ppp).max(0.0) as u32;
let x1 = (((bb[2] + 16.0) * ppp) as u32).min(img.width());
let y1 = (((bb[3] + 46.0) * ppp) as u32).min(img.height());
image::imageops::crop_imm(&img, x0, y0, x1 - x0, y1 - y0).to_image()
}
None => img,
};
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(format!("{name}.png"));
img.save(&path).unwrap();
eprintln!("snapshot: {}", path.display());
}
}
fn label(d: &mut Driver, kind: &str, index: usize) -> Value {
d.exec("sequence.inspect", json!({}))[kind][index]["label"].clone()
}
#[test]
fn the_track_header_strip_sets_a_colour_label() {
let mut d = Driver::demo();
assert_eq!(label(&mut d, "video", 0), json!(null));
let ids = d.ids("timeline.track.");
for id in ["timeline.track.V1.color", "timeline.track.A1.color"] {
assert!(ids.iter().any(|i| i == id), "{id} missing from {ids:?}");
}
// the strip sits left of the source patch, inside the header
let strip = d.element("timeline.track.V1.color").unwrap();
let patch = d.element("timeline.track.V1.sourcePatch").unwrap();
assert!(strip[0] + strip[2] <= patch[0], "strip {strip:?} overlaps the source patch {patch:?}");
d.click("timeline.track.V1.color");
let menu = d.ids("timeline.track.V1.color.");
assert!(menu.iter().any(|i| i == "timeline.track.V1.color.Forest") && menu.iter().any(|i| i == "timeline.track.V1.color.None"), "{menu:?}");
d.click("timeline.track.V1.color.Forest");
assert_eq!(label(&mut d, "video", 0), json!("Forest"));
assert!(!d.ids("timeline.track.V1.color.").iter().any(|i| i == "timeline.track.V1.color.Forest"), "the menu closes");
// audio tracks too
d.click("timeline.track.A1.color");
d.click("timeline.track.A1.color.Mango");
assert_eq!(label(&mut d, "audio", 0), json!("Mango"));
d.snapshot("track-color-labels", None);
// None clears; undo brings it back
d.click("timeline.track.V1.color");
d.click("timeline.track.V1.color.None");
assert_eq!(label(&mut d, "video", 0), json!(null));
d.exec("edit.undo", json!({}));
assert_eq!(label(&mut d, "video", 0), json!("Forest"));
}
+8 -4
View File
@@ -27,7 +27,7 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement**
| G11 | No plugin hosting | ecosystem | no | 200–400 h |
| G12 | AI features | AI | no | 150–300 h |
| G13 | Delivery formats | file format | no | 60–110 h |
| G14 | Localization: 8 of the 12 key languages missing | localization | no | 150–230 h |
| G14 | Localization: 3 of the 12 key languages missing | localization | no | 150–230 h |
| G15 | Interchange never validated in Avid / Pro Tools; slow XML import | file format | partly | 20–40 h |
| G16 | Performance reports on 0.5.0 | performance | partly | 30–60 h |
| G17 | Audio device and playback reliability | stability | partly | 20–40 h |
@@ -231,22 +231,26 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement**
(audio-only `.m4a`, or ADTS `.aac`; #814); ProRes 4444 / 4444 XQ in the export UI (#342).
- **Estimate:** 60–110 h. Doc: [file-format-parity.md](file-format-parity.md).
## G14. Localization: 8 of the 12 key languages missing
## G14. Localization: 3 of the 12 key languages missing (Hindi, Arabic, Vietnamese)
- **Have:** Spanish, Japanese, Simplified Chinese, French (#498) and Portuguese (Brazil) (#859)
catalogs cover ~99.6–100% of the ~3,500 cataloged strings; Ukrainian ~10% (menus).
- **Missing:** Hindi, Arabic (no RTL interface layout or catalog; the interface has Arabic and Hebrew
fallback fonts and shows captions in reading order, but other names and fields keep egui's
left-to-right run order; Arabic titles reported broken, #395),
German beyond menus (#490), Korean (#508; needs Hangul fonts in craft-fonts),
Korean (#508; needs Hangul fonts in craft-fonts),
Vietnamese; Premiere also ships Russian. Engine errors,
CLI and MCP stay English. No language has a recorded native-speaker review.
CLI and MCP stay English. Apart from the German menus (#630), no language has a recorded
native-speaker review.
- **Done:** Indonesian (#867): `id.tsv` has every entry of the Spanish catalog (~100%), in Edit ▸
Language, Settings ▸ General and the native macOS menu; System Language picks it for `id-*` (and
legacy `in-*`) locales. Native-speaker review welcome.
- **Done:** Italian: `it.tsv` has every entry of the Spanish catalog (~100%), in Edit ▸ Language,
Settings ▸ General and the native macOS menu; System Language picks it for `it-*` locales.
Native-speaker review welcome.
- **Done:** German: `de.tsv` has every entry of the Spanish catalog (~100%), beyond the menus of
#630 (panels, dialogs, effects and their parameters, settings, status messages). The menus are
native-reviewed; review of the rest welcome.
- **Estimate:** 150–230 h. Doc: [localization-parity.md](localization-parity.md).
## G15. Interchange never validated in Avid / Pro Tools; slow XML import
+4 -1
View File
@@ -13,7 +13,10 @@ kerning and ligatures, bidi, line breaking, all drawn as vectors in linear light
- Its **layers** are effect instances `graphic_text` / `graphic_shape` in the clip's effect list,
in paint order (first = back). They are hidden from the Effects panel. Because they are effect
instances they save with the project, take keyframes on every animatable property (Source Text
keyframes hold), appear in Effect Controls and copy with the clip. Standard effects on a graphic
keyframes hold), appear in Effect Controls and copy with the clip. The Essential Graphics
panel's number and point properties (Tracking, Leading, Font Size, Position, Opacity…) carry
the same stopwatch as Effect Controls (`graphics.prop.<id>.stopwatch`, #2358): once a property
animates, changing it in the panel adds or updates a keyframe at the playhead. Standard effects on a graphic
clip apply to the composed graphic; Motion and Opacity apply last.
- `filmcraft_project::graphic::eval_layer` evaluates a layer at a time into a `LayerSpec`.
- Projects with graphics need no schema migration: everything is additive (new item kind, new
+13 -7
View File
@@ -1,6 +1,6 @@
# Localization parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (Korean covers the whole interface, #508) · **Target:** Adobe Premiere Pro 2026 (26.5.2)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (German covers the whole interface) · **Target:** Adobe Premiere Pro 2026 (26.5.2)
Per-language status of FilmCraft's interface. Premiere 26.5.2 ships 10 interface languages (the
installed bundle's `.lproj` folders): German, English, Spanish, French, Italian, Japanese, Korean,
@@ -17,8 +17,8 @@ are reachable over the control channel. One catalog per language,
commands, panels, workspaces, presets) are translated where they are drawn. Searches match the
displayed labels as well as English sources. Tests fail when a `tl!` literal, a menu label or a
registry name has no Spanish entry; `zh-cn.tsv` must also cover every `tl!` literal and menu
label; `ja.tsv`, `fr.tsv` and `pt-br.tsv` have the same entries but are not yet required by tests
(French must cover the core menus, Brazilian Portuguese every menu label, and neither may keep an
label; `ja.tsv`, `fr.tsv`, `pt-br.tsv` and `de.tsv` have the same entries but are not yet required by tests
(French and German must cover the core menus, Brazilian Portuguese every menu label, and neither may keep an
entry without a Spanish one). Engine error messages, the
control channel, the CLI and MCP stay English.
@@ -50,17 +50,22 @@ whole interface, one PR) at ~4–8 h per language for the catalog plus tests.
| Portuguese (Brazil) | pt-br | 3,541 (same entries as Spanish, plus the macOS Settings item) | as Spanish | Latin (bundled fonts) | not recorded; welcome | partial (#859) | review |
| Indonesian | id | 3,541 (same entries as Spanish, plus the macOS Settings item); must cover the core menus, no entry without a Spanish one | as Spanish | Latin (bundled fonts); System Language also maps the legacy `in` code | not recorded; welcome | partial (#867) | review |
| Japanese | ja | 3,487 (99.7%) | as zh-cn; system-font kanji may pick the Chinese fallback (#547) | CJK fonts, vertical title text | not recorded | partial | 4–8 h + review |
| German | de | 371 (10.6%) | menus only | Latin | yes (#630) | menus only | 5–9 h + review |
| German | de | 3,590 (same entries as Spanish); must cover the core menus and the macOS app menu, no entry without a Spanish one | as Spanish | Latin (bundled fonts) | menus yes (#630); the rest not recorded, welcome | partial | review |
| Korean | ko | 3,573 (same entries as Spanish, plus the macOS Settings item) | as Spanish | installed system Hangul font (Malgun Gothic, Apple SD Gothic Neo, Noto Sans CJK KR…); refused without one | not recorded; welcome | partial (#508) | review; Hangul in craft-fonts for the web |
| Vietnamese | vi | 0 (0%) | — | Latin with stacked diacritics (font coverage to check) | — | none | 6–12 h + review |
| *Other shipped:* Italian | it | 3,573 (same entries as Spanish, plus the macOS Settings item); must cover the core menus, no entry without a Spanish one | as Spanish | Latin (bundled fonts) | not recorded; welcome | partial | review |
| *Other shipped:* Ukrainian | uk | 351 (10.0%) | menus only | Cyrillic (bundled) | not recorded | menus only | 5–9 h |
UI strings are catalog entries (the non-comment lines of `src/i18n/<code>.tsv`; English counts the
source strings in the code), so the rows were measured at different times: at this change the
Spanish and German catalogs both have 3,590 entries, German every Spanish one. Strings added
later may stay English in German until translated; only the core menus are required.
Premiere also ships Italian, which we now cover like Spanish, and Russian (6–10 h).
**Localization dimension:** 6 of the 12 key languages near-complete (English, Spanish, Japanese,
Simplified Chinese, French, Portuguese (Brazil)), 1 menus-only, 5 none; against Premiere's 10 languages
we cover 6 well and 1 partly. **~35%, 150–230 h** for all twelve to `full` plus Italian and Russian, of which the script
**Localization dimension:** 9 of the 12 key languages near-complete (English, Spanish, Japanese,
Simplified Chinese, French, Portuguese (Brazil), Indonesian, Korean, German), 3 none (Hindi, Arabic,
Vietnamese); against Premiere's 10 languages we cover 9 well and Russian partly. **~35%, 150–230 h** for all twelve to `full` plus Italian and Russian, of which the script
work (RTL and complex shaping in the egui interface) is the largest and least parallel part.
Every language needs native-speaker review (human).
@@ -68,5 +73,6 @@ Every language needs native-speaker review (human).
| Date | Change | Summary |
|---|---|---|
| 2026-10-13 | minor | German (de) covers the whole interface: every entry of the Spanish catalog (3,590); the tests require the core menus and the macOS app menu and reject entries without a Spanish one |
| 2026-10-11 | minor | German (de) ships the menus (#630): 371 of 3,498 strings (10.6%), native-reviewed; dimension now 2 menus-only, 5 none |
| 2026-10-10 | major | Created from ROADMAP.md's "Interface localization" section; catalog coverage measured (es 3,496, ja 3,487, zh-cn 3,485, pt-br 348, uk 351 of 3,498); the 12-language table and script-support gaps added |