Skip to main content

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}