Skip to main content

oxideav_scene/
object.rs

1//! Scene objects — what's on the canvas, and where.
2
3use std::sync::Arc;
4
5use crate::animation::Animation;
6use crate::duration::Lifetime;
7use crate::id::ObjectId;
8
9/// Pixel format alias; re-exports [`oxideav_core::PixelFormat`] so
10/// callers don't need a direct core dependency just to build a
11/// canvas.
12pub use oxideav_core::PixelFormat;
13
14/// Canvas — either pixel-based (NLE, compositor) or vector-coord
15/// (PDF pages).
16#[non_exhaustive]
17#[derive(Clone, Copy, Debug, PartialEq)]
18pub enum Canvas {
19    /// Pixel raster. Used by the streaming compositor and the NLE
20    /// timeline.
21    Raster {
22        width: u32,
23        height: u32,
24        pixel_format: PixelFormat,
25    },
26    /// Unit-agnostic vector canvas. PDF pages use this; the unit is
27    /// whatever the producer declared. All scene coordinates live in
28    /// this unit; rasterisation happens at export time.
29    Vector {
30        width: f32,
31        height: f32,
32        unit: LengthUnit,
33    },
34}
35
36impl Canvas {
37    /// Convenience for the common case: 8-bit 4:2:0 raster.
38    pub const fn raster(width: u32, height: u32) -> Self {
39        Canvas::Raster {
40            width,
41            height,
42            pixel_format: PixelFormat::Yuv420P,
43        }
44    }
45
46    /// Pixel dims for raster canvases, `None` for vector canvases.
47    pub fn raster_size(&self) -> Option<(u32, u32)> {
48        match self {
49            Canvas::Raster { width, height, .. } => Some((*width, *height)),
50            Canvas::Vector { .. } => None,
51        }
52    }
53}
54
55/// Length unit for vector canvases.
56#[non_exhaustive]
57#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
58pub enum LengthUnit {
59    /// PostScript / PDF point: 1/72 inch.
60    #[default]
61    Point,
62    /// Millimetre.
63    Millimetre,
64    /// Inch.
65    Inch,
66    /// CSS pixel (96/in).
67    CssPixel,
68    /// Device pixel — what it is is device-dependent.
69    DevicePixel,
70}
71
72/// One renderable element on a scene.
73#[derive(Clone, Debug)]
74pub struct SceneObject {
75    pub id: ObjectId,
76    pub kind: ObjectKind,
77    pub transform: Transform,
78    pub lifetime: Lifetime,
79    pub animations: Vec<Animation>,
80    pub z_order: i32,
81    pub opacity: f32,
82    pub blend_mode: BlendMode,
83    pub effects: Vec<Effect>,
84    pub clip: Option<ClipRect>,
85}
86
87impl Default for SceneObject {
88    fn default() -> Self {
89        SceneObject {
90            id: ObjectId::default(),
91            kind: ObjectKind::Shape(Shape::rect(0.0, 0.0)),
92            transform: Transform::identity(),
93            lifetime: Lifetime::default(),
94            animations: Vec::new(),
95            z_order: 0,
96            opacity: 1.0,
97            blend_mode: BlendMode::default(),
98            effects: Vec::new(),
99            clip: None,
100        }
101    }
102}
103
104/// What a scene object IS.
105#[non_exhaustive]
106#[derive(Clone, Debug)]
107pub enum ObjectKind {
108    Image(ImageSource),
109    Video(VideoSource),
110    Text(TextRun),
111    Shape(Shape),
112    Group(Vec<ObjectId>),
113    Live(LiveStreamHandle),
114    /// Vector content — a self-contained
115    /// [`oxideav_core::VectorFrame`]. Renders natively to vector
116    /// outputs (PDF / SVG writers consume the `VectorFrame` as-is)
117    /// and rasterises through `oxideav_raster::Renderer` for
118    /// raster outputs (PNG / MP4 / RTMP); see
119    /// [`crate::raster::rasterize_vector`] for the helper. The
120    /// rasteriser also picks up `Group::cache_key` automatically
121    /// when the same sub-tree is re-rendered.
122    Vector(oxideav_core::VectorFrame),
123}
124
125impl ObjectKind {
126    /// Object-local content extent for the kinds that carry one
127    /// intrinsically.
128    ///
129    /// - [`ObjectKind::Vector`] — the underlying
130    ///   [`oxideav_core::VectorFrame`]'s viewport `(width, height)`.
131    /// - [`ObjectKind::Shape`] — delegates to [`Shape::content_size`].
132    /// - [`ObjectKind::Live`] — the source's
133    ///   [`hint_size`](LiveStreamHandle::hint_size), cast to `f32`
134    ///   when present.
135    /// - [`ObjectKind::Image`], [`ObjectKind::Video`],
136    ///   [`ObjectKind::Text`], [`ObjectKind::Group`] — return
137    ///   `None`. These kinds either pull their extent from a frame
138    ///   the renderer fetches at render time (image / video / live
139    ///   without a hint), from a shaping engine the scene crate
140    ///   doesn't bind (text), or from their referenced children
141    ///   resolved against a scene (group). Callers wanting a
142    ///   geometry estimate for these kinds pass a fallback into
143    ///   [`SceneObject::bbox`].
144    pub fn content_size(&self) -> Option<(f32, f32)> {
145        match self {
146            ObjectKind::Vector(vf) => Some((vf.width, vf.height)),
147            ObjectKind::Shape(s) => s.content_size(),
148            ObjectKind::Live(h) => h.hint_size.map(|(w, h)| (w as f32, h as f32)),
149            ObjectKind::Image(_)
150            | ObjectKind::Video(_)
151            | ObjectKind::Text(_)
152            | ObjectKind::Group(_) => None,
153        }
154    }
155}
156
157impl SceneObject {
158    /// Object-local content extent — sugar over
159    /// [`ObjectKind::content_size`] for the object's own kind. See
160    /// that method for which kinds report a size and which return
161    /// `None`.
162    pub fn content_size(&self) -> Option<(f32, f32)> {
163        self.kind.content_size()
164    }
165
166    /// Axis-aligned bounding box of this object in canvas space.
167    ///
168    /// The intrinsic content extent is taken from
169    /// [`SceneObject::content_size`] when available; otherwise
170    /// `fallback` is used — pass the canvas size (or a per-object
171    /// hint from the renderer) for kinds whose content size isn't
172    /// known to the scene layer (raster images, video, text runs).
173    /// The chosen extent is then run through
174    /// [`Transform::bbox`](Transform::bbox) and finally intersected
175    /// with [`SceneObject::clip`] if the object carries one.
176    ///
177    /// Clipping is conservative: the returned rectangle is the
178    /// *intersection* of the transformed content AABB with the clip
179    /// rect, expressed in canvas coordinates. The clip's coordinates
180    /// are interpreted as already living in canvas space (matching
181    /// the [`ClipRect`] doc-comment). When the intersection is empty
182    /// the returned rect has zero width / height — the caller can
183    /// detect culling by checking `rect.width == 0.0 ||
184    /// rect.height == 0.0`.
185    pub fn bbox(&self, fallback: (f32, f32)) -> oxideav_core::Rect {
186        let (w, h) = self.content_size().unwrap_or(fallback);
187        let bb = self.transform.bbox(w, h);
188        match self.clip {
189            None => bb,
190            Some(clip) => intersect_rect(bb, clip),
191        }
192    }
193
194    /// Find the *first* animation track on this object whose
195    /// [`AnimatedProperty`](crate::animation::AnimatedProperty) matches
196    /// `prop` and sample it at scene time `t`. Returns the raw
197    /// [`KeyframeValue`](crate::animation::KeyframeValue) the track
198    /// emits — no merging with the object's base
199    /// [`Transform`] / [`opacity`](Self::opacity) is performed here.
200    ///
201    /// Returns `None` when the object carries no track for `prop` or
202    /// when the matching track has no keyframes. Tracks are searched
203    /// in insertion order; if two tracks animate the same property
204    /// (currently allowed by
205    /// [`Operation::Animate`](crate::ops::Operation::Animate)) only
206    /// the first is consulted — the second is effectively shadowed
207    /// until a [`CancelAnimation`](crate::ops::Operation::CancelAnimation)
208    /// removes the leader.
209    pub fn evaluate_property_at(
210        &self,
211        t: crate::duration::TimeStamp,
212        prop: &crate::animation::AnimatedProperty,
213    ) -> Option<crate::animation::KeyframeValue> {
214        let anim = self.animations.iter().find(|a| &a.property == prop)?;
215        anim.sample(t)
216    }
217
218    /// Compose the object's base [`Transform`] with any
219    /// [`Position`](crate::animation::AnimatedProperty::Position) /
220    /// [`Scale`](crate::animation::AnimatedProperty::Scale) /
221    /// [`Rotation`](crate::animation::AnimatedProperty::Rotation) /
222    /// [`Skew`](crate::animation::AnimatedProperty::Skew) /
223    /// [`Anchor`](crate::animation::AnimatedProperty::Anchor)
224    /// animation tracks evaluated at scene time `t`.
225    ///
226    /// Composition rule (per property):
227    ///
228    /// - `Position` (Vec2) — *added* to base `position`. Animations
229    ///   are offsets from the base, matching the documented
230    ///   `Operation::SetTransform` semantics ("animations on the
231    ///   same object continue to add to this base").
232    /// - `Scale` (Vec2) — *multiplied* with base `scale`. Matches the
233    ///   convention used by After Effects / Lottie scale tracks.
234    /// - `Rotation` (Scalar, radians) — *added* to base `rotation`.
235    /// - `Skew` (Vec2, radians) — *added* to base `skew`.
236    /// - `Anchor` (Vec2, normalised 0..=1) — *replaces* base `anchor`.
237    ///   Anchors are pivot points, not deltas, so addition would be
238    ///   meaningless; the animated value is used verbatim.
239    ///
240    /// Variant mismatches between the base field type and the track's
241    /// [`KeyframeValue`] (e.g. an `Animation` on `Position` carrying a
242    /// `Scalar`) are silently ignored — the base value passes
243    /// through. Animation tracks targeting non-transform properties
244    /// (`Opacity`, `Volume`, `EffectParam`, `Custom`) are likewise
245    /// ignored by this method.
246    pub fn effective_transform_at(&self, t: crate::duration::TimeStamp) -> Transform {
247        use crate::animation::{AnimatedProperty as P, KeyframeValue as V};
248        let mut out = self.transform;
249        for prop in [P::Position, P::Scale, P::Rotation, P::Skew, P::Anchor] {
250            let Some(v) = self.evaluate_property_at(t, &prop) else {
251                continue;
252            };
253            match (prop, v) {
254                (P::Position, V::Vec2(dx, dy)) => {
255                    out.position = (out.position.0 + dx, out.position.1 + dy);
256                }
257                (P::Scale, V::Vec2(sx, sy)) => {
258                    out.scale = (out.scale.0 * sx, out.scale.1 * sy);
259                }
260                (P::Rotation, V::Scalar(r)) => {
261                    out.rotation += r;
262                }
263                (P::Skew, V::Vec2(kx, ky)) => {
264                    out.skew = (out.skew.0 + kx, out.skew.1 + ky);
265                }
266                (P::Anchor, V::Vec2(ax, ay)) => {
267                    out.anchor = (ax, ay);
268                }
269                _ => {} // variant mismatch — base value passes through.
270            }
271        }
272        out
273    }
274
275    /// Compose the object's base [`opacity`](Self::opacity) with any
276    /// [`Opacity`](crate::animation::AnimatedProperty::Opacity)
277    /// animation track evaluated at scene time `t`.
278    ///
279    /// The animated value *multiplies* the base — a base of `0.5` and
280    /// an animated `Scalar(0.5)` yields `0.25`. The result is clamped
281    /// to `0.0..=1.0` so the caller can hand it straight to a
282    /// compositor's alpha channel without re-clamping.
283    ///
284    /// Variant mismatches (a non-`Scalar` keyframe on an `Opacity`
285    /// track) are ignored — the base value passes through clamped.
286    pub fn effective_opacity_at(&self, t: crate::duration::TimeStamp) -> f32 {
287        use crate::animation::{AnimatedProperty as P, KeyframeValue as V};
288        let base = self.opacity;
289        let factor = match self.evaluate_property_at(t, &P::Opacity) {
290            Some(V::Scalar(v)) => v,
291            _ => 1.0,
292        };
293        (base * factor).clamp(0.0, 1.0)
294    }
295
296    /// Evaluate every animation track on this object at scene time
297    /// `t` and return a [`Sample`] carrying the resolved transform +
298    /// opacity. The object's `kind`, `z_order`, `blend_mode`, `clip`
299    /// and `id` are forwarded verbatim from `self`.
300    ///
301    /// This is the single-call entry point a renderer uses per object
302    /// per frame: it hides the per-property dispatch and produces a
303    /// pre-merged state that can be fed straight to the compositor.
304    pub fn sample_at(&self, t: crate::duration::TimeStamp) -> Sample {
305        Sample {
306            id: self.id,
307            z_order: self.z_order,
308            transform: self.effective_transform_at(t),
309            opacity: self.effective_opacity_at(t),
310            blend_mode: self.blend_mode,
311            clip: self.clip,
312        }
313    }
314}
315
316/// Per-object resolved state at a single scene time. Produced by
317/// [`SceneObject::sample_at`] (and [`crate::Scene::sampled_at`]) so
318/// renderers consume a flat, animation-merged view of each visible
319/// object rather than threading [`Animation`](crate::animation::Animation)
320/// evaluation through their own pipeline.
321///
322/// The forwarded fields (`id`, `z_order`, `blend_mode`, `clip`) come
323/// from the source [`SceneObject`] unchanged; `transform` is the
324/// composed base + animation-track result (see
325/// [`SceneObject::effective_transform_at`]); `opacity` is the
326/// composed + clamped value from
327/// [`SceneObject::effective_opacity_at`]. The object's `kind` is not
328/// inlined here — the renderer typically already holds the source
329/// [`SceneObject`] for that and inlining `kind` would defeat the
330/// "cheap to clone per frame" goal of this struct.
331#[derive(Clone, Copy, Debug)]
332pub struct Sample {
333    pub id: ObjectId,
334    pub z_order: i32,
335    pub transform: Transform,
336    pub opacity: f32,
337    pub blend_mode: BlendMode,
338    pub clip: Option<ClipRect>,
339}
340
341/// Intersect the transformed-object AABB with a [`ClipRect`] given
342/// in canvas space. Returns a [`Rect`] with non-negative extent;
343/// extent is zero on both axes when the rectangles do not overlap.
344fn intersect_rect(a: oxideav_core::Rect, clip: ClipRect) -> oxideav_core::Rect {
345    let ax2 = a.x + a.width;
346    let ay2 = a.y + a.height;
347    let bx1 = clip.x;
348    let by1 = clip.y;
349    let bx2 = clip.x + clip.width;
350    let by2 = clip.y + clip.height;
351    let x1 = a.x.max(bx1);
352    let y1 = a.y.max(by1);
353    let x2 = ax2.min(bx2);
354    let y2 = ay2.min(by2);
355    if x2 <= x1 || y2 <= y1 {
356        oxideav_core::Rect::new(x1, y1, 0.0, 0.0)
357    } else {
358        oxideav_core::Rect::new(x1, y1, x2 - x1, y2 - y1)
359    }
360}
361
362/// Affine placement on the canvas. Applied in this order:
363/// translate → anchor-relative rotate → scale → skew.
364#[derive(Clone, Copy, Debug, PartialEq)]
365pub struct Transform {
366    pub position: (f32, f32),
367    pub scale: (f32, f32),
368    /// Radians, counter-clockwise, around `anchor`.
369    pub rotation: f32,
370    /// Pivot point in normalised object-local coordinates (0..=1).
371    /// `(0.5, 0.5)` is the object centre.
372    pub anchor: (f32, f32),
373    /// Shear in radians, per axis.
374    pub skew: (f32, f32),
375}
376
377impl Transform {
378    pub const fn identity() -> Self {
379        Transform {
380            position: (0.0, 0.0),
381            scale: (1.0, 1.0),
382            rotation: 0.0,
383            anchor: (0.5, 0.5),
384            skew: (0.0, 0.0),
385        }
386    }
387
388    /// Lower this high-level transform into a flat
389    /// [`oxideav_core::Transform2D`] (the SVG / PDF `matrix(a,b,c,d,e,f)`
390    /// form) for a content box of the given `(width, height)`.
391    ///
392    /// The struct's per-field semantics are realised in the documented
393    /// order: a point in object-local space is first moved so the
394    /// normalised [`anchor`](Self::anchor) sits at the origin, then
395    /// rotated, scaled, and sheared about that anchor, and finally
396    /// translated by [`position`](Self::position). Concretely the
397    /// returned matrix `M` satisfies
398    ///
399    /// ```text
400    /// M = T(position) · T(+pivot) · skew · scale · rotate · T(-pivot)
401    /// ```
402    ///
403    /// where `pivot = (anchor.0 * width, anchor.1 * height)`. Applying
404    /// `M` to a local point yields its canvas-space coordinate. The
405    /// identity [`Transform`] over any content size lowers to
406    /// [`Transform2D::identity`](oxideav_core::Transform2D::identity).
407    ///
408    /// `width` / `height` are the object's intrinsic content extent in
409    /// canvas units — only the anchor pivot depends on them, so a
410    /// zero-size content box still produces a well-formed (pivot-at-
411    /// origin) matrix.
412    pub fn to_matrix(&self, width: f32, height: f32) -> oxideav_core::Transform2D {
413        use oxideav_core::Transform2D as M;
414
415        let (px, py) = (self.anchor.0 * width, self.anchor.1 * height);
416
417        // Built right-to-left so the leftmost factor is applied last:
418        // start at the anchor-origin shift, then rotate, scale, skew,
419        // re-apply the pivot, and finally translate into place.
420        let mut m = M::translate(self.position.0, self.position.1);
421        m = m.compose(&M::translate(px, py));
422        // Skew: shear-X then shear-Y, matching Premiere's per-axis skew.
423        if self.skew.0 != 0.0 {
424            m = m.compose(&M::skew_x(self.skew.0));
425        }
426        if self.skew.1 != 0.0 {
427            m = m.compose(&M::skew_y(self.skew.1));
428        }
429        m = m.compose(&M::scale(self.scale.0, self.scale.1));
430        if self.rotation != 0.0 {
431            m = m.compose(&M::rotate(self.rotation));
432        }
433        m = m.compose(&M::translate(-px, -py));
434        m
435    }
436
437    /// Map an object-local point into canvas space under this
438    /// transform, for a content box of `(width, height)`. Convenience
439    /// over [`to_matrix`](Self::to_matrix) +
440    /// [`Transform2D::apply`](oxideav_core::Transform2D::apply).
441    pub fn apply_to_point(
442        &self,
443        width: f32,
444        height: f32,
445        point: oxideav_core::Point,
446    ) -> oxideav_core::Point {
447        self.to_matrix(width, height).apply(point)
448    }
449
450    /// Axis-aligned bounding box, in canvas space, of a
451    /// `(width, height)` content box placed at the local origin
452    /// `(0, 0)..(width, height)` and run through this transform.
453    ///
454    /// Computed by mapping the box's four corners and taking the min /
455    /// max of the results, so it is tight for translate / scale / skew
456    /// and a correct (rotation-aware) enclosing box for rotations —
457    /// the AABB grows to contain a rotated rectangle rather than
458    /// rotating with it. The returned [`oxideav_core::Rect`] always has
459    /// non-negative `width` / `height`.
460    pub fn bbox(&self, width: f32, height: f32) -> oxideav_core::Rect {
461        use oxideav_core::Point;
462
463        let m = self.to_matrix(width, height);
464        let corners = [
465            m.apply(Point::new(0.0, 0.0)),
466            m.apply(Point::new(width, 0.0)),
467            m.apply(Point::new(width, height)),
468            m.apply(Point::new(0.0, height)),
469        ];
470        let mut min_x = corners[0].x;
471        let mut min_y = corners[0].y;
472        let mut max_x = corners[0].x;
473        let mut max_y = corners[0].y;
474        for p in &corners[1..] {
475            min_x = min_x.min(p.x);
476            min_y = min_y.min(p.y);
477            max_x = max_x.max(p.x);
478            max_y = max_y.max(p.y);
479        }
480        oxideav_core::Rect::new(min_x, min_y, max_x - min_x, max_y - min_y)
481    }
482}
483
484impl Default for Transform {
485    fn default() -> Self {
486        Transform::identity()
487    }
488}
489
490/// Compositing blend — painter's algorithm default is `Normal`.
491#[non_exhaustive]
492#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
493pub enum BlendMode {
494    #[default]
495    Normal,
496    Multiply,
497    Screen,
498    Overlay,
499    Add,
500    /// Subtract destination from source.
501    Subtract,
502    /// Source replaces destination even in transparent regions —
503    /// useful for mask objects.
504    Copy,
505}
506
507/// Filter applied to the object's raster output before compositing.
508/// The parameter map is opaque here; per-effect implementations in
509/// sibling crates interpret it.
510#[derive(Clone, Debug)]
511pub struct Effect {
512    pub name: String,
513    pub params: Vec<(String, f32)>,
514}
515
516/// Axis-aligned clipping rectangle in canvas coordinates.
517#[derive(Clone, Copy, Debug, PartialEq)]
518pub struct ClipRect {
519    pub x: f32,
520    pub y: f32,
521    pub width: f32,
522    pub height: f32,
523}
524
525/// Bitmap source. Either an owned frame, a shared frame handle, or
526/// a path that the renderer resolves on first use.
527#[non_exhaustive]
528#[derive(Clone, Debug)]
529pub enum ImageSource {
530    /// Fully-decoded frame, `Arc`-shared so cloning is cheap.
531    Decoded(Arc<oxideav_core::VideoFrame>),
532    /// Filesystem path — resolved lazily by the renderer.
533    Path(String),
534    /// Raw bytes of an encoded image file (PNG/JPEG/etc).
535    EncodedBytes(Arc<[u8]>),
536}
537
538/// Video source. Resolves packets via the container layer on
539/// demand; the scene renderer advances it to the requested PTS.
540#[non_exhaustive]
541#[derive(Clone, Debug)]
542pub enum VideoSource {
543    Path(String),
544    EncodedBytes(Arc<[u8]>),
545}
546
547/// Styled text run. Font resolution + shaping land in a separate
548/// crate; this type only carries what the model needs to preserve
549/// (the string itself + structural + appearance metadata).
550#[derive(Clone, Debug, Default)]
551pub struct TextRun {
552    pub text: String,
553    pub font_family: String,
554    pub font_weight: u16,
555    pub font_size: f32,
556    /// `0xRRGGBBAA`.
557    pub color: u32,
558    /// Optional explicit glyph-advance vector (PDF-style). If
559    /// `None`, the rasteriser shapes on the fly.
560    pub advances: Option<Vec<f32>>,
561    pub italic: bool,
562    pub underline: bool,
563}
564
565/// Vector shape primitive.
566#[non_exhaustive]
567#[derive(Clone, Debug)]
568pub enum Shape {
569    Rect {
570        width: f32,
571        height: f32,
572        fill: u32,
573        stroke: Option<Stroke>,
574        corner_radius: f32,
575    },
576    Polygon {
577        points: Vec<(f32, f32)>,
578        fill: u32,
579        stroke: Option<Stroke>,
580    },
581    Path {
582        /// SVG path data ("M10,10 L20,20 …").
583        data: String,
584        fill: u32,
585        stroke: Option<Stroke>,
586    },
587}
588
589impl Shape {
590    /// Zero-size placeholder rect with no fill. Used by
591    /// `SceneObject::default`.
592    pub const fn rect(width: f32, height: f32) -> Self {
593        Shape::Rect {
594            width,
595            height,
596            fill: 0,
597            stroke: None,
598            corner_radius: 0.0,
599        }
600    }
601
602    /// Object-local content extent — the `(width, height)` of the
603    /// minimal axis-aligned box that contains the shape's geometry
604    /// in its own coordinate system (before any [`Transform`] is
605    /// applied).
606    ///
607    /// - [`Shape::Rect`] reports its declared `(width, height)`
608    ///   verbatim. A rounded rect with `corner_radius > 0` still has
609    ///   the same outer bound; the rounding only carves area away
610    ///   *inside* the box.
611    /// - [`Shape::Polygon`] reports the bounding box of its `points`
612    ///   list. An empty polygon reports `(0.0, 0.0)`.
613    /// - [`Shape::Path`] is parsed by [`crate::svg_path::parse_bbox`]
614    ///   and reports the AABB of every anchor / control point. This is
615    ///   the convex-hull-of-control-points superset of the painted
616    ///   curve (an exact tight bound would walk the derivative roots);
617    ///   it is what scene-layer layout queries actually want. Returns
618    ///   `None` for empty / unparseable data.
619    ///
620    /// Stroke half-widths are NOT included; the bounds reflect the
621    /// filled geometry only. A rasteriser that needs the stroked
622    /// silhouette must inflate the result by `stroke.width / 2`.
623    pub fn content_size(&self) -> Option<(f32, f32)> {
624        match self {
625            Shape::Rect { width, height, .. } => Some((*width, *height)),
626            Shape::Polygon { points, .. } => {
627                if points.is_empty() {
628                    return Some((0.0, 0.0));
629                }
630                let (mut min_x, mut min_y) = points[0];
631                let (mut max_x, mut max_y) = (min_x, min_y);
632                for &(x, y) in &points[1..] {
633                    min_x = min_x.min(x);
634                    min_y = min_y.min(y);
635                    max_x = max_x.max(x);
636                    max_y = max_y.max(y);
637                }
638                Some(((max_x - min_x).max(0.0), (max_y - min_y).max(0.0)))
639            }
640            Shape::Path { data, .. } => {
641                crate::svg_path::parse_bbox(data).map(|(min_x, min_y, max_x, max_y)| {
642                    ((max_x - min_x).max(0.0), (max_y - min_y).max(0.0))
643                })
644            }
645        }
646    }
647}
648
649#[derive(Clone, Copy, Debug, PartialEq)]
650pub struct Stroke {
651    pub color: u32,
652    pub width: f32,
653}
654
655/// Opaque handle to a live input feed. The renderer polls it for
656/// the most recent frame at render time.
657#[derive(Clone, Debug)]
658pub struct LiveStreamHandle {
659    /// Implementation-defined URI — `rtmp://…`, `file://named-pipe`,
660    /// etc. The streaming compositor resolves this against a
661    /// pluggable `LiveSource` registry (pending crate).
662    pub uri: String,
663    /// Optional hint for the expected frame size. The renderer will
664    /// fall back to the actual frame size if it differs.
665    pub hint_size: Option<(u32, u32)>,
666}
667
668#[cfg(test)]
669mod tests {
670    use super::*;
671
672    #[test]
673    fn raster_canvas_size() {
674        let c = Canvas::raster(640, 480);
675        assert_eq!(c.raster_size(), Some((640, 480)));
676    }
677
678    #[test]
679    fn vector_canvas_no_raster_size() {
680        let c = Canvas::Vector {
681            width: 595.0,
682            height: 842.0,
683            unit: LengthUnit::Point,
684        };
685        assert!(c.raster_size().is_none());
686    }
687
688    #[test]
689    fn transform_identity_roundtrip() {
690        let t = Transform::identity();
691        assert_eq!(t.position, (0.0, 0.0));
692        assert_eq!(t.scale, (1.0, 1.0));
693        assert_eq!(t.anchor, (0.5, 0.5));
694    }
695
696    #[test]
697    fn scene_object_default_is_neutral() {
698        let o = SceneObject::default();
699        assert_eq!(o.opacity, 1.0);
700        assert_eq!(o.blend_mode, BlendMode::Normal);
701        assert!(o.animations.is_empty());
702    }
703
704    #[test]
705    fn identity_transform_lowers_to_identity_matrix() {
706        let m = Transform::identity().to_matrix(100.0, 50.0);
707        assert!(m.is_identity());
708    }
709
710    #[test]
711    fn translate_only_offsets_points() {
712        let t = Transform {
713            position: (10.0, -5.0),
714            ..Transform::identity()
715        };
716        // Pure translation is anchor-independent.
717        let p = t.apply_to_point(40.0, 40.0, oxideav_core::Point::new(3.0, 7.0));
718        assert!((p.x - 13.0).abs() < 1e-5);
719        assert!((p.y - 2.0).abs() < 1e-5);
720    }
721
722    #[test]
723    fn scale_pivots_about_anchor_centre() {
724        // Anchor at centre of a 20x20 box → pivot (10,10). 2x scale
725        // keeps the pivot fixed and pushes corners out symmetrically.
726        let t = Transform {
727            scale: (2.0, 2.0),
728            ..Transform::identity()
729        };
730        let centre = t.apply_to_point(20.0, 20.0, oxideav_core::Point::new(10.0, 10.0));
731        assert!((centre.x - 10.0).abs() < 1e-5);
732        assert!((centre.y - 10.0).abs() < 1e-5);
733        let bb = t.bbox(20.0, 20.0);
734        // 20x20 scaled 2x about centre → 40x40 centred on (10,10).
735        assert!((bb.width - 40.0).abs() < 1e-4);
736        assert!((bb.height - 40.0).abs() < 1e-4);
737        assert!((bb.x - (-10.0)).abs() < 1e-4);
738        assert!((bb.y - (-10.0)).abs() < 1e-4);
739    }
740
741    #[test]
742    fn quarter_turn_bbox_swaps_extent() {
743        // 90° rotation of a 40x10 box about its centre → AABB 10x40.
744        let t = Transform {
745            rotation: std::f32::consts::FRAC_PI_2,
746            ..Transform::identity()
747        };
748        let bb = t.bbox(40.0, 10.0);
749        assert!((bb.width - 10.0).abs() < 1e-3);
750        assert!((bb.height - 40.0).abs() < 1e-3);
751    }
752
753    #[test]
754    fn bbox_extent_is_never_negative() {
755        let t = Transform {
756            scale: (-3.0, 0.5),
757            rotation: 1.1,
758            skew: (0.3, -0.2),
759            position: (12.0, -4.0),
760            anchor: (0.25, 0.75),
761        };
762        let bb = t.bbox(30.0, 18.0);
763        assert!(bb.width >= 0.0);
764        assert!(bb.height >= 0.0);
765    }
766
767    #[test]
768    fn shape_rect_reports_its_own_extent() {
769        let s = Shape::Rect {
770            width: 80.0,
771            height: 30.0,
772            fill: 0,
773            stroke: None,
774            corner_radius: 4.0,
775        };
776        assert_eq!(s.content_size(), Some((80.0, 30.0)));
777    }
778
779    #[test]
780    fn shape_polygon_reports_aabb_of_points() {
781        let s = Shape::Polygon {
782            points: vec![(-3.0, 5.0), (10.0, -2.0), (7.0, 12.0)],
783            fill: 0,
784            stroke: None,
785        };
786        // x ∈ [-3, 10] → width 13. y ∈ [-2, 12] → height 14.
787        assert_eq!(s.content_size(), Some((13.0, 14.0)));
788    }
789
790    #[test]
791    fn empty_polygon_has_zero_extent() {
792        let s = Shape::Polygon {
793            points: Vec::new(),
794            fill: 0,
795            stroke: None,
796        };
797        assert_eq!(s.content_size(), Some((0.0, 0.0)));
798    }
799
800    #[test]
801    fn shape_path_extent_is_parsed_aabb() {
802        let s = Shape::Path {
803            data: "M10,10 L20,20".to_string(),
804            fill: 0,
805            stroke: None,
806        };
807        // AABB of (10,10)..(20,20) → 10x10 extent.
808        assert_eq!(s.content_size(), Some((10.0, 10.0)));
809    }
810
811    #[test]
812    fn shape_path_unparseable_returns_none() {
813        let s = Shape::Path {
814            data: "totally-not-a-path".to_string(),
815            fill: 0,
816            stroke: None,
817        };
818        assert!(s.content_size().is_none());
819    }
820
821    #[test]
822    fn shape_path_arc_returns_none_for_now() {
823        // The svg_path parser deliberately rejects arc commands; the
824        // bbox query treats that as "no usable bound."
825        let s = Shape::Path {
826            data: "M0,0 A 5 5 0 0 0 10 10".to_string(),
827            fill: 0,
828            stroke: None,
829        };
830        assert!(s.content_size().is_none());
831    }
832
833    #[test]
834    fn live_kind_uses_hint_size_when_present() {
835        let live = ObjectKind::Live(LiveStreamHandle {
836            uri: "rtmp://x".into(),
837            hint_size: Some((1280, 720)),
838        });
839        assert_eq!(live.content_size(), Some((1280.0, 720.0)));
840        let live_blank = ObjectKind::Live(LiveStreamHandle {
841            uri: "rtmp://x".into(),
842            hint_size: None,
843        });
844        assert!(live_blank.content_size().is_none());
845    }
846
847    #[test]
848    fn vector_kind_pulls_extent_from_frame_viewport() {
849        let vf = oxideav_core::VectorFrame::new(640.0, 480.0);
850        let k = ObjectKind::Vector(vf);
851        assert_eq!(k.content_size(), Some((640.0, 480.0)));
852    }
853
854    #[test]
855    fn image_video_text_group_have_no_intrinsic_extent() {
856        assert!(ObjectKind::Text(TextRun::default())
857            .content_size()
858            .is_none());
859        assert!(ObjectKind::Group(Vec::new()).content_size().is_none());
860    }
861
862    #[test]
863    fn scene_object_bbox_uses_intrinsic_extent() {
864        let obj = SceneObject {
865            kind: ObjectKind::Shape(Shape::Rect {
866                width: 40.0,
867                height: 20.0,
868                fill: 0,
869                stroke: None,
870                corner_radius: 0.0,
871            }),
872            transform: Transform {
873                position: (5.0, 7.0),
874                ..Transform::identity()
875            },
876            ..SceneObject::default()
877        };
878        // Fallback is ignored: the shape supplies its own (40, 20).
879        let bb = obj.bbox((1000.0, 1000.0));
880        assert!((bb.x - 5.0).abs() < 1e-4);
881        assert!((bb.y - 7.0).abs() < 1e-4);
882        assert!((bb.width - 40.0).abs() < 1e-4);
883        assert!((bb.height - 20.0).abs() < 1e-4);
884    }
885
886    #[test]
887    fn scene_object_bbox_falls_back_for_extentless_kinds() {
888        let obj = SceneObject {
889            kind: ObjectKind::Text(TextRun::default()),
890            transform: Transform {
891                position: (10.0, 20.0),
892                ..Transform::identity()
893            },
894            ..SceneObject::default()
895        };
896        let bb = obj.bbox((100.0, 50.0));
897        assert!((bb.x - 10.0).abs() < 1e-4);
898        assert!((bb.y - 20.0).abs() < 1e-4);
899        assert!((bb.width - 100.0).abs() < 1e-4);
900        assert!((bb.height - 50.0).abs() < 1e-4);
901    }
902
903    #[test]
904    fn scene_object_bbox_clips_to_clip_rect() {
905        let obj = SceneObject {
906            kind: ObjectKind::Shape(Shape::Rect {
907                width: 100.0,
908                height: 100.0,
909                fill: 0,
910                stroke: None,
911                corner_radius: 0.0,
912            }),
913            transform: Transform::identity(),
914            clip: Some(ClipRect {
915                x: 20.0,
916                y: 30.0,
917                width: 50.0,
918                height: 40.0,
919            }),
920            ..SceneObject::default()
921        };
922        let bb = obj.bbox((0.0, 0.0));
923        assert!((bb.x - 20.0).abs() < 1e-4);
924        assert!((bb.y - 30.0).abs() < 1e-4);
925        assert!((bb.width - 50.0).abs() < 1e-4);
926        assert!((bb.height - 40.0).abs() < 1e-4);
927    }
928
929    #[test]
930    fn scene_object_bbox_clip_with_no_overlap_collapses_to_zero() {
931        let obj = SceneObject {
932            kind: ObjectKind::Shape(Shape::Rect {
933                width: 10.0,
934                height: 10.0,
935                fill: 0,
936                stroke: None,
937                corner_radius: 0.0,
938            }),
939            transform: Transform::identity(),
940            clip: Some(ClipRect {
941                x: 500.0,
942                y: 500.0,
943                width: 50.0,
944                height: 50.0,
945            }),
946            ..SceneObject::default()
947        };
948        let bb = obj.bbox((0.0, 0.0));
949        assert!(bb.width <= 0.0 || bb.height <= 0.0);
950    }
951
952    // ----- effective_transform_at / effective_opacity_at / sample_at -----
953
954    use crate::animation::{
955        AnimatedProperty as P, Animation, Easing, Keyframe, KeyframeValue as V, Repeat,
956    };
957
958    fn scalar_anim(prop: P, kf: &[(crate::duration::TimeStamp, f32)]) -> Animation {
959        Animation::new(
960            prop,
961            kf.iter()
962                .map(|(t, v)| Keyframe {
963                    time: *t,
964                    value: V::Scalar(*v),
965                    easing: None,
966                })
967                .collect(),
968            Easing::Linear,
969            Repeat::Once,
970        )
971    }
972
973    fn vec2_anim(prop: P, kf: &[(crate::duration::TimeStamp, (f32, f32))]) -> Animation {
974        Animation::new(
975            prop,
976            kf.iter()
977                .map(|(t, (x, y))| Keyframe {
978                    time: *t,
979                    value: V::Vec2(*x, *y),
980                    easing: None,
981                })
982                .collect(),
983            Easing::Linear,
984            Repeat::Once,
985        )
986    }
987
988    #[test]
989    fn evaluate_property_at_returns_none_without_track() {
990        let obj = SceneObject::default();
991        assert!(obj.evaluate_property_at(0, &P::Opacity).is_none());
992    }
993
994    #[test]
995    fn evaluate_property_at_returns_raw_keyframe_value() {
996        let obj = SceneObject {
997            animations: vec![scalar_anim(P::Opacity, &[(0, 0.0), (100, 1.0)])],
998            ..SceneObject::default()
999        };
1000        let v = obj.evaluate_property_at(50, &P::Opacity).unwrap();
1001        match v {
1002            V::Scalar(s) => assert!((s - 0.5).abs() < 1e-4),
1003            _ => panic!("wrong variant"),
1004        }
1005    }
1006
1007    #[test]
1008    fn effective_transform_with_no_animation_is_base() {
1009        let obj = SceneObject {
1010            transform: Transform {
1011                position: (10.0, 20.0),
1012                scale: (2.0, 3.0),
1013                rotation: 0.5,
1014                anchor: (0.25, 0.75),
1015                skew: (0.1, 0.2),
1016            },
1017            ..SceneObject::default()
1018        };
1019        assert_eq!(obj.effective_transform_at(123), obj.transform);
1020    }
1021
1022    #[test]
1023    fn position_track_adds_to_base() {
1024        let obj = SceneObject {
1025            transform: Transform {
1026                position: (5.0, 7.0),
1027                ..Transform::identity()
1028            },
1029            animations: vec![vec2_anim(
1030                P::Position,
1031                &[(0, (10.0, 20.0)), (100, (10.0, 20.0))],
1032            )],
1033            ..SceneObject::default()
1034        };
1035        let t = obj.effective_transform_at(50);
1036        assert!((t.position.0 - 15.0).abs() < 1e-4);
1037        assert!((t.position.1 - 27.0).abs() < 1e-4);
1038    }
1039
1040    #[test]
1041    fn scale_track_multiplies_with_base() {
1042        let obj = SceneObject {
1043            transform: Transform {
1044                scale: (2.0, 3.0),
1045                ..Transform::identity()
1046            },
1047            animations: vec![vec2_anim(P::Scale, &[(0, (1.5, 2.0)), (100, (1.5, 2.0))])],
1048            ..SceneObject::default()
1049        };
1050        let t = obj.effective_transform_at(50);
1051        assert!((t.scale.0 - 3.0).abs() < 1e-4);
1052        assert!((t.scale.1 - 6.0).abs() < 1e-4);
1053    }
1054
1055    #[test]
1056    fn rotation_track_adds_to_base() {
1057        let obj = SceneObject {
1058            transform: Transform {
1059                rotation: 1.0,
1060                ..Transform::identity()
1061            },
1062            animations: vec![scalar_anim(P::Rotation, &[(0, 0.5), (100, 0.5)])],
1063            ..SceneObject::default()
1064        };
1065        assert!((obj.effective_transform_at(50).rotation - 1.5).abs() < 1e-4);
1066    }
1067
1068    #[test]
1069    fn skew_track_adds_to_base() {
1070        let obj = SceneObject {
1071            transform: Transform {
1072                skew: (0.2, 0.3),
1073                ..Transform::identity()
1074            },
1075            animations: vec![vec2_anim(P::Skew, &[(0, (0.1, -0.1)), (100, (0.1, -0.1))])],
1076            ..SceneObject::default()
1077        };
1078        let t = obj.effective_transform_at(50);
1079        assert!((t.skew.0 - 0.3).abs() < 1e-4);
1080        assert!((t.skew.1 - 0.2).abs() < 1e-4);
1081    }
1082
1083    #[test]
1084    fn anchor_track_replaces_base() {
1085        let obj = SceneObject {
1086            transform: Transform {
1087                anchor: (0.5, 0.5),
1088                ..Transform::identity()
1089            },
1090            animations: vec![vec2_anim(
1091                P::Anchor,
1092                &[(0, (0.25, 0.75)), (100, (0.25, 0.75))],
1093            )],
1094            ..SceneObject::default()
1095        };
1096        let t = obj.effective_transform_at(50);
1097        assert!((t.anchor.0 - 0.25).abs() < 1e-4);
1098        assert!((t.anchor.1 - 0.75).abs() < 1e-4);
1099    }
1100
1101    #[test]
1102    fn variant_mismatch_on_transform_track_falls_through() {
1103        // Position expects Vec2; feeding it a Scalar is a no-op.
1104        let obj = SceneObject {
1105            transform: Transform {
1106                position: (3.0, 4.0),
1107                ..Transform::identity()
1108            },
1109            animations: vec![scalar_anim(P::Position, &[(0, 99.0), (100, 99.0)])],
1110            ..SceneObject::default()
1111        };
1112        let t = obj.effective_transform_at(50);
1113        assert!((t.position.0 - 3.0).abs() < 1e-4);
1114        assert!((t.position.1 - 4.0).abs() < 1e-4);
1115    }
1116
1117    #[test]
1118    fn effective_opacity_no_track_is_base() {
1119        let obj = SceneObject {
1120            opacity: 0.7,
1121            ..SceneObject::default()
1122        };
1123        assert!((obj.effective_opacity_at(0) - 0.7).abs() < 1e-4);
1124    }
1125
1126    #[test]
1127    fn effective_opacity_multiplies_and_clamps() {
1128        let obj = SceneObject {
1129            opacity: 0.8,
1130            animations: vec![scalar_anim(P::Opacity, &[(0, 0.5), (100, 0.5)])],
1131            ..SceneObject::default()
1132        };
1133        // 0.8 * 0.5 = 0.4
1134        assert!((obj.effective_opacity_at(50) - 0.4).abs() < 1e-4);
1135    }
1136
1137    #[test]
1138    fn effective_opacity_clamps_to_unit_range() {
1139        // Base 1.0 * animated 2.0 would be 2.0; should clamp to 1.0.
1140        let obj = SceneObject {
1141            opacity: 1.0,
1142            animations: vec![scalar_anim(P::Opacity, &[(0, 2.0), (100, 2.0)])],
1143            ..SceneObject::default()
1144        };
1145        assert!((obj.effective_opacity_at(50) - 1.0).abs() < 1e-4);
1146
1147        // Negative animated value would yield negative; should clamp to 0.0.
1148        let obj = SceneObject {
1149            opacity: 0.5,
1150            animations: vec![scalar_anim(P::Opacity, &[(0, -1.0), (100, -1.0)])],
1151            ..SceneObject::default()
1152        };
1153        assert!(obj.effective_opacity_at(50).abs() < 1e-4);
1154    }
1155
1156    #[test]
1157    fn sample_at_forwards_compositor_fields() {
1158        let obj = SceneObject {
1159            id: ObjectId::new(42),
1160            opacity: 0.5,
1161            z_order: 7,
1162            blend_mode: BlendMode::Screen,
1163            clip: Some(ClipRect {
1164                x: 1.0,
1165                y: 2.0,
1166                width: 3.0,
1167                height: 4.0,
1168            }),
1169            transform: Transform {
1170                position: (10.0, 20.0),
1171                ..Transform::identity()
1172            },
1173            animations: vec![scalar_anim(P::Opacity, &[(0, 0.5), (100, 0.5)])],
1174            ..SceneObject::default()
1175        };
1176        let s = obj.sample_at(50);
1177        assert_eq!(s.id, ObjectId::new(42));
1178        assert_eq!(s.z_order, 7);
1179        assert_eq!(s.blend_mode, BlendMode::Screen);
1180        assert!(s.clip.is_some());
1181        assert!((s.opacity - 0.25).abs() < 1e-4); // 0.5 * 0.5
1182        assert!((s.transform.position.0 - 10.0).abs() < 1e-4);
1183    }
1184
1185    #[test]
1186    fn multiple_transform_tracks_compose_independently() {
1187        let obj = SceneObject {
1188            transform: Transform {
1189                position: (1.0, 1.0),
1190                scale: (1.0, 1.0),
1191                rotation: 0.1,
1192                ..Transform::identity()
1193            },
1194            animations: vec![
1195                vec2_anim(P::Position, &[(0, (4.0, 5.0)), (100, (4.0, 5.0))]),
1196                scalar_anim(P::Rotation, &[(0, 0.4), (100, 0.4)]),
1197                vec2_anim(P::Scale, &[(0, (3.0, 4.0)), (100, (3.0, 4.0))]),
1198            ],
1199            ..SceneObject::default()
1200        };
1201        let t = obj.effective_transform_at(50);
1202        assert!((t.position.0 - 5.0).abs() < 1e-4); // 1+4
1203        assert!((t.position.1 - 6.0).abs() < 1e-4); // 1+5
1204        assert!((t.scale.0 - 3.0).abs() < 1e-4); // 1*3
1205        assert!((t.scale.1 - 4.0).abs() < 1e-4); // 1*4
1206        assert!((t.rotation - 0.5).abs() < 1e-4); // 0.1+0.4
1207    }
1208}