mirror of
https://github.com/storytold/filmcraft.git
synced 2026-10-24 10:10:05 -06:00
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:
+3
-2
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)))
|
||||
{
|
||||
|
||||
+3225
-3
File diff suppressed because it is too large
Load Diff
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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"));
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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})");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user