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 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}