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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-03 00:55:04 -04:00
parent 1c474b8c7e
commit 39a014d7fc
8 changed files with 224 additions and 0 deletions

View File

@ -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<Timeline>,
}
/// 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,
}
}

68
crates/doc/src/video.rs Normal file
View File

@ -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::<Timeline>(&j).unwrap(), t);
}
}

View File

@ -839,6 +839,7 @@ fn build() -> Vec<CommandSpec> {
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
}

View File

@ -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::<Vec<_>>(),
"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::<Vec<_>>(),
"dataSets": doc.variables.data_sets.iter().map(|s| s.name.clone()).collect::<Vec<_>>(),

View File

@ -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;

View File

@ -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<Timeline>)) -> Result<Value> {
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<Value> {
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<Value> {
with_timeline(s, |t| *t = None)
}
fn set_frame(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<Value> {
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<Value> {
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<CommandSpec> {
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());
}
}

View File

@ -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,

View File

@ -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<photocraft_doc::Timeline>,
/// Image › Analysis: measurement scale, count groups, ruler.
#[serde(default)]
pub measurement: photocraft_doc::Measurement,