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}