#1714: reopen each comp at its saved current time

Layer times were already saved and read back as exact ticks; the values in
the issue are 34f, 315f and 3033f at 59.94 fps. The viewer was black because
the playhead wasn't saved: a reopened comp started at 0, before its layers.
The project file's view (`activeComp`, `openComps`) now also records each
comp's current time (`compTimes`), restored on open within the comp's frames.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
arnav-hiwarkar
2026-10-17 14:32:14 +05:30
co-authored by Claude Opus 5.5
parent 8f02f52a44
commit b9172fec2f
4 changed files with 67 additions and 9 deletions
+16 -7
View File
@@ -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<ItemId> = Vec::new();
for id in view.open_comps.iter().copied().filter(|id| self.project.comp(*id).is_some()) {
if !open.contains(&id) {
+38
View File
@@ -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()));
}
+11
View File
@@ -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<ItemId>,
/// 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<ItemId, Tick>,
}
/// 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_json::Value>,
#[serde(rename = "openComps", default)]
open_comps: Option<serde_json::Value>,
#[serde(rename = "compTimes", default)]
comp_times: Option<serde_json::Value>,
}
let Ok(m) = serde_json::from_str::<Meta>(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()),
}
}
+2 -2
View File
@@ -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