Skip to main content

tellur_core/timeline_component/
component.rs

1//! The [`TimelineComponent`] trait, its builder marker, and the one-way
2//! blanket that lifts every [`RasterComponent`] into the timeline world.
3
4use std::hash::Hash;
5
6use crate::dyn_compare::{DynEq, DynHash};
7use crate::geometry::{Rect, Vec2};
8use crate::layer::composite_children;
9use crate::raster::{RasterComponent, RasterImage, Resolution};
10use crate::render_context::RenderContext;
11
12use super::*;
13
14// ── The one unit: a thing placed on a timeline ──────────────────────────────
15
16/// A "playable": over its own local clock `[0, duration)` it emits any of three
17/// channels — video frames, audio samples, or subtitle cues. The temporal twin
18/// of [`RasterComponent`].
19///
20/// Implemented by the timeline leaves/containers (later steps), by any
21/// `#[component(timeline)]` (step 2), and — via the one-way blanket below — by
22/// every [`RasterComponent`] (a *timeless* visual).
23///
24/// The trait must be usable as `Box<dyn TimelineComponent + Send>` (audit M2),
25/// so it is object-safe and every implementor is expected to be `Send`.
26///
27/// The [`DynEq`] / [`DynHash`] super-traits mirror
28/// [`RasterComponent`](crate::raster::RasterComponent): they give
29/// `dyn TimelineComponent` an object-safe `PartialEq` / `Hash` (see the manual
30/// impls below `Box<dyn TimelineComponent + Send>`), so a container that holds
31/// `Vec<Box<dyn TimelineComponent + Send>>` (e.g.
32/// [`Timeline`](crate::timeline_container::Timeline) /
33/// [`Sequence`](crate::timeline_container::Sequence))
34/// can satisfy the `TimelineBuilder::Output: PartialEq + Hash` marker bound the
35/// same way the raster [`Flex`](crate::layout::raster::Flex) does — by
36/// deriving over a comparable child vec. Timeline nodes are never memoized
37/// through `ctx.render` (`.sketch/02 §11`), so this identity is purely the
38/// builder-marker key, not a per-frame cache key.
39pub trait TimelineComponent: DynEq + DynHash {
40    /// Intrinsic length in seconds, or `None` for a *timeless* component (a
41    /// visual / 字幕) whose length is given by the window it is placed into.
42    ///
43    /// This is the place-pass view of length; [`measure`](Self::measure) is the
44    /// bottom-up measure-pass twin. Leaves usually define one and let the other
45    /// default to it.
46    fn duration(&self) -> Option<f32> {
47        // Default: a component with no intrinsic length is timeless.
48        None
49    }
50
51    /// Bottom-up intrinsic duration for the resolve pass's measure phase
52    /// (`.sketch/02 §5`). Defaults to [`duration`](Self::duration); containers
53    /// override to fold over their children.
54    ///
55    /// CONTRACT (the load-bearing invariant, `.sketch/02 §5/§9`): a container's
56    /// `measure` is computed bottom-up from its children and **excludes every
57    /// `.fill()` child**. `.fill()` takes the container's resolved length, so
58    /// measuring a fill child *into* that length would close a cycle; excluding
59    /// it keeps the dependency strictly one-directional (container length →
60    /// fill-child length) and the two-pass solve acyclic and terminating.
61    /// Consequently a non-fill container whose non-fill children are all `None`
62    /// measures as `None`, and an empty / all-fill interior measures as a
63    /// determinate `0.0` (`max(∅)` / `Σ(∅)`); the all-fill case should also
64    /// [`warn`](ResolveCtx::warn) at place time. `None` means *timeless*: the
65    /// length is supplied later by the placement window.
66    fn measure(&self) -> Option<f32> {
67        self.duration()
68    }
69
70    /// Top-down place hook (`.sketch/02 §6`). Walks `self` and, for a container,
71    /// recurses over its OWN children via `&self` recursion — assigning each an
72    /// absolute start and recording [`Event`] trigger times into `out` —
73    /// returning this node's resolved length.
74    ///
75    /// CONTRACT: `abs_start` is the absolute start handed down by the parent;
76    /// the return value is the node's resolved length folded back up. A
77    /// container computes each child's absolute start as `abs_start + relative`
78    /// and recurses with it (a `Sequence` advances a cursor; a `Timeline`
79    /// overlays from a common base and resolves `.fill()` children against its
80    /// own measured length in a second sub-pass). The all-fill / empty-interior
81    /// case resolves to a determinate `0.0` and should emit a
82    /// [`warn`](ResolveCtx::warn).
83    ///
84    /// The default is the leaf behaviour: record nothing and report own length
85    /// (falling back to `0.0` for a timeless leaf, whose interval comes from the
86    /// placement window instead).
87    fn resolve(&self, abs_start: f32, out: &mut ResolveCtx) -> f32 {
88        // Leaves record no triggers and report their own (possibly absent)
89        // length; containers (step 4) override to recurse and place children.
90        let _ = (abs_start, out);
91        self.duration().unwrap_or(0.0)
92    }
93
94    /// Visual channel for this frame. `clock` carries both time axes (see
95    /// [`Clock`]); `canvas` is the composition's fixed LOGICAL layout space
96    /// (resolution-independent), which the pixel `target` scales. `None` ⇒
97    /// contributes nothing visually.
98    fn frame(
99        &self,
100        clock: Clock<'_>,
101        canvas: Vec2,
102        target: Resolution,
103        ctx: &mut dyn RenderContext,
104    ) -> Option<RasterImage> {
105        // TODO(task 4): leaves/containers produce real frames.
106        let _ = (clock, canvas, target, ctx);
107        None
108    }
109
110    /// Audio channel for `[clock, clock + window)`. `None` ⇒ silent.
111    fn samples(&self, clock: Clock<'_>, window: f32) -> Option<AudioBuffer> {
112        // The eager mix-down (step 8) uses [`mix_into`](Self::mix_into) instead
113        // of this per-window pull; this stays the (unused) per-window seam.
114        let _ = (clock, window);
115        None
116    }
117
118    /// Eager mix-down hook (step 8, B4 v1 — `.sketch/01` ZONE C / `.sketch/02
119    /// §15`). Contributes this node's audio into `mix` (a fixed rate / channel
120    /// layout buffer for `[0, duration]`), placing it at the absolute
121    /// `start_secs` and accounting for the accumulated placement `speed`.
122    ///
123    /// CONTRACT: mirrors [`resolve`](Self::resolve) / [`frame`](Self::frame)
124    /// placement — a container recurses over its own children, advancing
125    /// `start_secs` exactly as it advances absolute starts (a
126    /// [`Sequence`](crate::timeline_container::Sequence)
127    /// cursor sums prior lengths; a
128    /// [`Timeline`](crate::timeline_container::Timeline) overlays at the same
129    /// base), and
130    /// a [`Placed`] shifts by its relative start and folds its window
131    /// `speed()` in. A leaf decodes / conforms / sums via
132    /// [`AudioMix::add`](crate::audio::AudioMix::add). The default is the
133    /// timeless / silent behaviour: contribute nothing.
134    fn mix_into(&self, mix: &mut crate::audio::AudioMix, start_secs: f32, speed: f32) {
135        let _ = (mix, start_secs, speed);
136    }
137
138    /// Subtitle channel: cues made absolute by adding `offset` (this
139    /// component's resolved start). Collected once at export.
140    fn cues(&self, offset: f32) -> Vec<Cue> {
141        // TODO(task 5+): Subtitle leaves / containers contribute cues.
142        let _ = offset;
143        Vec::new()
144    }
145
146    /// What the live UI draws: kind + label, plus the node's RESOLVED absolute
147    /// interval and any [`Event`] trigger times.
148    ///
149    /// `offset` is this node's resolved absolute start, threaded top-down exactly
150    /// like [`cues`](Self::cues): a leaf stamps `start = offset`,
151    /// `end = offset + self.duration().unwrap_or(0.0)`; a container places each
152    /// child at `offset + child_relative_start`, mirroring its
153    /// [`resolve`](Self::resolve) / [`cues`](Self::cues) cursor.
154    ///
155    /// Named `arrangement` (not `outline`) to avoid clashing with the existing
156    /// `tellur_renderer::Outline` raster effect (audit hygiene note).
157    fn arrangement(&self, offset: f32) -> Arrangement;
158
159    /// The STRUCTURAL `&dyn Any` behind this component, peeling any transparent
160    /// decorator. A container inspecting a child's concrete type (e.g.
161    /// downcasting to [`Placed`] to detect a `.fill()`) must go through this, not
162    /// [`DynEq::as_any`](crate::dyn_compare::DynEq::as_any), so a wrapping
163    /// [`Sourced`] (which the generated `.child(...)` setter adds to capture the
164    /// call site) does not hide the real child. Defaults to `self` — only a
165    /// decorator like `Sourced` overrides it to forward to its inner component.
166    fn structural_any(&self) -> &dyn std::any::Any {
167        self.as_any()
168    }
169}
170
171// Compile-time guarantee that `TimelineComponent` is object-safe *and*
172// spellable with `+ Send` (audit M2).
173const _: Option<&(dyn TimelineComponent + Send)> = None;
174
175// `dyn TimelineComponent + Send` gets object-safe `PartialEq` / `Hash` through
176// the `DynEq` / `DynHash` super-traits, exactly as `dyn RasterComponent` does
177// (`raster.rs`). This is what makes `Box<dyn TimelineComponent + Send>` and a
178// container's `Vec<Box<dyn TimelineComponent + Send>>` comparable, so the
179// containers can derive the `PartialEq + Hash` the `TimelineBuilder` marker
180// requires.
181impl PartialEq for dyn TimelineComponent + Send {
182    fn eq(&self, other: &Self) -> bool {
183        DynEq::dyn_eq(self, other.as_any())
184    }
185}
186
187impl Eq for dyn TimelineComponent + Send {}
188
189impl Hash for dyn TimelineComponent + Send {
190    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
191        DynHash::dyn_hash(self, state);
192    }
193}
194
195/// Any [`RasterComponent`] IS a timeless visual [`TimelineComponent`] — ONE
196/// direction only. This is what lets a styled `Text` (a "Caption") be placed in
197/// time with `.at(0.0..dur)`; its [`duration`](TimelineComponent::duration) is
198/// `None` until a placement window gives it one.
199///
200/// COHERENCE (audit, `.sketch/01` A.1): this blanket is the *only* impl after
201/// step 1. A concrete `RasterComponent` must reach the timeline world through
202/// this blanket — never via a second direct `impl TimelineComponent for Foo`,
203/// or the pair becomes an `E0119`.
204impl<C> TimelineComponent for C
205where
206    C: RasterComponent + 'static,
207{
208    // A timeless visual has no intrinsic length; the placement window sets it.
209    fn duration(&self) -> Option<f32> {
210        None
211    }
212
213    fn frame(
214        &self,
215        clock: Clock<'_>,
216        canvas: Vec2,
217        target: Resolution,
218        ctx: &mut dyn RenderContext,
219    ) -> Option<RasterImage> {
220        // Route through `ctx.render` so the visual memoizes one level down
221        // (`.sketch/02 §11`): the framework caches `&dyn RasterComponent`, not
222        // `&dyn TimelineComponent`, so this is the path that earns a cache slot.
223        let _ = clock;
224        // `canvas` is the composition's FIXED logical layout space, passed in
225        // and decoupled from the pixel `target` (resolution-independent). Lay
226        // the visual out against the canvas — NOT its intrinsic box. Sizing
227        // against the intrinsic box (`layout(UNBOUNDED)`) would stretch a
228        // wide-short text box to fill the frame (aspect distortion) and collapse
229        // a `Fill` component against the unbounded axis. The canvas makes
230        // anchored/`Fill` placement resolve against the full frame, and
231        // `ctx.render` scales that logical canvas to the pixel target with no
232        // distortion (mirrors the raster root, where `size` aspect matches
233        // `target`).
234        let canvas_rect = Rect {
235            origin: Vec2::ZERO,
236            size: canvas,
237        };
238        if self.paint_bounds(canvas) == canvas_rect {
239            return Some(ctx.render(self, canvas, target));
240        }
241        // A visual painting outside its layout box (a drop shadow, an
242        // outline) expects its pixel `target` to span `paint_bounds`, not the
243        // canvas (`composite_children`'s contract) — handing it the frame's
244        // `target` directly would squeeze the wider paint bounds into the
245        // canvas-sized pixel grid, shrinking and distorting the content.
246        // Composite it like a `Layer` child instead: render at paint-bounds
247        // resolution, blit at the painted origin, clip spill at the frame edge.
248        Some(composite_children(
249            canvas_rect,
250            target,
251            &[(Vec2::ZERO, canvas, self as &dyn RasterComponent)],
252            ctx,
253        ))
254    }
255
256    fn samples(&self, _clock: Clock<'_>, _window: f32) -> Option<AudioBuffer> {
257        None
258    }
259
260    fn cues(&self, _offset: f32) -> Vec<Cue> {
261        Vec::new()
262    }
263
264    fn arrangement(&self, offset: f32) -> Arrangement {
265        // A timeless visual surfaces as a Video-kind node — every rasterized
266        // visual (a backdrop, a caption telop, a reveal) lives on the video
267        // (映像) track; there is no separate caption kind. Un-windowed it is
268        // 0-length; the wrapping `Placed` stamps its real window end (mirrors the
269        // `cues` zero-length-point + window-end-stamp pattern).
270        Arrangement {
271            kind: NodeKind::Video,
272            // TODO(task 6): carry a real label (e.g. the concrete type name).
273            label: String::new(),
274            // A `#[component(raster)]` overrides `arrangement_name` to surface
275            // its display name here; plain raster primitives leave it `None`.
276            name: self.arrangement_name(),
277            // The wrapping `Sourced` (from the container setter) stamps the
278            // call site; a bare visual has none of its own.
279            source: None,
280            start: offset,
281            end: offset + self.duration().unwrap_or(0.0),
282            trim: None,
283            triggers: Vec::new(),
284            children: Vec::new(),
285        }
286    }
287}
288
289// ── Builder marker ──────────────────────────────────────────────────────────
290
291/// Marker for a *complete* builder of a [`TimelineComponent`], mirroring
292/// [`VectorBuilder`](crate::builder::VectorBuilder) /
293/// [`RasterBuilder`](crate::builder::RasterBuilder).
294///
295/// The buildless placement/trigger extensions ([`TimedBuilder`],
296/// [`TriggersBuilder`]) hang off THIS marker, not off [`TimelineComponent`] —
297/// that disjointness is what keeps the two blanket families from overlapping
298/// (audit B2).
299pub trait TimelineBuilder: Sized {
300    type Output: TimelineComponent + PartialEq + Hash + 'static;
301    /// Finishes the builder. This is the `.build()` the caller never writes.
302    fn build_component(self) -> Self::Output;
303}