From 39a014d7fc9b5afe883fccbe0f21627ef5448bcf Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 00:55:04 -0400 Subject: [PATCH] Timeline foundation: document timeline model + engine commands Document.timeline (fps/duration/playhead/work-area), threaded through .pcraft. Engine timeline.{create,delete,setFrame,next/previousFrame, setProps,info} (navigation is non-history, scriptable for frame-range rendering); reported in document.inspect. Foundation for Layer > Video Layers + the Timeline panel (follow-up arcs). doc 4 + engine 2 tests; format/doc/engine green, clippy clean. Co-Authored-By: Claude Opus 4.8 --- crates/doc/src/lib.rs | 5 + crates/doc/src/video.rs | 68 ++++++++++++++ crates/engine/src/commands.rs | 1 + crates/engine/src/inspect.rs | 1 + crates/engine/src/lib.rs | 1 + crates/engine/src/timeline_cmds.rs | 143 +++++++++++++++++++++++++++++ crates/format/src/convert.rs | 2 + crates/format/src/manifest.rs | 3 + 8 files changed, 224 insertions(+) create mode 100644 crates/doc/src/video.rs create mode 100644 crates/engine/src/timeline_cmds.rs diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 54f225dc..11394efb 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -15,6 +15,7 @@ pub mod mode; pub mod pattern; pub mod slices; pub mod variables; +pub mod video; pub mod text; pub mod text_styles; pub mod vector; @@ -34,6 +35,7 @@ pub use photocraft_geom::{Affine, Rect, Size}; pub use photocraft_raster::Surface; pub use mode::{ColorTable, Duotone, DuotoneInk, StackMode}; pub use pattern::Pattern; +pub use video::Timeline; pub use variables::{DataSet, DataValue, PixelAlign, PixelMethod, VarKind, VariableDef, Variables}; pub use slices::{Slice, SliceKind, SliceOrigin, Slices}; pub use text_styles::TextStyles; @@ -613,6 +615,8 @@ pub struct Document { pub slices: Slices, /// Image › Variables and Data Sets (data-driven graphics). pub variables: Variables, + /// Window › Timeline (None until a video timeline is created). + pub timeline: Option, } /// Where a layer lives in the tree: indices from the root down. @@ -649,6 +653,7 @@ impl Document { text_styles: TextStyles::default(), slices: Slices::default(), variables: Variables::default(), + timeline: None, } } diff --git a/crates/doc/src/video.rs b/crates/doc/src/video.rs new file mode 100644 index 00000000..987c2155 --- /dev/null +++ b/crates/doc/src/video.rs @@ -0,0 +1,68 @@ +//! Window › Timeline. A document-level playhead over a fixed number of frames at a frame rate. +//! Video layers (Layer › Video Layers) show their content for the current frame; this type is the +//! shared clock. Frame-animation and video-timeline modes both reduce to "which frame is current". + +use serde::{Deserialize, Serialize}; + +/// The document timeline. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Timeline { + /// Frames per second. + pub fps: f32, + /// Total number of frames (>= 1). + pub duration: usize, + /// The playhead (0-based frame index). + pub current: usize, + /// Work-area start frame (inclusive). + #[serde(default)] + pub work_start: usize, + /// Work-area end frame (exclusive). + pub work_end: usize, +} + +impl Timeline { + pub fn new(duration: usize, fps: f32) -> Self { + let d = duration.max(1); + Timeline { fps: if fps > 0.0 { fps } else { 30.0 }, duration: d, current: 0, work_start: 0, work_end: d } + } + + /// Keep every index inside `0..duration` and the work area non-empty. + pub fn clamp(&mut self) { + self.duration = self.duration.max(1); + self.fps = if self.fps > 0.0 { self.fps } else { 30.0 }; + self.current = self.current.min(self.duration - 1); + self.work_start = self.work_start.min(self.duration - 1); + self.work_end = self.work_end.clamp(self.work_start + 1, self.duration); + } + + /// Playhead time in seconds. + pub fn time(&self) -> f32 { + self.current as f32 / self.fps.max(1e-3) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_and_clamp() { + let mut t = Timeline::new(0, 0.0); + assert_eq!(t.duration, 1); + assert_eq!(t.fps, 30.0); + let mut t = Timeline::new(24, 24.0); + t.current = 100; + t.work_end = 999; + t.clamp(); + assert_eq!(t.current, 23); + assert_eq!(t.work_end, 24); + assert!((t.time() - 23.0 / 24.0).abs() < 1e-6); + } + + #[test] + fn round_trips() { + let t = Timeline::new(48, 25.0); + let j = serde_json::to_string(&t).unwrap(); + assert_eq!(serde_json::from_str::(&j).unwrap(), t); + } +} diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index d6647df7..d4e4c7a0 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -839,6 +839,7 @@ fn build() -> Vec { v.extend(crate::frame_cmds::specs()); v.extend(crate::migrate_cmds::specs()); v.extend(crate::trap_cmds::specs()); + v.extend(crate::timeline_cmds::specs()); v.extend(crate::variables_cmds::specs()); v } diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 1876c062..2dd093c0 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -48,6 +48,7 @@ pub fn document(d: &DocState) -> Value { "lastAppliedComp": doc.last_applied_comp, "measurement": {"scale": doc.measurement.scale.describe(), "ruler": doc.measurement.ruler.map(|r| json!({"start": r.start, "end": r.end, "protractor": r.protractor})), "count": doc.measurement.count_total(), "countGroups": doc.measurement.count_groups.len()}, "notes": doc.notes.iter().map(|n| json!({"author": n.author, "text": n.text, "position": n.position})).collect::>(), + "timeline": doc.timeline.as_ref().map(|t| json!({"fps": t.fps, "duration": t.duration, "current": t.current})), "variables": (!doc.variables.is_empty()).then(|| json!({ "defs": doc.variables.defs.iter().map(|vd| json!({"name": vd.name, "layer": vd.layer.0})).collect::>(), "dataSets": doc.variables.data_sets.iter().map(|s| s.name.clone()).collect::>(), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 7f6a5def..5b4d4bb1 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -58,6 +58,7 @@ pub mod automate_cmds; mod frame_cmds; mod migrate_cmds; mod trap_cmds; +mod timeline_cmds; mod variables_cmds; pub mod pick_cmds; pub mod print_cmds; diff --git a/crates/engine/src/timeline_cmds.rs b/crates/engine/src/timeline_cmds.rs new file mode 100644 index 00000000..e97175b2 --- /dev/null +++ b/crates/engine/src/timeline_cmds.rs @@ -0,0 +1,143 @@ +//! Window › Timeline: create and drive the document timeline (playhead over N frames at a frame +//! rate). Navigation doesn't add history steps. Video layers (Layer › Video Layers, added later) +//! read `current` to show their frame. Headless and scriptable so agents can render frame ranges. + +use std::sync::Arc; + +use photocraft_doc::Timeline; +use serde_json::{Value, json}; + +use crate::commands::CommandSpec; +use crate::{EngineError, Result, Session}; + +fn has_doc(s: &Session) -> std::result::Result<(), String> { + s.active().map(|_| ()).ok_or_else(|| "no document".into()) +} + +fn has_timeline(s: &Session) -> std::result::Result<(), String> { + match s.active() { + Some(d) if d.doc.timeline.is_some() => Ok(()), + Some(_) => Err("no timeline (create one first)".into()), + None => Err("no document".into()), + } +} + +/// Mutate the document timeline without a history step (navigation / metadata). +fn with_timeline(s: &mut Session, f: impl FnOnce(&mut Option)) -> Result { + let st = s.active_mut().ok_or(EngineError::NoDocument)?; + let mut doc = (*st.doc).clone(); + f(&mut doc.timeline); + if let Some(t) = &mut doc.timeline { + t.clamp(); + } + st.doc = Arc::new(doc); + st.revision += 1; + info(s) +} + +fn create(s: &mut Session, p: &Value) -> Result { + let duration = p.get("duration").and_then(Value::as_u64).unwrap_or(30) as usize; + let fps = p.get("fps").and_then(Value::as_f64).unwrap_or(30.0) as f32; + with_timeline(s, |t| *t = Some(Timeline::new(duration, fps))) +} + +fn delete(s: &mut Session) -> Result { + with_timeline(s, |t| *t = None) +} + +fn set_frame(s: &mut Session, p: &Value) -> Result { + let f = p.get("frame").and_then(Value::as_u64).ok_or_else(|| EngineError::BadParams { cmd: "timeline.setFrame".into(), msg: "need `frame`".into() })? as usize; + with_timeline(s, |t| { + if let Some(t) = t { + t.current = f; + } + }) +} + +fn step(s: &mut Session, delta: i64) -> Result { + with_timeline(s, |t| { + if let Some(t) = t { + t.current = (t.current as i64 + delta).clamp(0, t.duration as i64 - 1) as usize; + } + }) +} + +fn set_props(s: &mut Session, p: &Value) -> Result { + with_timeline(s, |t| { + if let Some(t) = t { + if let Some(fps) = p.get("fps").and_then(Value::as_f64) { + t.fps = fps as f32; + } + if let Some(d) = p.get("duration").and_then(Value::as_u64) { + t.duration = d as usize; + } + if let Some(ws) = p.get("workStart").and_then(Value::as_u64) { + t.work_start = ws as usize; + } + if let Some(we) = p.get("workEnd").and_then(Value::as_u64) { + t.work_end = we as usize; + } + } + }) +} + +fn info(s: &mut Session) -> Result { + let st = s.active().ok_or(EngineError::NoDocument)?; + Ok(match &st.doc.timeline { + Some(t) => json!({"timeline": {"fps": t.fps, "duration": t.duration, "current": t.current, "workStart": t.work_start, "workEnd": t.work_end, "time": t.time()}}), + None => json!({"timeline": null}), + }) +} + +macro_rules! spec { + ($id:expr, $label:expr, $params:expr, $en:expr, $run:expr) => { + CommandSpec { id: $id, label: $label, menu: &[], shortcut: None, params: $params, enabled: $en, journal: false, run: $run } + }; +} + +pub fn specs() -> Vec { + vec![ + spec!("timeline.create", "Create Video Timeline", r#"{duration?:30, fps?:30} → {timeline}"#, has_doc, |s, p| create(s, &p)), + spec!("timeline.delete", "Delete Timeline", "{} → {timeline:null}", has_timeline, |s, _| delete(s)), + spec!("timeline.setFrame", "Go to Frame", r#"{frame} → {timeline}"#, has_timeline, |s, p| set_frame(s, &p)), + spec!("timeline.nextFrame", "Next Frame", "{} → {timeline}", has_timeline, |s, _| step(s, 1)), + spec!("timeline.previousFrame", "Previous Frame", "{} → {timeline}", has_timeline, |s, _| step(s, -1)), + spec!("timeline.setProps", "Timeline Settings", r#"{fps?, duration?, workStart?, workEnd?} → {timeline}"#, has_timeline, |s, p| set_props(s, &p)), + spec!("timeline.info", "Timeline Info", "{} → {timeline}", has_doc, |s, _| info(s)), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn create_navigate_delete() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 16, "height": 16})).unwrap(); + assert_eq!(s.execute("timeline.info", json!({})).unwrap()["timeline"], Value::Null); + let r = s.execute("timeline.create", json!({"duration": 24, "fps": 24.0})).unwrap(); + assert_eq!(r["timeline"]["duration"], 24); + assert_eq!(r["timeline"]["current"], 0); + // Navigate. + s.execute("timeline.setFrame", json!({"frame": 10})).unwrap(); + s.execute("timeline.nextFrame", json!({})).unwrap(); + assert_eq!(s.execute("timeline.info", json!({})).unwrap()["timeline"]["current"], 11); + // Clamp beyond the end. + let r = s.execute("timeline.setFrame", json!({"frame": 999})).unwrap(); + assert_eq!(r["timeline"]["current"], 23); + // Props. + s.execute("timeline.setProps", json!({"fps": 30.0})).unwrap(); + assert_eq!(s.execute("timeline.info", json!({})).unwrap()["timeline"]["fps"], 30.0); + // Delete. + s.execute("timeline.delete", json!({})).unwrap(); + assert_eq!(s.execute("timeline.info", json!({})).unwrap()["timeline"], Value::Null); + } + + #[test] + fn navigation_without_timeline_errors() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + assert!(s.execute("timeline.setFrame", json!({"frame": 1})).is_err()); + } +} diff --git a/crates/format/src/convert.rs b/crates/format/src/convert.rs index f0f07dd1..e04c202b 100644 --- a/crates/format/src/convert.rs +++ b/crates/format/src/convert.rs @@ -274,6 +274,7 @@ pub(crate) fn doc_m(d: &Document, sink: &mut dyn Sink) -> DocM { color_table: d.color_table.clone(), duotone: d.duotone.clone(), variables: d.variables.clone(), + timeline: d.timeline.clone(), layer_comps: d.layer_comps.iter().map(|c| comp_m(c, sink)).collect(), last_applied_comp: d.last_applied_comp, last_document_state: d.last_document_state.as_ref().map(|c| comp_m(c, sink)), @@ -637,6 +638,7 @@ impl Loader<'_> { color_table: m.color_table.clone(), duotone: m.duotone.clone(), variables: m.variables.clone(), + timeline: m.timeline.clone(), layer_comps, last_applied_comp: m.last_applied_comp, last_document_state, diff --git a/crates/format/src/manifest.rs b/crates/format/src/manifest.rs index fc84b259..9a22aea0 100644 --- a/crates/format/src/manifest.rs +++ b/crates/format/src/manifest.rs @@ -79,6 +79,9 @@ pub struct DocM { /// Image › Variables and Data Sets. #[serde(default)] pub variables: photocraft_doc::Variables, + /// Window › Timeline. + #[serde(default)] + pub timeline: Option, /// Image › Analysis: measurement scale, count groups, ruler. #[serde(default)] pub measurement: photocraft_doc::Measurement,