tellur_plugin/lib.rs
1//! The timeline plugin ABI contract shared by authored projects and the
2//! live-preview host.
3//!
4//! An authored timeline is compiled to a `cdylib` that exports a single entry
5//! symbol ([`ENTRY_SYMBOL`]) returning a [`TimelineCollection`]; the host
6//! `dlopen`s the library and calls it. This contract intentionally uses Rust
7//! types across the dynamic-library boundary, so it is sound only when the host
8//! and plugin are built from the same `tellur` version, toolchain, and resolved
9//! boundary-crate versions — it is NOT a stable C ABI. The entry symbol is
10//! versioned so a stale plugin fails to resolve cleanly instead of hitting a
11//! vtable mismatch, and [`abi::ABI_FINGERPRINT_SYMBOL`] carries a finer-grained
12//! fingerprint checked at load time.
13
14use std::hash::{Hash, Hasher};
15
16use tellur_core::geometry::Vec2;
17use tellur_core::raster::{RasterImage, Resolution};
18use tellur_core::render_context::RenderContext;
19use tellur_core::time::TimelineTime;
20use tellur_core::timeline::Timeline;
21use tellur_core::timeline_component::{
22 resolve, resolve_with_canvas, Arrangement, AudioBuffer, Clock, NodeKind, ResolveError,
23 ResolvedTimeline, TimelineComponent,
24};
25
26// Re-exported under a hidden name so `export_timeline!` can reach `tellur-core`
27// through `$crate` regardless of how the plugin author depends on it — directly
28// on `tellur-core`, or only on the `tellur` facade (which pulls this crate in
29// transitively). The macro must NOT hardcode `::tellur_core`, which only
30// resolves when the author lists `tellur-core` as a direct dependency.
31#[doc(hidden)]
32pub use tellur_core as __core;
33
34/// ABI version carried by the entry symbol.
35///
36/// Bumped to `v2` for the timeline subsystem migration: the collection now
37/// carries the new [`TimelineComponent`] model and gained an `arrangement`
38/// vtable slot. The host resolves this symbol with an unchecked `transmute_copy`,
39/// so a stale 2-method `v1` `.so` would dlsym/transmute fine yet hit a
40/// vtable-slot UB at call time. Renaming the symbol makes the lookup fail
41/// cleanly on an old plugin instead.
42///
43/// Bumped to `v3` for motion blur: `RenderContext` (which the host passes
44/// across the boundary as `&mut dyn`) gained the `motion_blur_enabled`
45/// vtable slot and `GpuRasterBackend` gained `temporal_average`, so a `v2`
46/// plugin calling through a `v3` host context would hit the same class of
47/// vtable mismatch.
48///
49/// Bumped to `v4` for live-preview audio windows: `TimelineCollection` gained
50/// `render_audio_window`, so stale `v3` plugins must fail at `dlsym` instead of
51/// landing on the wrong vtable slot.
52pub const ENTRY_SYMBOL: &[u8] = b"tellur_timeline_collection_v4\0";
53
54pub mod abi;
55pub use abi::{
56 validate_plugin_fingerprint, AbiFingerprintFn, AbiMismatchError, ABI_FINGERPRINT,
57 ABI_FINGERPRINT_SYMBOL,
58};
59
60/// Export the C ABI fingerprint symbol alongside timeline entry points.
61#[doc(hidden)]
62#[macro_export]
63macro_rules! __tellur_export_abi_fingerprint {
64 () => {
65 #[no_mangle]
66 pub extern "C" fn tellur_abi_fingerprint_v1() -> *const ::std::os::raw::c_char {
67 $crate::abi::ABI_FINGERPRINT_C.as_ptr().cast()
68 }
69 };
70}
71
72/// The signature of the [`ENTRY_SYMBOL`] entry point a plugin exports.
73pub type EntryFn = unsafe extern "Rust" fn() -> Box<dyn TimelineCollection>;
74
75/// Metadata for one timeline exposed by a plugin.
76#[derive(Debug, Clone, PartialEq)]
77pub struct TimelineInfo {
78 pub id: String,
79 pub title: String,
80 pub duration: f32,
81 /// A resolve-time error for this timeline, if its tree failed to resolve
82 /// (e.g. a media probe failure or a timeless root). Mirrors the
83 /// `last_error` display the server already surfaces for plugin load
84 /// failures; `None` when the timeline resolved cleanly.
85 pub error: Option<String>,
86}
87
88/// A collection of timelines exported by one dynamic library.
89pub trait TimelineCollection: Send {
90 fn timelines(&self) -> Vec<TimelineInfo>;
91
92 fn build(
93 &self,
94 id: &str,
95 t: TimelineTime,
96 target: Resolution,
97 ctx: &mut dyn RenderContext,
98 ) -> Option<RasterImage>;
99
100 /// The resolved arrangement tree for `id`, for the live UI to introspect.
101 ///
102 /// Defaulted to `None` so existing collections can migrate in stages
103 /// (audit B.4): a collection that has not yet built a resolved tree simply
104 /// returns nothing here.
105 fn arrangement(&self, _id: &str) -> Option<Arrangement> {
106 None
107 }
108
109 /// The eager audio mix-down for `id` at `rate` / `channels`, for the live
110 /// preview to mux into its video stream. `None` means the collection
111 /// contributes no audio (the preview then muxes a silent track for a
112 /// consistent A/V stream structure). Defaulted so collections migrate in
113 /// stages, mirroring [`arrangement`](Self::arrangement).
114 fn render_audio(&self, _id: &str, _rate: u32, _channels: u16) -> Option<AudioBuffer> {
115 None
116 }
117
118 /// Eager audio mix-down for `[start, start + duration)`, used by the live
119 /// preview when it encodes an individual cache segment. Custom collections
120 /// must implement this explicitly to preview audio; falling back to the
121 /// whole-track [`render_audio`](Self::render_audio) would reintroduce the
122 /// per-segment full-timeline work this API avoids.
123 fn render_audio_window(
124 &self,
125 _id: &str,
126 _start: f32,
127 _duration: f32,
128 _rate: u32,
129 _channels: u16,
130 ) -> Option<AudioBuffer> {
131 None
132 }
133}
134
135/// Wraps a single resolved [`TimelineComponent`] as a one-entry collection.
136///
137/// The tree is resolved ONCE at collection construction (plugin-side — the host
138/// only sees `Box<dyn TimelineCollection>`, audit B1) and the result is stored
139/// as a [`Result`]: resolving probes media and can fail, but the entry fn
140/// cannot return a `Result` across the dylib boundary and panicking there is
141/// UB-adjacent (audit M5). So the error is stored and surfaced per query —
142/// `build` yields `None`, `timelines` carries the error string, `arrangement`
143/// yields `None`.
144pub struct SingleTimeline {
145 id: &'static str,
146 title: &'static str,
147 resolved: Result<ResolvedTimeline, ResolveError>,
148}
149
150/// Resolves `root` and wraps it as a one-entry [`TimelineCollection`].
151///
152/// `resolve` CONSUMES the tree (audit M1) and can fail; the result is kept
153/// intact so the entry fn stays panic-free.
154pub fn single_timeline<T: TimelineComponent + Send + 'static>(
155 id: &'static str,
156 title: &'static str,
157 root: T,
158) -> SingleTimeline {
159 SingleTimeline {
160 id,
161 title,
162 resolved: resolve(root),
163 }
164}
165
166/// Like [`single_timeline`] but resolves against an explicit logical `canvas`.
167pub fn single_timeline_with_canvas<T: TimelineComponent + Send + 'static>(
168 id: &'static str,
169 title: &'static str,
170 root: T,
171 canvas: Vec2,
172) -> SingleTimeline {
173 SingleTimeline {
174 id,
175 title,
176 resolved: resolve_with_canvas(root, canvas),
177 }
178}
179
180impl SingleTimeline {
181 fn resolved(&self) -> Option<&ResolvedTimeline> {
182 self.resolved.as_ref().ok()
183 }
184}
185
186impl TimelineCollection for SingleTimeline {
187 fn timelines(&self) -> Vec<TimelineInfo> {
188 let (duration, error) = match &self.resolved {
189 Ok(resolved) => (resolved.duration(), None),
190 Err(e) => (0.0, Some(e.to_string())),
191 };
192 vec![TimelineInfo {
193 id: self.id.to_owned(),
194 title: self.title.to_owned(),
195 duration,
196 error,
197 }]
198 }
199
200 fn build(
201 &self,
202 id: &str,
203 t: TimelineTime,
204 target: Resolution,
205 ctx: &mut dyn RenderContext,
206 ) -> Option<RasterImage> {
207 if id != self.id {
208 return None;
209 }
210 self.resolved()?.frame(t, target, ctx)
211 }
212
213 fn arrangement(&self, id: &str) -> Option<Arrangement> {
214 if id != self.id {
215 return None;
216 }
217 // Root offset 0: the resolved tree's start coincides with the global
218 // axis, so the walk stamps absolute starts/ends from there.
219 Some(self.resolved()?.source().arrangement(0.0))
220 }
221
222 fn render_audio(&self, id: &str, rate: u32, channels: u16) -> Option<AudioBuffer> {
223 if id != self.id {
224 return None;
225 }
226 // `render_audio` returns a buffer of the whole resolved length — silent
227 // when the tree has no audio sources — so the preview always gets a
228 // determinate track to mux.
229 Some(self.resolved()?.render_audio(rate, channels))
230 }
231
232 fn render_audio_window(
233 &self,
234 id: &str,
235 start: f32,
236 duration: f32,
237 rate: u32,
238 channels: u16,
239 ) -> Option<AudioBuffer> {
240 if id != self.id {
241 return None;
242 }
243 Some(
244 self.resolved()?
245 .render_audio_window(start, duration, rate, channels),
246 )
247 }
248}
249
250/// Adapts an old closure-based [`Timeline`] to the new [`TimelineComponent`]
251/// model so the existing demo scene (which still builds a `Timeline`) can be
252/// served by the migrated collection without rewriting it.
253///
254/// A `Timeline` is opaque (a closure with a fixed `duration`), so this presents
255/// it as a single timed leaf: its `frame` plays the timeline at the global
256/// clock, its length is the timeline's `duration`, and its `arrangement` is one
257/// Video-kind node spanning `[0, duration]`.
258///
259/// `TimelineComponent` requires `PartialEq + Hash` (via the `DynEq`/`DynHash`
260/// super-traits). A `Timeline` closure is neither, and timeline nodes are never
261/// memoized through `ctx.render` (`.sketch/02 §11`) — this identity is only the
262/// builder-marker key — so the wrapper compares all instances equal and hashes
263/// to a constant. There is exactly one per collection, so that is sound.
264pub struct LegacyTimeline<T: Timeline + Send> {
265 timeline: T,
266}
267
268impl<T: Timeline + Send> LegacyTimeline<T> {
269 /// Wraps `timeline` so it can be placed in the new timeline world.
270 pub fn new(timeline: T) -> Self {
271 Self { timeline }
272 }
273}
274
275impl<T: Timeline + Send> PartialEq for LegacyTimeline<T> {
276 fn eq(&self, _other: &Self) -> bool {
277 // Opaque closure timeline: treat the single wrapped instance as its own
278 // identity. Not used as a per-frame cache key (see the type doc).
279 true
280 }
281}
282
283impl<T: Timeline + Send> Eq for LegacyTimeline<T> {}
284
285impl<T: Timeline + Send> Hash for LegacyTimeline<T> {
286 fn hash<H: Hasher>(&self, _state: &mut H) {
287 // Constant hash: consistent with the all-equal `PartialEq` above.
288 }
289}
290
291impl<T: Timeline + Send + 'static> TimelineComponent for LegacyTimeline<T> {
292 fn duration(&self) -> Option<f32> {
293 Some(self.timeline.duration())
294 }
295
296 fn frame(
297 &self,
298 clock: Clock<'_>,
299 canvas: Vec2,
300 target: Resolution,
301 ctx: &mut dyn RenderContext,
302 ) -> Option<RasterImage> {
303 // The legacy closure handles its own SCENE_SIZE internally; the logical
304 // `canvas` is not threaded into it.
305 let _ = canvas;
306 Some(self.timeline.build(clock.global(), target, ctx))
307 }
308
309 fn arrangement(&self, offset: f32) -> Arrangement {
310 // One Video-kind node spanning the whole timeline; no source crop,
311 // no triggers, no children — the legacy timeline is opaque.
312 Arrangement {
313 kind: NodeKind::Video,
314 label: String::new(),
315 name: None,
316 source: None,
317 start: offset,
318 end: offset + self.timeline.duration(),
319 trim: None,
320 triggers: Vec::new(),
321 children: Vec::new(),
322 }
323 }
324}
325
326/// Exports a single [`TimelineComponent`] builder from a `cdylib`.
327///
328/// ```ignore
329/// fn build() -> impl tellur_core::timeline_component::TimelineComponent + Send { ... }
330/// tellur_plugin::export_timeline!("main", "Main", build);
331/// ```
332#[macro_export]
333macro_rules! export_timeline {
334 ($id:expr, $title:expr, $builder:path) => {
335 $crate::__tellur_export_abi_fingerprint!();
336 #[no_mangle]
337 pub extern "Rust" fn tellur_timeline_collection_v4(
338 ) -> ::std::boxed::Box<dyn $crate::TimelineCollection> {
339 ::std::boxed::Box::new($crate::single_timeline($id, $title, $builder()))
340 }
341 };
342 ($id:expr, $title:expr, $builder:path, canvas = ($w:expr, $h:expr)) => {
343 $crate::__tellur_export_abi_fingerprint!();
344 #[no_mangle]
345 pub extern "Rust" fn tellur_timeline_collection_v4(
346 ) -> ::std::boxed::Box<dyn $crate::TimelineCollection> {
347 ::std::boxed::Box::new($crate::single_timeline_with_canvas(
348 $id,
349 $title,
350 $builder(),
351 $crate::__core::geometry::Vec2($w, $h),
352 ))
353 }
354 };
355}
356
357/// Exports a single OLD closure-based [`tellur_core::timeline::Timeline`]
358/// builder from a `cdylib`, wrapping it in a [`LegacyTimeline`] adapter so it
359/// serves through the migrated collection unchanged.
360///
361/// ```ignore
362/// fn build() -> impl tellur_core::timeline::Timeline + Send { ... }
363/// tellur_plugin::export_legacy_timeline!("main", "Main", build);
364/// ```
365#[macro_export]
366macro_rules! export_legacy_timeline {
367 ($id:expr, $title:expr, $builder:path) => {
368 $crate::__tellur_export_abi_fingerprint!();
369 #[no_mangle]
370 pub extern "Rust" fn tellur_timeline_collection_v4(
371 ) -> ::std::boxed::Box<dyn $crate::TimelineCollection> {
372 ::std::boxed::Box::new($crate::single_timeline(
373 $id,
374 $title,
375 $crate::LegacyTimeline::new($builder()),
376 ))
377 }
378 };
379}
380
381/// Exports a custom [`TimelineCollection`] builder from a `cdylib`.
382#[macro_export]
383macro_rules! export_timeline_collection {
384 ($builder:path) => {
385 $crate::__tellur_export_abi_fingerprint!();
386 #[no_mangle]
387 pub extern "Rust" fn tellur_timeline_collection_v4(
388 ) -> ::std::boxed::Box<dyn $crate::TimelineCollection> {
389 ::std::boxed::Box::new($builder())
390 }
391 };
392}
393
394#[cfg(test)]
395mod tests {
396 use super::*;
397 use tellur_core::geometry::{Constraints, Vec2};
398 use tellur_core::raster::{PixelFormat, RasterComponent};
399 use tellur_core::timeline_component::Timed;
400 use tellur_core::timeline_container::Timeline as TimelineContainer;
401
402 // A trivial timeless visual so a small NEW-API timeline can be built without
403 // pulling in media decode. Reaches the timeline world via the one-way
404 // blanket over `RasterComponent`.
405 #[derive(PartialEq, Hash)]
406 struct Dot;
407
408 impl RasterComponent for Dot {
409 fn layout(&self, _c: Constraints) -> Vec2 {
410 Vec2(1.0, 1.0)
411 }
412
413 fn render(&self, _s: Vec2, _t: Resolution, _ctx: &mut dyn RenderContext) -> RasterImage {
414 RasterImage::cpu(1, 1, PixelFormat::Rgba8, vec![0u8, 0, 0, 0])
415 }
416 }
417
418 // Builds an overlay `Timeline` of two windowed visuals — a small NEW-API
419 // timeline that resolves to a determinate length (no media probe).
420 fn build_new_api_timeline() -> TimelineContainer {
421 TimelineContainer::builder()
422 .child(Dot.at(0.0..2.0))
423 .child(Dot.at(0.0..3.0))
424 .build()
425 }
426
427 #[test]
428 fn single_timeline_surfaces_resolved_duration() {
429 let collection = single_timeline("main", "Main", build_new_api_timeline());
430 let infos = collection.timelines();
431 assert_eq!(infos.len(), 1);
432 assert_eq!(infos[0].id, "main");
433 assert_eq!(infos[0].title, "Main");
434 // The overlay length is the max child window end (3.0).
435 assert_eq!(infos[0].duration, 3.0);
436 assert_eq!(infos[0].error, None);
437 }
438
439 #[test]
440 fn single_timeline_arrangement_walks_the_resolved_tree() {
441 let collection = single_timeline("main", "Main", build_new_api_timeline());
442
443 // A non-matching id yields nothing.
444 assert!(collection.arrangement("other").is_none());
445
446 let root = collection
447 .arrangement("main")
448 .expect("the resolved tree has an arrangement");
449
450 // The root is the overlay Timeline spanning [0, 3].
451 assert_eq!(root.kind, NodeKind::Timeline);
452 assert_eq!(root.start, 0.0);
453 assert_eq!(root.end, 3.0);
454 assert!(root.trim.is_none());
455
456 // Two children, each a Video-kind visual leaf (via the blanket): a
457 // timeless visual lives on the video (映像) track.
458 assert_eq!(root.children.len(), 2);
459 for child in &root.children {
460 assert_eq!(child.kind, NodeKind::Video);
461 assert!(child.children.is_empty());
462 }
463 }
464
465 #[test]
466 fn legacy_timeline_adapter_arrangement_is_one_video_span() {
467 // The legacy adapter presents an opaque closure timeline as a single
468 // Video-kind node spanning its whole duration.
469 let legacy = LegacyTimeline::new(tellur_core::timeline::timeline(
470 4.0,
471 |_t, target: Resolution, _ctx| {
472 RasterImage::cpu(
473 target.width,
474 target.height,
475 PixelFormat::Rgba8,
476 vec![0u8; (target.width * target.height * 4) as usize],
477 )
478 },
479 ));
480 let collection = single_timeline("main", "Main", legacy);
481
482 assert_eq!(collection.timelines()[0].duration, 4.0);
483 let root = collection.arrangement("main").expect("resolves to a span");
484 assert_eq!(root.kind, NodeKind::Video);
485 assert_eq!(root.start, 0.0);
486 assert_eq!(root.end, 4.0);
487 assert!(root.children.is_empty());
488 }
489}