From 952b04322ed672244bfb35c57c6a789a26cc48b0 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 01:04:47 +0200 Subject: [PATCH] Project Settings: Frame Count, Start at 0 or Start at 1 (part of #1807) file.projectSettings takes frameStart (0|1), the Project Settings dialog shows Frame Count, and scripts get app.project.framesCountType and displayStartFrame. The setting was already read by the Frames and Feet + Frames time display; nothing could change it. --- crates/engine/src/commands/file.rs | 11 +++++- crates/engine/src/commands/time.rs | 14 ++++++++ crates/engine/src/tests_timeline.rs | 45 ++++++++++++++++++++++++ crates/script/src/model.rs | 1 + crates/script/src/prelude.js | 10 ++++++ crates/script/src/tests.rs | 20 +++++++++++ crates/ui-egui/src/panels/anim_tools.rs | 8 ++++- crates/ui-egui/src/panels/forms.rs | 2 ++ crates/ui-egui/src/panels/mod.rs | 5 +++ crates/ui-egui/src/panels/timeline.rs | 6 +++- crates/ui-egui/tests/ui_timeline_trim.rs | 14 ++++++++ 11 files changed, 133 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/commands/file.rs b/crates/engine/src/commands/file.rs index 8c4c384e..0fea452d 100644 --- a/crates/engine/src/commands/file.rs +++ b/crates/engine/src/commands/file.rs @@ -665,6 +665,15 @@ fn project_settings(s: &mut Session, p: &Value) -> Result { proj.settings.time_display = effectcraft_project::TimeDisplayStyle::parse(t) .ok_or_else(|| bad("file.projectSettings", format!("timeDisplay: timecode|frames|feet35|feet16, not `{t}`")))?; } + if let Some(f) = p.get("frameStart") { + // Frame Count: After Effects offers Start at 0 and Start at 1 only. Scripts and dialog + // number fields may send `1.0`. + proj.settings.frame_start = match f.as_f64() { + Some(0.0) => 0, + Some(1.0) => 1, + _ => return Err(bad(cmd, format!("frameStart: 0|1, not `{f}`"))), + }; + } Ok(()) })?; Ok(serde_json::to_value(&s.project.settings).unwrap_or_default()) @@ -745,7 +754,7 @@ pub fn specs() -> Vec { "Project Settings...", ["File"], Some("Cmd+Alt+Shift+K"), - "{bitDepth?: 8|16|32, colorEngine?: adobe|ocio, workingSpace?: none|srgb|rec709|rec2020|p3|acescg|aces2065, workingGamma?: 2.2|2.4 (with no working space), linearize?, blendLinear?, hdr?: clip|compand|toneMap, outputSpace?: srgb|rec709|rec2020|p3|rec2100pq|rec2100hlg, renderer?: gpu|software, timeDisplay?: timecode|frames|feet35|feet16}", + "{bitDepth?: 8|16|32, colorEngine?: adobe|ocio, workingSpace?: none|srgb|rec709|rec2020|p3|acescg|aces2065, workingGamma?: 2.2|2.4 (with no working space), linearize?, blendLinear?, hdr?: clip|compand|toneMap, outputSpace?: srgb|rec709|rec2020|p3|rec2100pq|rec2100hlg, renderer?: gpu|software, timeDisplay?: timecode|frames|feet35|feet16, frameStart?: 0|1 (Frame Count: Start at 0 / Start at 1)}", always, project_settings ), diff --git a/crates/engine/src/commands/time.rs b/crates/engine/src/commands/time.rs index 8cb3071b..556c02bc 100644 --- a/crates/engine/src/commands/time.rs +++ b/crates/engine/src/commands/time.rs @@ -28,6 +28,20 @@ pub fn display_time(s: &Session, comp: &Comp, t: Tick) -> String { } } +/// A length (a layer's Duration) in the project's Time Display Style: a count of frames, so +/// neither Start Numbering Frames At nor the comp's Start Timecode moves it. +pub fn display_duration(s: &Session, comp: &Comp, len: Tick) -> String { + let fr = comp.frame_rate; + let n = fr.frame_at(len); + match s.project.settings.time_display { + TimeDisplayStyle::Frames => format!("{n:05}"), + TimeDisplayStyle::Feet35 | TimeDisplayStyle::Feet16 => { + effectcraft_time::format_feet_frames(n, s.project.settings.time_display.frames_per_foot().unwrap_or(16)) + } + TimeDisplayStyle::Timecode => effectcraft_time::format_timecode_ae(n, fr, false), + } +} + /// Parse a time typed in the project's Time Display Style (relative `+n`/`-n` from frame /// `current`) into a comp frame. pub fn parse_display_time(s: &Session, comp: &Comp, text: &str, current: i64) -> std::result::Result { diff --git a/crates/engine/src/tests_timeline.rs b/crates/engine/src/tests_timeline.rs index 29168e17..33dac779 100644 --- a/crates/engine/src/tests_timeline.rs +++ b/crates/engine/src/tests_timeline.rs @@ -613,6 +613,51 @@ fn time_set_reads_frames_style_digits_as_a_frame_count() { assert_eq!(r["display"], "00100"); } +/// Project Settings ▸ Time Display Style ▸ Frames ▸ Frame Count: Start at 0 / Start at 1 +/// (`app.project.framesCountType`), undoable and saved with the project (#1807). +#[test] +fn project_settings_frame_count_starts_at_one() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Count", "width": 64, "height": 64, "frameRate": 30, "duration": 2})).unwrap(); + s.execute("file.projectSettings", json!({"timeDisplay": "frames"})).unwrap(); + assert_eq!(s.execute("time.set", json!({"frame": 12})).unwrap()["display"], "00012"); + + s.execute("file.projectSettings", json!({"frameStart": 1})).unwrap(); + assert_eq!(s.execute("time.set", json!({"frame": 12})).unwrap()["display"], "00013"); + for bad in [json!(2), json!(-1), json!("one")] { + assert!(s.execute("file.projectSettings", json!({"frameStart": bad})).is_err(), "{bad}"); + } + assert_eq!(s.project.settings.frame_start, 1); + + let dir = std::env::temp_dir().join(format!("ec-frame-start-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("count.ecproj").to_string_lossy().to_string(); + s.execute("file.save", json!({"path": path})).unwrap(); + let mut reopened = Session::default(); + reopened.execute("file.open", json!({"path": path})).unwrap(); + let _ = std::fs::remove_dir_all(dir); + assert_eq!(reopened.project.settings.frame_start, 1); + + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.execute("time.set", json!({"frame": 12})).unwrap()["display"], "00012"); +} + +/// A Duration is a count of frames: Start at 1 and the comp's Start Timecode number positions, +/// not lengths (#1807). +#[test] +fn durations_ignore_the_frame_count_and_the_start_timecode() { + use crate::commands::time::display_duration; + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Len", "width": 64, "height": 64, "frameRate": 30, "duration": 10, "startTimecode": "0:00:01:00"})).unwrap(); + s.execute("file.projectSettings", json!({"frameStart": 1})).unwrap(); + let comp = s.active_comp().unwrap().clone(); + let len = comp.frame_rate.tick_of(30); + for (style, want) in [("frames", "00030"), ("feet35", "0001+14"), ("timecode", "0:00:01:00")] { + s.execute("file.projectSettings", json!({"timeDisplay": style})).unwrap(); + assert_eq!(display_duration(&s, &comp, len), want, "{style}"); + } +} + #[test] fn separate_dimensions_split_and_rejoin() { let (mut s, l) = setup(); diff --git a/crates/script/src/model.rs b/crates/script/src/model.rs index 90b03ae5..23356c06 100644 --- a/crates/script/src/model.rs +++ b/crates/script/src/model.rs @@ -85,6 +85,7 @@ fn project(s: &Session) -> J { "workingSpace": p.settings.working_space.map(|w| format!("{w:?}")).unwrap_or_default(), "workingGamma": if p.settings.working_gamma_24() { 2.4 } else { 2.2 }, "timeDisplayFrames": p.settings.time_display == effectcraft_project::TimeDisplayStyle::Frames, + "displayStartFrame": p.settings.frame_start, "renderQueue": p.render_queue.len(), "revision": s.revision, }) diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index 52712bc0..d83b4192 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -98,6 +98,8 @@ var ImportAsType = __enum("ImportAsType", ["COMP_CROPPED_LAYERS", "FOOTAGE", "CO ImportAsType.COMPOSITION_CROPPED_LAYERS = ImportAsType.COMP_CROPPED_LAYERS; ImportAsType.COMPOSITION = ImportAsType.COMP; var TimeDisplayType = __enum("TimeDisplayType", ["FRAMES", "TIMECODE"], 2012); +// After Effects' values (FC_START_0 = 2612), as in docsforadobe/Types-for-Adobe. +var FramesCountType = __enum("FramesCountType", ["FC_START_0", "FC_START_1", "FC_TIMECODE_CONVERSION"], 2612); var LogType = __enum("LogType", ["ERRORS_ONLY", "ERRORS_AND_SETTINGS", "ERRORS_AND_PER_FRAME_INFO"], 3212); var KeyframeLabel = __enum("KeyframeLabel", ["NONE"], 0); @@ -1887,6 +1889,14 @@ Project.prototype = { set workingGamma(v) { __call("file.projectSettings", { workingGamma: __num(v) }); }, get timeDisplayType() { return this.__info().timeDisplayFrames ? TimeDisplayType.FRAMES : TimeDisplayType.TIMECODE; }, set timeDisplayType(v) { __call("file.projectSettings", { timeDisplay: v === TimeDisplayType.FRAMES ? "frames" : "timecode" }); }, + get framesCountType() { return this.__info().displayStartFrame === 1 ? FramesCountType.FC_START_1 : FramesCountType.FC_START_0; }, + set framesCountType(v) { + if (v === FramesCountType.FC_TIMECODE_CONVERSION) throw __err("framesCountType: Timecode Conversion is not supported, use FC_START_0 or FC_START_1"); + if (v !== FramesCountType.FC_START_0 && v !== FramesCountType.FC_START_1) throw __err("Bad framesCountType value " + v); + __call("file.projectSettings", { frameStart: v === FramesCountType.FC_START_1 ? 1 : 0 }); + }, + get displayStartFrame() { return this.__info().displayStartFrame; }, + set displayStartFrame(v) { __call("file.projectSettings", { frameStart: __num(v) }); }, get expressionEngine() { return "javascript-1.0"; }, set expressionEngine(v) {}, item: function (i) { diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index a1e96194..cf8590f6 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -611,6 +611,26 @@ fn engine_entry_points() { let _ = std::fs::remove_dir_all(&dir); } +/// `app.project.framesCountType` / `displayStartFrame`: Project Settings ▸ Frame Count (#1807). +#[test] +fn frames_count_type_sets_the_project_frame_count() { + let mut s = session(); + let o = ok( + &mut s, + "var p = app.project; var a = [p.framesCountType === FramesCountType.FC_START_0, p.displayStartFrame]; p.framesCountType = FramesCountType.FC_START_1; a.concat([p.framesCountType === FramesCountType.FC_START_1, p.displayStartFrame]).join('|')", + ); + assert_eq!(o.result, json!("true|0|true|1")); + // After Effects' enum values, so scripts that store the number keep working. + let o = ok(&mut s, "[FramesCountType.FC_START_0, FramesCountType.FC_START_1, FramesCountType.FC_TIMECODE_CONVERSION].join('|')"); + assert_eq!(o.result, json!("2612|2613|2614")); + assert_eq!(s.project.settings.frame_start, 1); + ok(&mut s, "app.project.displayStartFrame = 0"); + assert_eq!(s.project.settings.frame_start, 0); + let o = run_code(&mut s, "app.project.framesCountType = FramesCountType.FC_TIMECODE_CONVERSION", "test.jsx"); + assert!(o.error.is_some_and(|e| e.message.contains("Timecode Conversion"))); + assert_eq!(s.project.settings.frame_start, 0); +} + #[test] fn console_keeps_variables() { let mut s = session(); diff --git a/crates/ui-egui/src/panels/anim_tools.rs b/crates/ui-egui/src/panels/anim_tools.rs index 15a4f4f8..d9bcb171 100644 --- a/crates/ui-egui/src/panels/anim_tools.rs +++ b/crates/ui-egui/src/panels/anim_tools.rs @@ -184,7 +184,13 @@ pub fn motion_sketch(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let fr = c.frame_rate; let (s, e) = (app.session.time(), c.work_area.1); let tc = |x| crate::panels::timecode(&app.session, c, x); - p.text(pos2(x0, y + 8.0), Align2::LEFT_CENTER, tr_args("Start: {} Duration: {}", &[&(tc(s)), &(tc(e - s))]), Tokens::ui(11.0), t.text_faint); + p.text( + pos2(x0, y + 8.0), + Align2::LEFT_CENTER, + tr_args("Start: {} Duration: {}", &[&(tc(s)), &crate::panels::duration(&app.session, c, e - s)]), + Tokens::ui(11.0), + t.text_faint, + ); let _ = fr; y += ROW; let b = Rect::from_min_size(pos2(x0, y), vec2(120.0, 24.0)); diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index 185c1033..942bd9fe 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -542,6 +542,8 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { } }, ), + // Time Display Style ▸ Frames ▸ Frame Count (also numbers Feet + Frames). + Field::choice("frameStart", "Frame count", &[("Start at 0", json!(0)), ("Start at 1", json!(1))], usize::from(st.frame_start == 1)), Field::choice( "colorEngine", "Color engine", diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 3aa9a19c..b8bd04f5 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -151,6 +151,11 @@ pub fn timecode(session: &Session, comp: &Comp, t: Tick) -> String { effectcraft_engine::commands::time::display_time(session, comp, t) } +/// A length (a Duration) formatted per project settings, with no start offset. +pub fn duration(session: &Session, comp: &Comp, len: Tick) -> String { + effectcraft_engine::commands::time::display_duration(session, comp, len) +} + /// Ctrl/Cmd-click on a current-time display toggles the project's Time Display Style between /// Timecode and Frames, as in After Effects (through Project Settings: one undo step). pub(crate) fn toggle_time_display(app: &mut EffectcraftApp, resp: &egui::Response) { diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index b34fbd9d..ec2e51d6 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -3542,7 +3542,11 @@ fn layer_cells( continue; } let cr = Rect::from_min_max(pos2(x + 2.0, r.min.y + 1.0), pos2(x + TIME_W - 2.0, r.max.y - 1.0)); - let tc = crate::panels::timecode(&app.session, comp, Tick::from_seconds_f64(secs) - if kind == "duration" { comp.display_start } else { Tick(0) }); + let tc = if kind == "duration" { + crate::panels::duration(&app.session, comp, Tick::from_seconds_f64(secs)) + } else { + crate::panels::timecode(&app.session, comp, Tick::from_seconds_f64(secs)) + }; let resp = ui.interact(cr, egui::Id::new(("tl-cell", kind, lid)), Sense::drag()).on_hover_text(crate::i18n::tr("Drag to change")); lp.text(pos2(cr.min.x + 4.0, cy), Align2::LEFT_CENTER, &tc, Tokens::mono(11.0), if resp.hovered() || resp.dragged() { t.hot_text } else { t.timecode }); app.auto.add(&format!("timeline.layer.{lid}.{kind}"), cr, &tc); diff --git a/crates/ui-egui/tests/ui_timeline_trim.rs b/crates/ui-egui/tests/ui_timeline_trim.rs index d66bf5b7..6f95f744 100644 --- a/crates/ui-egui/tests/ui_timeline_trim.rs +++ b/crates/ui-egui/tests/ui_timeline_trim.rs @@ -119,3 +119,17 @@ fn dragging_bar_ends_trims_in_and_out() { let (i, o, s) = timing(&h, pre); assert!((i - 3.0).abs() < 1e-6 && (o - 8.0).abs() < 1.5 / 30.0 && s.abs() < 1e-6, "out extended to ~8 s, not slipped: in {i} out {o} start {s}"); } + +/// With Frames and Frame Count Start at 1, In moves to 00061 but the Duration column still counts +/// the layer's 120 frames (#1807). +#[test] +fn duration_column_counts_frames_from_any_frame_count() { + let (mut h, solid, _) = harness(); + effectcraft_ui_egui::panels::timeline::set_column(&mut h.state_mut().ui.timeline, "in", true).unwrap(); + effectcraft_ui_egui::panels::timeline::set_column(&mut h.state_mut().ui.timeline, "duration", true).unwrap(); + h.state_mut().session.execute("file.projectSettings", json!({"timeDisplay": "frames", "frameStart": 1})).unwrap(); + h.run_steps(3); + let cell = |h: &Harness<'_, EffectcraftApp>, kind: &str| h.state().auto.find(&format!("timeline.layer.{solid}.{kind}")).expect("cell").label.clone(); + assert_eq!(cell(&h, "in"), "00061"); + assert_eq!(cell(&h, "duration"), "00120"); +}