diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 96e09b57..3086f378 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -344,6 +344,11 @@ fn one_view() -> u8 { 1 } +/// `t` as a current time of `comp`: a frame within its duration. +fn comp_frame_at(comp: &Comp, t: Tick) -> Tick { + comp.frame_rate.snap_nearest(t.min(comp.duration - comp.frame_duration()).max(Tick::ZERO)) +} + /// What Copy with Property Links / Copy Expression Only put on the clipboard. #[derive(Clone, Debug, PartialEq)] pub enum LinkClip { @@ -860,11 +865,7 @@ impl Session { pub fn set_time(&mut self, t: Tick) { if let Some(c) = self.state.active_comp { - let comp = self.project.comp(c); - let t = match comp { - Some(comp) => comp.frame_rate.snap_nearest(t.clamp(Tick::ZERO, comp.duration - comp.frame_duration())), - None => t, - }; + let t = self.project.comp(c).map_or(t, |comp| comp_frame_at(comp, t)); self.state.times.insert(c, t); if self.prefs.general.sync_time_related_items { self.sync_related_times(c, t); @@ -985,13 +986,21 @@ impl Session { /// The open compositions, as a project file records them ([`effectcraft_project::SavedView`]). pub fn saved_view(&self) -> effectcraft_project::SavedView { - effectcraft_project::SavedView { active_comp: self.state.active_comp, open_comps: self.state.open_comps.clone() } + let comp_times = self.state.times.iter().filter(|(id, _)| self.project.comp(**id).is_some()).map(|(id, t)| (*id, *t)).collect(); + effectcraft_project::SavedView { active_comp: self.state.active_comp, open_comps: self.state.open_comps.clone(), comp_times } } - /// Reopen the compositions a project file was saved with (#748). Ids that no longer name a + /// Reopen the compositions a project file was saved with (#748), each at its saved current + /// time (#1714). Ids that no longer name a /// comp are skipped; with none left the project stays on the comp [`Session::replace_project`] /// opened (its first), as files from before the view was saved do. pub fn restore_view(&mut self, view: &effectcraft_project::SavedView) { + for (id, t) in &view.comp_times { + if let Some(comp) = self.project.comp(*id) { + let t = comp_frame_at(comp, *t); + self.state.times.insert(*id, t); + } + } let mut open: Vec = Vec::new(); for id in view.open_comps.iter().copied().filter(|id| self.project.comp(*id).is_some()) { if !open.contains(&id) { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 72133c5d..458583ee 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -686,3 +686,41 @@ fn reopening_a_project_restores_its_active_comp() { o.insert("openComps".into(), open); } } + +/// Layer times are saved as exact ticks and read back unchanged, and each comp reopens at the +/// current time it was saved at, not at 0, where a layer starting later shows nothing (#1714). +#[test] +fn reopening_a_project_restores_layer_times_and_current_time() { + use effectcraft_time::{FrameRate, Tick}; + let dir = std::env::temp_dir().join(format!("ec-test-1714-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("times.ecproj").to_string_lossy().to_string(); + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "C", "width": 108, "height": 192, "duration": 60})).unwrap(); + s.execute("layer.newSolid", json!({"name": "crown"})).unwrap(); + let cid = s.active_comp_id().unwrap(); + let comp = std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap(); + comp.frame_rate = FrameRate::FPS_59_94; + // The layer from the issue: 34f, 315f and 3033f at 59.94 fps. + let l = comp.layers.first_mut().unwrap(); + (l.start_time, l.in_point, l.out_point) = (Tick(144_086_342_400), Tick(1_334_917_584_000), Tick(12_853_349_308_800)); + s.execute("time.set", json!({"time": 6.0})).unwrap(); + let t = s.time(); + s.execute("file.saveAs", json!({"path": path})).unwrap(); + + let mut s2 = Session::default(); + s2.execute("file.open", json!({"path": path})).unwrap(); + let l = s2.project.comp(cid).unwrap().layers.first().unwrap(); + assert_eq!((l.start_time, l.in_point, l.out_point), (Tick(144_086_342_400), Tick(1_334_917_584_000), Tick(12_853_349_308_800))); + assert_eq!(s2.time(), t); + assert!(l.in_point <= s2.time() && s2.time() < l.out_point, "the layer shows at the reopened time"); + assert!(!s2.is_dirty()); + + // A saved time past the comp's end (a hand-edited file) lands on its last frame. + let text = std::fs::read_to_string(&path).unwrap().replace(&format!("\"{}\": {}", cid.0, t.0), &format!("\"{}\": {}", cid.0, i64::MAX)); + std::fs::write(&path, text).unwrap(); + let mut s3 = Session::default(); + s3.execute("file.open", json!({"path": path})).unwrap(); + let c = s3.project.comp(cid).unwrap(); + assert_eq!(s3.time(), c.frame_rate.snap_nearest(c.duration - c.frame_duration())); +} diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 16d78216..be35590a 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -62,6 +62,10 @@ pub struct SavedView { /// The open comps (Timeline tabs), in tab order. #[serde(skip_serializing_if = "Vec::is_empty")] pub open_comps: Vec, + /// Each comp's current time, so a project reopens on the frame it was saved at rather than + /// at 0, where its layers may not have started yet (#1714). + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub comp_times: BTreeMap, } /// The view a project file was saved with ([`SavedView`]); empty for older files. Values that @@ -73,6 +77,8 @@ pub fn saved_view(text: &str) -> SavedView { active_comp: Option, #[serde(rename = "openComps", default)] open_comps: Option, + #[serde(rename = "compTimes", default)] + comp_times: Option, } let Ok(m) = serde_json::from_str::(text) else { return SavedView::default() }; SavedView { @@ -82,6 +88,11 @@ pub fn saved_view(text: &str) -> SavedView { .as_ref() .and_then(serde_json::Value::as_array) .map_or_else(Vec::new, |a| a.iter().filter_map(serde_json::Value::as_u64).map(ItemId).collect()), + comp_times: m + .comp_times + .as_ref() + .and_then(serde_json::Value::as_object) + .map_or_else(BTreeMap::new, |o| o.iter().filter_map(|(k, v)| Some((ItemId(k.parse().ok()?), Tick(v.as_i64()?)))).collect()), } } diff --git a/crates/project/src/tests.rs b/crates/project/src/tests.rs index c19d81ee..4edd8661 100644 --- a/crates/project/src/tests.rs +++ b/crates/project/src/tests.rs @@ -173,14 +173,14 @@ fn project_files_name_their_version_and_always_read_back() { #[test] fn project_files_remember_the_open_comps() { let (p, cid) = project_with_layer(); - let view = crate::SavedView { active_comp: Some(cid), open_comps: vec![cid] }; + let view = crate::SavedView { active_comp: Some(cid), open_comps: vec![cid], comp_times: [(cid, effectcraft_time::Tick(4_237_833_600))].into() }; let text = p.to_file_json_with_view(&view).unwrap(); assert_eq!(crate::saved_view(&text), view); assert_eq!(Project::from_json(&text).unwrap(), p, "the view is not part of the project"); // Files from before #748 have no view; a garbled one is ignored, not an error. assert_eq!(crate::saved_view(&p.to_file_json().unwrap()), crate::SavedView::default()); let odd = text.replacen(&format!("\"activeComp\": {}", cid.0), "\"activeComp\": \"Main\"", 1); - assert_eq!(crate::saved_view(&odd), crate::SavedView { active_comp: None, open_comps: vec![cid] }); + assert_eq!(crate::saved_view(&odd), crate::SavedView { active_comp: None, ..view }); } /// The Project panel's "used N times": layers in every comp that use the item directly, a precomp