Merge #2185: Project Settings: Frame Count, Start at 0 or Start at 1 (@marcoarnulfo)

This commit is contained in:
aiongg
2026-10-24 00:13:55 +00:00
11 changed files with 133 additions and 3 deletions
+10 -1
View File
@@ -665,6 +665,15 @@ fn project_settings(s: &mut Session, p: &Value) -> Result<Value> {
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<CommandSpec> {
"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
),
+14
View File
@@ -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<i64, String> {
+45
View File
@@ -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();