diff --git a/crates/engine/src/cmd/time.rs b/crates/engine/src/cmd/time.rs index 556c02bcb..49a3caede 100644 --- a/crates/engine/src/cmd/time.rs +++ b/crates/engine/src/cmd/time.rs @@ -43,7 +43,9 @@ pub fn display_duration(s: &Session, comp: &Comp, len: Tick) -> String { } /// Parse a time typed in the project's Time Display Style (relative `+n`/`-n` from frame -/// `current`) into a comp frame. +/// `current`) into a comp frame. In Timecode style, in a comp whose Start Timecode is negative, a +/// `-` before a timecode with separators (`-0:00:00:12`, `-1.00`) is the sign of a displayed time, +/// not a step back; a bare `-n` still steps back. pub fn parse_display_time(s: &Session, comp: &Comp, text: &str, current: i64) -> std::result::Result { let fr = comp.frame_rate; let st = &s.project.settings; @@ -53,6 +55,20 @@ pub fn parse_display_time(s: &Session, comp: &Comp, text: &str, current: i64) -> effectcraft_time::parse_feet_frames(text, pf, current + start).map(|f| f - start).map_err(|e| e.0) } None => { + // A comp whose Start Timecode is negative shows the times before 0 with a `-` + // (`-0:00:00:12`), so there a `-` before a timecode with separators is the sign of a + // displayed time and typing what the display shows lands on the frame it shows. A bare + // `-n` still moves n frames back. + if st.time_display == TimeDisplayStyle::Timecode + && comp.display_start < Tick::ZERO + && let Some(body) = text.trim().strip_prefix('-').map(str::trim_start) + && body.contains([':', ';', '.', ',']) + && !body.starts_with(['+', '-']) + { + let drop_frame = fr.supports_drop_frame() && body.contains(';'); + let shown = effectcraft_time::parse_timecode(body, fr, drop_frame, current).map_err(|e| e.0)?; + return Ok(shown.saturating_neg().saturating_sub(fr.frame_at(comp.display_start))); + } // The Timeline shows non-drop timecode (`:`), so typed `:`/`.` text is read as non-drop // too; only explicit `;` fields mean drop-frame (#890). let drop_frame = fr.supports_drop_frame() && text.contains(';'); diff --git a/crates/engine/src/tests/timeline.rs b/crates/engine/src/tests/timeline.rs index 55475eef3..b5a44239b 100644 --- a/crates/engine/src/tests/timeline.rs +++ b/crates/engine/src/tests/timeline.rs @@ -649,6 +649,31 @@ fn time_set_keeps_the_frame_shown_in_the_current_time_field() { assert_eq!(r["frame"], 13); } +/// A comp that starts before 0 shows its first frames as negative timecode (`-0:00:00:12`). +/// Typing back what the current-time display shows (Go to Time opens with it) stays on that frame; +/// the leading `-` used to move back from the current frame instead. A bare `-n` still steps back, +/// and in a comp that starts at 0 a `-` stays relative. +#[test] +fn time_set_round_trips_negative_displayed_timecode() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Pre-roll", "width": 64, "height": 64, "frameRate": 24, "duration": 4, "startTime": -1})).unwrap(); + for frame in [0_i64, 1, 12, 23, 24, 25, 60] { + let r = s.execute("time.set", json!({"frame": frame})).unwrap(); + let display = r["display"].as_str().unwrap().to_string(); + let r = s.execute("time.set", json!({"timecode": display})).unwrap(); + assert_eq!(r["frame"], frame, "displayed {display}"); + } + s.execute("time.set", json!({"frame": 12})).unwrap(); + assert_eq!(s.execute("time.set", json!({"timecode": "-0:00:00:12"})).unwrap()["display"], "-0:00:00:12"); + assert_eq!(s.execute("time.set", json!({"timecode": "-5"})).unwrap()["frame"], 7, "a bare -n steps back"); + assert_eq!(s.execute("time.set", json!({"timecode": "-0:00:00:20"})).unwrap()["frame"], 4); + assert!(s.execute("time.set", json!({"timecode": "- -1:00"})).is_err(), "a second sign is still an error"); + + s.execute("comp.settings", json!({"startTime": 0})).unwrap(); + s.execute("time.set", json!({"frame": 12})).unwrap(); + assert_eq!(s.execute("time.set", json!({"timecode": "-0:00:00:02"})).unwrap()["frame"], 10, "relative when the comp starts at 0"); +} + /// Frames display style: typed digits are a frame count, so 00100 is frame 100, not 1 s (#530). #[test] fn time_set_reads_frames_style_digits_as_a_frame_count() { diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index 9b94360a5..58f89e39d 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -1130,6 +1130,22 @@ mod tests { assert_eq!(at, 19, "displayed 00020 with frames starting at 1"); } + /// In a comp that starts 1 s before 0, Go to Time at frame 12 opens on the negative time the + /// viewer shows, and OK without editing it stays on frame 12 (it used to jump to frame 0). + #[test] + fn go_to_time_ok_keeps_a_negative_displayed_time() { + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + app.session.execute("comp.new", json!({"name": "Main", "width": 64, "height": 36, "frameRate": 24, "duration": 4, "startTime": -1})).unwrap(); + app.session.execute("time.set", json!({"frame": 12})).unwrap(); + assert!(open_form(&mut app, "time.set", &json!({}))); + let f = &app.dialog_state.form; + assert!(matches!(&f.fields[0].kind, FieldKind::Text(t) if t == "-0:00:00:12"), "{:?}", f.fields[0]); + let (command, params) = (f.command.clone(), f.params()); + app.session.execute(&command, params).unwrap(); + let at = app.session.active_comp().unwrap().frame_rate.frame_at(app.session.time()); + assert_eq!(at, 12, "OK on the unedited time stays put"); + } + /// File ▸ Export ▸ Adobe Premiere Pro Project… opens a form that says it writes Final Cut Pro /// XML; its fields register automation ids and OK runs `file.exportTimeline`. #[test]