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 tellur_core::geometry::Vec2;
15use tellur_core::raster::{RasterImage, RasterResidency, Resolution};
16use tellur_core::render_context::RenderContext;
17use tellur_core::time::TimelineTime;
18use tellur_core::timeline_component::{
19 resolve, resolve_with_canvas, Arrangement, AudioBuffer, ResolveError, ResolvedTimeline,
20 TimelineComponent,
21};
22
23// Re-exported under a hidden name so `export_timeline!` can reach `tellur-core`
24// through `$crate` regardless of how the plugin author depends on it — directly
25// on `tellur-core`, or only on the `tellur` facade (which pulls this crate in
26// transitively). The macro must NOT hardcode `::tellur_core`, which only
27// resolves when the author lists `tellur-core` as a direct dependency.
28#[doc(hidden)]
29pub use tellur_core as __core;
30
31/// ABI version carried by the entry symbol.
32///
33/// Bumped to `v2` for the timeline subsystem migration: the collection now
34/// carries the new [`TimelineComponent`] model and gained an `arrangement`
35/// vtable slot. The host resolves this symbol with an unchecked `transmute_copy`,
36/// so a stale 2-method `v1` `.so` would dlsym/transmute fine yet hit a
37/// vtable-slot UB at call time. Renaming the symbol makes the lookup fail
38/// cleanly on an old plugin instead.
39///
40/// Bumped to `v3` for motion blur: `RenderContext` (which the host passes
41/// across the boundary as `&mut dyn`) gained the `motion_blur_enabled`
42/// vtable slot and `GpuRasterBackend` gained `temporal_average`, so a `v2`
43/// plugin calling through a `v3` host context would hit the same class of
44/// vtable mismatch.
45///
46/// Bumped to `v4` for live-preview audio windows: `TimelineCollection` gained
47/// `render_audio_window`, so stale `v3` plugins must fail at `dlsym` instead of
48/// landing on the wrong vtable slot.
49///
50/// Bumped to `v5` for consumer-demanded raster residency:
51/// `RenderContext::render` gained a residency argument and the trait gained
52/// `ensure_residency`, `GpuRasterBackend` gained `upload`, and
53/// `TimelineComponent::frame` and `TimelineCollection::build` gained residency
54/// arguments. Stale `v4` plugins must fail before calling through any changed
55/// trait object.
56///
57/// Bumped to `v6` for double-precision timeline seconds: `TimelineTime` and
58/// the timeline component/collection duration, placement, arrangement, and
59/// audio-window methods now carry `f64`. Stale `v5` plugins therefore have
60/// incompatible value layouts and vtable signatures and must fail at lookup.
61///
62/// Bumped to `v7` because `RasterComponent` gained clone support through a new
63/// supertrait, changing the vtable layout of raster trait objects passed to the
64/// host render context. Stale `v6` plugins must fail before crossing that
65/// boundary.
66///
67/// Bumped to `v8` because configurable stroke styles changed the layout of
68/// `Stroke`, and therefore `VectorGraphic`, which crosses the live-plugin
69/// boundary through `GpuRasterBackend::rasterize`. Stale `v7` plugins must fail
70/// before passing an incompatible graphic layout to the host renderer.
71///
72/// Bumped to `v9` because `TimelineComponent` gained clone support through a
73/// new supertrait, changing the vtable layout of timeline trait objects held by
74/// live-preview plugins. Stale `v8` plugins must fail before the host loads an
75/// incompatible component contract.
76pub const ENTRY_SYMBOL: &[u8] = b"tellur_timeline_collection_v9\0";
77
78pub mod abi;
79pub use abi::{
80 validate_plugin_fingerprint, AbiFingerprintFn, AbiMismatchError, ABI_FINGERPRINT,
81 ABI_FINGERPRINT_SYMBOL,
82};
83
84/// Export the C ABI fingerprint symbol alongside timeline entry points.
85#[doc(hidden)]
86#[macro_export]
87macro_rules! __tellur_export_abi_fingerprint {
88 () => {
89 #[no_mangle]
90 pub extern "C" fn tellur_abi_fingerprint_v1() -> *const ::std::os::raw::c_char {
91 $crate::abi::ABI_FINGERPRINT_C.as_ptr().cast()
92 }
93 };
94}
95
96/// The signature of the [`ENTRY_SYMBOL`] entry point a plugin exports.
97pub type EntryFn = unsafe extern "Rust" fn() -> Box<dyn TimelineCollection>;
98
99/// Metadata for one timeline exposed by a plugin.
100#[derive(Debug, Clone, PartialEq)]
101pub struct TimelineInfo {
102 pub id: String,
103 pub title: String,
104 pub duration: f64,
105 /// A resolve-time error for this timeline, if its tree failed to resolve
106 /// (e.g. a media probe failure or a timeless root). Mirrors the
107 /// `last_error` display the server already surfaces for plugin load
108 /// failures; `None` when the timeline resolved cleanly.
109 pub error: Option<String>,
110}
111
112/// A collection of timelines exported by one dynamic library.
113pub trait TimelineCollection: Send {
114 fn timelines(&self) -> Vec<TimelineInfo>;
115
116 /// Builds a frame with the representation requested by its consumer.
117 fn build(
118 &self,
119 id: &str,
120 t: TimelineTime,
121 target: Resolution,
122 residency: RasterResidency,
123 ctx: &mut dyn RenderContext,
124 ) -> Option<RasterImage>;
125
126 /// The resolved arrangement tree for `id`, for the live UI to introspect.
127 ///
128 /// Defaulted to `None` so existing collections can migrate in stages
129 /// (audit B.4): a collection that has not yet built a resolved tree simply
130 /// returns nothing here.
131 fn arrangement(&self, _id: &str) -> Option<Arrangement> {
132 None
133 }
134
135 /// The block-rendered audio mix-down for `id` at `rate` / `channels`, for the live
136 /// preview to mux into its video stream. `None` means the collection
137 /// contributes no audio (the preview then muxes a silent track for a
138 /// consistent A/V stream structure). Defaulted so collections migrate in
139 /// stages, mirroring [`arrangement`](Self::arrangement).
140 fn render_audio(&self, _id: &str, _rate: u32, _channels: u16) -> Option<AudioBuffer> {
141 None
142 }
143
144 /// Block-rendered audio mix-down for `[start, start + duration)`, used by the live
145 /// preview when it encodes an individual cache segment. Custom collections
146 /// must implement this explicitly to preview audio; falling back to the
147 /// whole-track [`render_audio`](Self::render_audio) would reintroduce the
148 /// per-segment full-timeline work this API avoids.
149 fn render_audio_window(
150 &self,
151 _id: &str,
152 _start: f64,
153 _duration: f64,
154 _rate: u32,
155 _channels: u16,
156 ) -> Option<AudioBuffer> {
157 None
158 }
159}
160
161/// Wraps a single resolved [`TimelineComponent`] as a one-entry collection.
162///
163/// The tree is resolved ONCE at collection construction (plugin-side — the host
164/// only sees `Box<dyn TimelineCollection>`, audit B1) and the result is stored
165/// as a [`Result`]: resolving probes media and can fail, but the entry fn
166/// cannot return a `Result` across the dylib boundary and panicking there is
167/// UB-adjacent (audit M5). So the error is stored and surfaced per query —
168/// frame builds yield `None`, `timelines` carries the error string, and
169/// `arrangement` yields `None`.
170pub struct SingleTimeline {
171 id: &'static str,
172 title: &'static str,
173 resolved: Result<ResolvedTimeline, ResolveError>,
174}
175
176/// Resolves `root` and wraps it as a one-entry [`TimelineCollection`].
177///
178/// `resolve` CONSUMES the tree (audit M1) and can fail; the result is kept
179/// intact so the entry fn stays panic-free.
180pub fn single_timeline<T: TimelineComponent + Send + 'static>(
181 id: &'static str,
182 title: &'static str,
183 root: T,
184) -> SingleTimeline {
185 SingleTimeline {
186 id,
187 title,
188 resolved: resolve(root),
189 }
190}
191
192/// Like [`single_timeline`] but resolves against an explicit logical `canvas`.
193pub fn single_timeline_with_canvas<T: TimelineComponent + Send + 'static>(
194 id: &'static str,
195 title: &'static str,
196 root: T,
197 canvas: Vec2,
198) -> SingleTimeline {
199 SingleTimeline {
200 id,
201 title,
202 resolved: resolve_with_canvas(root, canvas),
203 }
204}
205
206impl SingleTimeline {
207 fn resolved(&self) -> Option<&ResolvedTimeline> {
208 self.resolved.as_ref().ok()
209 }
210}
211
212impl TimelineCollection for SingleTimeline {
213 fn timelines(&self) -> Vec<TimelineInfo> {
214 let (duration, error) = match &self.resolved {
215 Ok(resolved) => (resolved.duration(), None),
216 Err(e) => (0.0, Some(e.to_string())),
217 };
218 vec![TimelineInfo {
219 id: self.id.to_owned(),
220 title: self.title.to_owned(),
221 duration,
222 error,
223 }]
224 }
225
226 fn build(
227 &self,
228 id: &str,
229 t: TimelineTime,
230 target: Resolution,
231 residency: RasterResidency,
232 ctx: &mut dyn RenderContext,
233 ) -> Option<RasterImage> {
234 if id != self.id {
235 return None;
236 }
237 self.resolved()?.frame(t, target, residency, ctx)
238 }
239
240 fn arrangement(&self, id: &str) -> Option<Arrangement> {
241 if id != self.id {
242 return None;
243 }
244 // Root offset 0: the resolved tree's start coincides with the global
245 // axis, so the walk stamps absolute starts/ends from there.
246 Some(self.resolved()?.source().arrangement(0.0))
247 }
248
249 fn render_audio(&self, id: &str, rate: u32, channels: u16) -> Option<AudioBuffer> {
250 if id != self.id {
251 return None;
252 }
253 // `render_audio` returns a buffer of the whole resolved length — silent
254 // when the tree has no audio sources — so the preview always gets a
255 // determinate track to mux.
256 Some(self.resolved()?.render_audio(rate, channels))
257 }
258
259 fn render_audio_window(
260 &self,
261 id: &str,
262 start: f64,
263 duration: f64,
264 rate: u32,
265 channels: u16,
266 ) -> Option<AudioBuffer> {
267 if id != self.id {
268 return None;
269 }
270 Some(
271 self.resolved()?
272 .render_audio_window(start, duration, rate, channels),
273 )
274 }
275}
276
277/// Exports a single [`TimelineComponent`] root from a `cdylib`.
278///
279/// ```ignore
280/// #[tellur_core::component(timeline)]
281/// fn Main() -> impl tellur_core::timeline_component::TimelineComponent { ... }
282///
283/// tellur_plugin::export_timeline!(
284/// root = Main::builder().build(),
285/// title = "Main",
286/// );
287/// ```
288///
289/// `title` is the human-readable label surfaced by [`TimelineInfo`]. The
290/// machine-facing timeline id defaults to `"main"`; set `id = "..."` after
291/// `title` when a different stable lookup key is required. Set
292/// `canvas = (width, height)` last to resolve the root against an explicit
293/// logical canvas.
294#[macro_export]
295macro_rules! export_timeline {
296 (@__emit root = $root:expr, title = $title:expr, id = $id:expr) => {
297 $crate::__tellur_export_abi_fingerprint!();
298 #[no_mangle]
299 pub extern "Rust" fn tellur_timeline_collection_v9(
300 ) -> ::std::boxed::Box<dyn $crate::TimelineCollection> {
301 let __root = $root;
302 ::std::boxed::Box::new($crate::single_timeline($id, $title, __root))
303 }
304 };
305 (
306 @__emit
307 root = $root:expr,
308 title = $title:expr,
309 id = $id:expr,
310 canvas = ($w:expr, $h:expr)
311 ) => {
312 $crate::__tellur_export_abi_fingerprint!();
313 #[no_mangle]
314 pub extern "Rust" fn tellur_timeline_collection_v9(
315 ) -> ::std::boxed::Box<dyn $crate::TimelineCollection> {
316 let __root = $root;
317 ::std::boxed::Box::new($crate::single_timeline_with_canvas(
318 $id,
319 $title,
320 __root,
321 $crate::__core::geometry::Vec2($w, $h),
322 ))
323 }
324 };
325 (root = $root:expr, title = $title:expr $(,)?) => {
326 $crate::export_timeline!(@__emit root = $root, title = $title, id = "main");
327 };
328 (root = $root:expr, title = $title:expr, canvas = ($w:expr, $h:expr) $(,)?) => {
329 $crate::export_timeline!(
330 @__emit
331 root = $root,
332 title = $title,
333 id = "main",
334 canvas = ($w, $h)
335 );
336 };
337 (root = $root:expr, title = $title:expr, id = $id:expr $(,)?) => {
338 $crate::export_timeline!(@__emit root = $root, title = $title, id = $id);
339 };
340 (
341 root = $root:expr,
342 title = $title:expr,
343 id = $id:expr,
344 canvas = ($w:expr, $h:expr) $(,)?
345 ) => {
346 $crate::export_timeline!(
347 @__emit
348 root = $root,
349 title = $title,
350 id = $id,
351 canvas = ($w, $h)
352 );
353 };
354 ($id:expr, $title:expr, $builder:path $(,)?) => {
355 ::core::compile_error!(
356 "export_timeline! now accepts `root = <TimelineComponent expression>, title = <title>`; call the former factory explicitly as `root = build()`"
357 );
358 };
359 ($id:expr, $title:expr, $builder:path, canvas = ($w:expr, $h:expr) $(,)?) => {
360 ::core::compile_error!(
361 "export_timeline! now accepts `root = <TimelineComponent expression>, title = <title>, canvas = (width, height)`; call the former factory explicitly as `root = build()`"
362 );
363 };
364}
365
366/// Exports a custom [`TimelineCollection`] builder from a `cdylib`.
367#[macro_export]
368macro_rules! export_timeline_collection {
369 ($builder:path) => {
370 $crate::__tellur_export_abi_fingerprint!();
371 #[no_mangle]
372 pub extern "Rust" fn tellur_timeline_collection_v9(
373 ) -> ::std::boxed::Box<dyn $crate::TimelineCollection> {
374 ::std::boxed::Box::new($builder())
375 }
376 };
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382 use std::sync::atomic::{AtomicU8, Ordering};
383
384 use tellur_core::geometry::{Constraints, Vec2};
385 use tellur_core::raster::{PixelFormat, RasterComponent, RasterResidency};
386 use tellur_core::render_context::PassThrough;
387 use tellur_core::timeline_component::{Clock, NodeKind, Timed};
388 use tellur_core::timeline_container::Timeline as TimelineContainer;
389
390 // A trivial timeless visual so a small native timeline can be built without
391 // pulling in media decode. Reaches the timeline world via the one-way
392 // blanket over `RasterComponent`.
393 #[derive(Clone, PartialEq, Hash)]
394 struct Dot;
395
396 impl RasterComponent for Dot {
397 fn layout(&self, _c: Constraints) -> Vec2 {
398 Vec2(1.0, 1.0)
399 }
400
401 fn render(
402 &self,
403 _s: Vec2,
404 _t: Resolution,
405 _residency: RasterResidency,
406 _ctx: &mut dyn RenderContext,
407 ) -> RasterImage {
408 RasterImage::cpu(1, 1, PixelFormat::Rgba8, vec![0u8, 0, 0, 0])
409 }
410 }
411
412 // Builds an overlay `Timeline` of two windowed visuals — a small native
413 // timeline that resolves to a determinate length (no media probe).
414 fn build_new_api_timeline() -> TimelineContainer {
415 TimelineContainer::builder()
416 .child(Dot.at(0.0..2.0))
417 .child(Dot.at(0.0..3.0))
418 .build()
419 }
420
421 static LAST_COLLECTION_RESIDENCY: AtomicU8 = AtomicU8::new(0);
422
423 #[derive(Clone, PartialEq, Hash)]
424 struct ResidencyTimeline;
425
426 impl TimelineComponent for ResidencyTimeline {
427 fn duration(&self) -> Option<f64> {
428 Some(1.0)
429 }
430
431 fn frame(
432 &self,
433 _clock: Clock<'_>,
434 _canvas: Vec2,
435 _target: Resolution,
436 residency: RasterResidency,
437 _ctx: &mut dyn RenderContext,
438 ) -> Option<RasterImage> {
439 let value = match residency {
440 RasterResidency::Cpu => 1,
441 RasterResidency::Gpu => 2,
442 };
443 LAST_COLLECTION_RESIDENCY.store(value, Ordering::Relaxed);
444 Some(RasterImage::cpu(
445 1,
446 1,
447 PixelFormat::Rgba8,
448 vec![0u8, 0, 0, 0],
449 ))
450 }
451
452 fn arrangement(&self, offset: f64) -> Arrangement {
453 Arrangement {
454 kind: NodeKind::Video,
455 label: String::new(),
456 name: None,
457 source: None,
458 start: offset,
459 end: offset + 1.0,
460 trim: None,
461 triggers: Vec::new(),
462 children: Vec::new(),
463 }
464 }
465 }
466
467 #[test]
468 fn entry_symbol_marks_the_cloneable_timeline_component_abi() {
469 assert_eq!(ENTRY_SYMBOL, b"tellur_timeline_collection_v9\0");
470 }
471
472 #[test]
473 fn single_timeline_forwards_residency() {
474 let collection = single_timeline("main", "Main", ResidencyTimeline);
475 let mut ctx = PassThrough;
476 let target = Resolution::new(1, 1);
477
478 LAST_COLLECTION_RESIDENCY.store(0, Ordering::Relaxed);
479 let _ = collection.build(
480 "main",
481 TimelineTime::new(0.0),
482 target,
483 RasterResidency::Gpu,
484 &mut ctx,
485 );
486 assert_eq!(LAST_COLLECTION_RESIDENCY.load(Ordering::Relaxed), 2);
487 }
488
489 #[test]
490 fn single_timeline_surfaces_resolved_duration() {
491 let collection = single_timeline("main", "Main", build_new_api_timeline());
492 let infos = collection.timelines();
493 assert_eq!(infos.len(), 1);
494 assert_eq!(infos[0].id, "main");
495 assert_eq!(infos[0].title, "Main");
496 // The overlay length is the max child window end (3.0).
497 assert_eq!(infos[0].duration, 3.0);
498 assert_eq!(infos[0].error, None);
499 }
500
501 #[test]
502 fn single_timeline_arrangement_walks_the_resolved_tree() {
503 let collection = single_timeline("main", "Main", build_new_api_timeline());
504
505 // A non-matching id yields nothing.
506 assert!(collection.arrangement("other").is_none());
507
508 let root = collection
509 .arrangement("main")
510 .expect("the resolved tree has an arrangement");
511
512 // The root is the overlay Timeline spanning [0, 3].
513 assert_eq!(root.kind, NodeKind::Timeline);
514 assert_eq!(root.start, 0.0);
515 assert_eq!(root.end, 3.0);
516 assert!(root.trim.is_none());
517
518 // Two children, each a Video-kind visual leaf (via the blanket): a
519 // timeless visual lives on the video (映像) track.
520 assert_eq!(root.children.len(), 2);
521 for child in &root.children {
522 assert_eq!(child.kind, NodeKind::Video);
523 assert!(child.children.is_empty());
524 }
525 }
526}