mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-24 10:09:32 -06:00
Merge #2185: Project Settings: Frame Count, Start at 0 or Start at 1 (@marcoarnulfo)
This commit is contained in:
@@ -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
|
||||
),
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1894,6 +1896,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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -152,6 +152,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) {
|
||||
|
||||
@@ -3692,7 +3692,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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user