Skip to main content

dioxuscut_player/
native_preview.rs

1//! Dioxus preview adapter for the same native scenes used during export.
2
3use crate::player::PlayerPlaybackState;
4use dioxus::prelude::*;
5use dioxuscut_composition::{Composition, CompositionError, NativeCompositionContext};
6use dioxuscut_core::use_current_frame;
7use dioxuscut_rasterizer::{
8    AudioTrack, BlendMode, ClipRegion, Color, GradientStop, ImageFit, MaskMode, Scene, SceneFilter,
9    SceneNode, SceneShadow, Transform2D,
10};
11use serde_json::Value;
12use std::collections::{HashMap, HashSet};
13use std::hash::{DefaultHasher, Hash, Hasher};
14use std::sync::atomic::{AtomicU64, Ordering};
15use std::sync::Arc;
16
17static NEXT_SCENE_VIEW_ID: AtomicU64 = AtomicU64::new(1);
18
19/// Cloneable handle for passing a native composition through Dioxus props.
20#[derive(Clone)]
21pub struct CompositionHandle(Arc<dyn Composition>);
22
23impl CompositionHandle {
24    pub fn new<C>(composition: C) -> Self
25    where
26        C: Composition + 'static,
27    {
28        Self(Arc::new(composition))
29    }
30
31    pub fn id(&self) -> &str {
32        self.0.id()
33    }
34
35    /// Prepare and render one frame through the shared export contract.
36    pub fn render_frame(
37        &self,
38        frame: u32,
39        input_props: &Value,
40        context: NativeCompositionContext,
41    ) -> Result<Scene, CompositionError> {
42        self.0.prepare(input_props, context)?.render(frame)
43    }
44}
45
46impl PartialEq for CompositionHandle {
47    fn eq(&self, other: &Self) -> bool {
48        Arc::ptr_eq(&self.0, &other.0)
49    }
50}
51
52#[derive(Props, Clone, PartialEq)]
53pub struct NativeCompositionPreviewProps {
54    pub composition: CompositionHandle,
55    #[props(default = Value::Object(Default::default()))]
56    pub input_props: Value,
57    pub width: u32,
58    pub height: u32,
59    pub fps: f64,
60    pub duration_in_frames: u32,
61}
62
63/// Renders the current Player frame using the same `Composition -> Scene` path as export.
64#[component]
65pub fn NativeCompositionPreview(props: NativeCompositionPreviewProps) -> Element {
66    let frame = use_current_frame();
67    let playback = try_consume_context::<Signal<PlayerPlaybackState>>()
68        .map(|state| *state.read())
69        .unwrap_or(PlayerPlaybackState {
70            playing: false,
71            seek_revision: 0,
72        });
73    let context = NativeCompositionContext {
74        width: props.width,
75        height: props.height,
76        fps: props.fps,
77        duration_in_frames: props.duration_in_frames,
78    };
79
80    match props
81        .composition
82        .render_frame(frame, &props.input_props, context)
83    {
84        Ok(scene) => rsx! {
85            SceneView {
86                scene,
87                width: props.width,
88                height: props.height,
89                frame,
90                fps: props.fps,
91                playing: playback.playing,
92                seek_revision: playback.seek_revision,
93            }
94        },
95        Err(error) => rsx! {
96            div {
97                class: "dioxuscut-native-preview-error",
98                style: "box-sizing: border-box; width: 100%; height: 100%; padding: 24px; background: #220d16; color: #fecdd3; font-family: monospace; white-space: pre-wrap;",
99                "Failed to preview composition '{props.composition.id()}' at frame {frame}: {error}"
100            }
101        },
102    }
103}
104
105#[derive(Props, Clone, PartialEq)]
106pub struct SceneViewProps {
107    pub scene: Scene,
108    pub width: u32,
109    pub height: u32,
110    #[props(default = 0)]
111    pub frame: u32,
112    #[props(default = 30.0)]
113    pub fps: f64,
114    #[props(default = false)]
115    pub playing: bool,
116    #[props(default = 0)]
117    pub seek_revision: u64,
118}
119
120/// Converts the shared native scene graph to SVG for Dioxus web/desktop preview.
121#[component]
122pub fn SceneView(props: SceneViewProps) -> Element {
123    let instance_id = use_hook(|| NEXT_SCENE_VIEW_ID.fetch_add(1, Ordering::Relaxed));
124    let media_status = use_context_provider(|| Signal::new(MediaPreviewStatus::default()));
125    let view_box = format!("0 0 {} {}", props.width, props.height);
126    let root_id = format!("dioxuscut-scene-{instance_id}");
127    let safe_fps = safe_fps(props.fps);
128    let timeline_time = f64::from(props.frame) / safe_fps;
129    let scene_signature = media_signature(&props.scene);
130    let status = media_status.read();
131    let errors = status.errors.values().cloned().collect::<Vec<_>>();
132    let buffering_count = status.buffering.len();
133    let media_event_revision = status.revision;
134    drop(status);
135    let sync_script = build_media_sync_script(
136        &root_id,
137        props.playing,
138        props.seek_revision,
139        scene_signature,
140        safe_fps,
141        media_event_revision,
142    );
143    use_effect(use_reactive!(|(sync_script,)| {
144        let _ = dioxus::document::eval(&sync_script);
145    }));
146
147    rsx! {
148        div {
149            class: "dioxuscut-scene-view-container",
150            width: "100%",
151            height: "100%",
152            style: "position: relative; display: block; overflow: hidden;",
153            svg {
154                id: root_id,
155                class: "dioxuscut-scene-view",
156                view_box,
157                width: "100%",
158                height: "100%",
159                style: "display: block; overflow: hidden;",
160                xmlns: "http://www.w3.org/2000/svg",
161                for (index, node) in props.scene.nodes.into_iter().enumerate() {
162                    SceneNodeView {
163                        key: "root-{index}",
164                        node,
165                        node_path: format!("scene-{instance_id}-root-{index}"),
166                        timeline_time,
167                        inherited_volume: 1.0,
168                    }
169                }
170            }
171            if buffering_count > 0 && props.playing {
172                div {
173                    class: "dioxuscut-media-buffering",
174                    style: "position: absolute; left: 12px; bottom: 12px; padding: 5px 8px; border-radius: 5px; background: rgba(0,0,0,0.72); color: white; font: 12px sans-serif; pointer-events: none;",
175                    "Buffering {buffering_count} media source(s)…"
176                }
177            }
178            if !errors.is_empty() {
179                div {
180                    class: "dioxuscut-media-error",
181                    style: "position: absolute; inset: auto 12px 12px 12px; padding: 8px 10px; border-radius: 5px; background: rgba(69,10,10,0.92); color: #fecaca; font: 12px monospace; white-space: pre-wrap; pointer-events: none;",
182                    for error in errors {
183                        div { "{error}" }
184                    }
185                }
186            }
187        }
188    }
189}
190
191#[derive(Props, Clone, PartialEq)]
192struct SceneNodeViewProps {
193    node: SceneNode,
194    node_path: String,
195    timeline_time: f64,
196    inherited_volume: f64,
197}
198
199#[component]
200fn SceneNodeView(props: SceneNodeViewProps) -> Element {
201    let media_status = use_context::<Signal<MediaPreviewStatus>>();
202    match props.node {
203        SceneNode::Rect {
204            x,
205            y,
206            w,
207            h,
208            fill,
209            stroke,
210            stroke_width,
211            corner_radius,
212        } => {
213            let fill = color_css(fill);
214            let stroke = optional_color_css(stroke);
215            rsx! {
216                rect {
217                    x,
218                    y,
219                    width: w,
220                    height: h,
221                    rx: corner_radius,
222                    ry: corner_radius,
223                    fill,
224                    stroke,
225                    stroke_width,
226                }
227            }
228        }
229        SceneNode::Circle {
230            cx,
231            cy,
232            r,
233            fill,
234            stroke,
235            stroke_width,
236        } => {
237            let fill = color_css(fill);
238            let stroke = optional_color_css(stroke);
239            rsx! {
240                circle { cx, cy, r, fill, stroke, stroke_width }
241            }
242        }
243        SceneNode::Path {
244            d,
245            fill,
246            stroke,
247            stroke_width,
248            opacity,
249        } => {
250            let fill = optional_color_css(fill);
251            let stroke = optional_color_css(stroke);
252            rsx! {
253                path { d, fill, stroke, stroke_width, opacity }
254            }
255        }
256        SceneNode::Text {
257            x,
258            y,
259            content,
260            font_size,
261            color,
262            font_weight,
263            font_sources,
264        } => {
265            let fill = color_css(color);
266            let (font_face_css, font_family) =
267                text_font_css(&props.node_path, &font_sources, font_weight);
268            rsx! {
269                if !font_face_css.is_empty() {
270                    defs { style { "{font_face_css}" } }
271                }
272                text {
273                    x,
274                    y,
275                    fill,
276                    font_size,
277                    font_weight,
278                    font_family,
279                    dominant_baseline: "alphabetic",
280                    "{content}"
281                }
282            }
283        }
284        SceneNode::Image {
285            src,
286            x,
287            y,
288            w,
289            h,
290            fit,
291            opacity,
292        } => {
293            let preserve_aspect_ratio = image_preserve_aspect_ratio(fit);
294            rsx! {
295                image {
296                    x,
297                    y,
298                    width: w,
299                    height: h,
300                    href: src,
301                    opacity,
302                    preserve_aspect_ratio,
303                }
304            }
305        }
306        SceneNode::Video {
307            src,
308            time,
309            looped,
310            x,
311            y,
312            w,
313            h,
314            fit,
315            opacity,
316        } => {
317            let media_id = format!("dioxuscut-media-{}", props.node_path);
318            let style = format!(
319                "display:block;width:100%;height:100%;object-fit:{};opacity:{};",
320                media_object_fit(fit),
321                opacity.clamp(0.0, 1.0)
322            );
323            let loading_id = media_id.clone();
324            let waiting_id = media_id.clone();
325            let ready_id = media_id.clone();
326            let error_id = media_id.clone();
327            let error_src = src.clone();
328            let loading_status = media_status;
329            let waiting_status = media_status;
330            let ready_status = media_status;
331            let error_status = media_status;
332            rsx! {
333                foreignObject { x, y, width: w, height: h,
334                    video {
335                        id: media_id,
336                        src,
337                        style,
338                        muted: true,
339                        r#loop: looped,
340                        preload: "auto",
341                        "data-dioxuscut-media": "video",
342                        "data-dioxuscut-time": time,
343                        "data-dioxuscut-active": "true",
344                        "data-dioxuscut-volume": "0",
345                        "data-dioxuscut-rate": "1",
346                        "data-dioxuscut-loop": looped,
347                        onloadstart: move |_| mark_media_loading(loading_status, &loading_id),
348                        onwaiting: move |_| mark_media_loading(waiting_status, &waiting_id),
349                        oncanplay: move |_| mark_media_ready(ready_status, &ready_id),
350                        onerror: move |_| mark_media_error(error_status, &error_id, &error_src),
351                    }
352                }
353            }
354        }
355        SceneNode::Audio { track } => {
356            let media_id = format!("dioxuscut-media-{}", props.node_path);
357            let (time, active) = audio_preview_timing(&track, props.timeline_time);
358            let volume = (track.volume * props.inherited_volume).clamp(0.0, 1.0);
359            let loading_id = media_id.clone();
360            let waiting_id = media_id.clone();
361            let ready_id = media_id.clone();
362            let error_id = media_id.clone();
363            let error_src = track.src.clone();
364            let loading_status = media_status;
365            let waiting_status = media_status;
366            let ready_status = media_status;
367            let error_status = media_status;
368            rsx! {
369                foreignObject { x: 0, y: 0, width: 1, height: 1,
370                    audio {
371                        id: media_id,
372                        src: track.src,
373                        preload: "auto",
374                        style: "display:none;",
375                        "data-dioxuscut-media": "audio",
376                        "data-dioxuscut-time": time,
377                        "data-dioxuscut-active": active,
378                        "data-dioxuscut-volume": volume,
379                        "data-dioxuscut-rate": track.playback_rate,
380                        "data-dioxuscut-loop": track.looped,
381                        onloadstart: move |_| mark_media_loading(loading_status, &loading_id),
382                        onwaiting: move |_| mark_media_loading(waiting_status, &waiting_id),
383                        oncanplay: move |_| mark_media_ready(ready_status, &ready_id),
384                        onerror: move |_| mark_media_error(error_status, &error_id, &error_src),
385                    }
386                }
387            }
388        }
389        SceneNode::LinearGradient {
390            x,
391            y,
392            w,
393            h,
394            angle_deg,
395            stops,
396        } => {
397            let gradient_id = format!("dioxuscut-linear-{}", props.node_path);
398            let center_x = x + w / 2.0;
399            let center_y = y + h / 2.0;
400            let gradient_transform = format!("rotate({angle_deg} {center_x} {center_y})");
401            let fill = format!("url(#{gradient_id})");
402            rsx! {
403                defs {
404                    linearGradient {
405                        id: gradient_id,
406                        gradient_units: "userSpaceOnUse",
407                        x1: x,
408                        y1: center_y,
409                        x2: x + w,
410                        y2: center_y,
411                        gradient_transform,
412                        for (index, stop) in stops.into_iter().enumerate() {
413                            GradientStopView { key: "stop-{index}", stop }
414                        }
415                    }
416                }
417                rect { x, y, width: w, height: h, fill }
418            }
419        }
420        SceneNode::RadialGradient { cx, cy, r, stops } => {
421            let gradient_id = format!("dioxuscut-radial-{}", props.node_path);
422            let fill = format!("url(#{gradient_id})");
423            rsx! {
424                defs {
425                    radialGradient {
426                        id: gradient_id,
427                        gradient_units: "userSpaceOnUse",
428                        cx,
429                        cy,
430                        r,
431                        for (index, stop) in stops.into_iter().enumerate() {
432                            GradientStopView { key: "stop-{index}", stop }
433                        }
434                    }
435                }
436                circle { cx, cy, r, fill }
437            }
438        }
439        SceneNode::Group {
440            transform,
441            opacity,
442            children,
443        } => {
444            let transform = transform_css(transform);
445            let inherited_volume = props.inherited_volume * f64::from(opacity);
446            rsx! {
447                g {
448                    transform,
449                    opacity,
450                    for (index, node) in children.into_iter().enumerate() {
451                        SceneNodeView {
452                            key: "child-{index}",
453                            node,
454                            node_path: format!("{}-{index}", props.node_path),
455                            timeline_time: props.timeline_time,
456                            inherited_volume,
457                        }
458                    }
459                }
460            }
461        }
462        SceneNode::Layer {
463            opacity,
464            blend_mode,
465            clip,
466            mask,
467            mask_mode,
468            filters,
469            shadow,
470            children,
471        } => {
472            let clip_id = format!("dioxuscut-clip-{}", props.node_path);
473            let mask_id = format!("dioxuscut-mask-{}", props.node_path);
474            let mut style = format!("mix-blend-mode:{};", blend_mode_css(blend_mode));
475            if clip.is_some() {
476                style.push_str(&format!("clip-path:url(#{clip_id});"));
477            }
478            if mask.is_some() {
479                style.push_str(&format!("mask:url(#{mask_id});"));
480            }
481            let filter = layer_filter_css(&filters, shadow.as_ref());
482            if !filter.is_empty() {
483                style.push_str(&format!("filter:{filter};"));
484            }
485            let inherited_volume = props.inherited_volume * f64::from(opacity);
486            rsx! {
487                if let Some(clip) = clip {
488                    LayerClipView { id: clip_id, clip }
489                }
490                if let Some(nodes) = mask {
491                    LayerMaskView {
492                        id: mask_id,
493                        mode: mask_mode,
494                        nodes,
495                        node_path: format!("{}-mask", props.node_path),
496                        timeline_time: props.timeline_time,
497                        inherited_volume,
498                    }
499                }
500                g {
501                    opacity,
502                    style,
503                    for (index, node) in children.into_iter().enumerate() {
504                        SceneNodeView {
505                            key: "layer-child-{index}",
506                            node,
507                            node_path: format!("{}-{index}", props.node_path),
508                            timeline_time: props.timeline_time,
509                            inherited_volume,
510                        }
511                    }
512                }
513            }
514        }
515    }
516}
517
518#[derive(Props, Clone, PartialEq)]
519struct LayerClipViewProps {
520    id: String,
521    clip: ClipRegion,
522}
523
524#[component]
525fn LayerClipView(props: LayerClipViewProps) -> Element {
526    rsx! {
527        defs {
528            clipPath {
529                id: props.id,
530                clip_path_units: "userSpaceOnUse",
531                match props.clip {
532                    ClipRegion::Rect { x, y, w, h, corner_radius } => rsx! {
533                        rect { x, y, width: w, height: h, rx: corner_radius, ry: corner_radius }
534                    },
535                    ClipRegion::Path { d } => rsx! { path { d } },
536                }
537            }
538        }
539    }
540}
541
542#[derive(Props, Clone, PartialEq)]
543struct LayerMaskViewProps {
544    id: String,
545    mode: MaskMode,
546    nodes: Vec<SceneNode>,
547    node_path: String,
548    timeline_time: f64,
549    inherited_volume: f64,
550}
551
552#[component]
553fn LayerMaskView(props: LayerMaskViewProps) -> Element {
554    let style = match props.mode {
555        MaskMode::Alpha => "mask-type:alpha;",
556        MaskMode::Luminance => "mask-type:luminance;",
557    };
558    rsx! {
559        defs {
560            mask {
561                id: props.id,
562                mask_units: "userSpaceOnUse",
563                x: "0%",
564                y: "0%",
565                width: "100%",
566                height: "100%",
567                style,
568                for (index, node) in props.nodes.into_iter().enumerate() {
569                    SceneNodeView {
570                        key: "mask-child-{index}",
571                        node,
572                        node_path: format!("{}-{index}", props.node_path),
573                        timeline_time: props.timeline_time,
574                        inherited_volume: props.inherited_volume,
575                    }
576                }
577            }
578        }
579    }
580}
581
582#[derive(Props, Clone, PartialEq)]
583struct GradientStopViewProps {
584    stop: GradientStop,
585}
586
587#[component]
588fn GradientStopView(props: GradientStopViewProps) -> Element {
589    let offset = format!("{}%", props.stop.position.clamp(0.0, 1.0) * 100.0);
590    let stop_color = color_css(props.stop.color);
591    rsx! { stop { offset, stop_color } }
592}
593
594fn color_css(color: Color) -> String {
595    format!(
596        "rgba({}, {}, {}, {:.6})",
597        color.r,
598        color.g,
599        color.b,
600        color.a as f64 / 255.0
601    )
602}
603
604fn text_font_css(node_path: &str, sources: &[String], font_weight: u16) -> (String, String) {
605    if sources.is_empty() {
606        return (String::new(), "sans-serif".into());
607    }
608    let mut declarations = String::new();
609    let mut families = Vec::with_capacity(sources.len() + 1);
610    for (index, source) in sources.iter().enumerate() {
611        let family = format!("dioxuscut-font-{node_path}-{index}");
612        let url = browser_font_url(source)
613            .replace('\\', "\\\\")
614            .replace('\'', "\\'")
615            .replace(['\r', '\n'], "");
616        declarations.push_str(&format!(
617            "@font-face{{font-family:'{family}';src:url('{url}');font-weight:{font_weight};}}"
618        ));
619        families.push(format!("'{family}'"));
620    }
621    families.push("sans-serif".into());
622    (declarations, families.join(", "))
623}
624
625fn browser_font_url(source: &str) -> String {
626    if source.contains("://") {
627        source.into()
628    } else if source.starts_with('/') {
629        format!("file://{source}")
630    } else if source.as_bytes().get(1) == Some(&b':') {
631        format!("file:///{}", source.replace('\\', "/"))
632    } else {
633        source.into()
634    }
635}
636
637fn blend_mode_css(mode: BlendMode) -> &'static str {
638    match mode {
639        BlendMode::Normal => "normal",
640        BlendMode::Multiply => "multiply",
641        BlendMode::Screen => "screen",
642        BlendMode::Overlay => "overlay",
643        BlendMode::Darken => "darken",
644        BlendMode::Lighten => "lighten",
645        BlendMode::ColorDodge => "color-dodge",
646        BlendMode::ColorBurn => "color-burn",
647        BlendMode::HardLight => "hard-light",
648        BlendMode::SoftLight => "soft-light",
649        BlendMode::Difference => "difference",
650        BlendMode::Exclusion => "exclusion",
651    }
652}
653
654fn layer_filter_css(filters: &[SceneFilter], shadow: Option<&SceneShadow>) -> String {
655    let mut values = filters
656        .iter()
657        .map(|filter| match filter {
658            SceneFilter::Blur { sigma } => format!("blur({}px)", sigma.max(0.0)),
659            SceneFilter::Brightness { amount } => format!("brightness({})", amount.max(0.0)),
660            SceneFilter::Grayscale { amount } => {
661                format!("grayscale({})", amount.clamp(0.0, 1.0))
662            }
663            SceneFilter::Opacity { amount } => format!("opacity({})", amount.clamp(0.0, 1.0)),
664        })
665        .collect::<Vec<_>>();
666    if let Some(shadow) = shadow {
667        values.push(format!(
668            "drop-shadow({}px {}px {}px {})",
669            shadow.offset_x,
670            shadow.offset_y,
671            shadow.blur_sigma.max(0.0),
672            color_css(shadow.color)
673        ));
674    }
675    values.join(" ")
676}
677
678fn optional_color_css(color: Option<Color>) -> String {
679    color.map(color_css).unwrap_or_else(|| "none".to_string())
680}
681
682fn transform_css(transform: Transform2D) -> String {
683    format!(
684        "translate({} {}) scale({} {}) rotate({})",
685        transform.tx, transform.ty, transform.scale_x, transform.scale_y, transform.rotate_deg
686    )
687}
688
689fn image_preserve_aspect_ratio(fit: ImageFit) -> &'static str {
690    match fit {
691        ImageFit::Cover => "xMidYMid slice",
692        ImageFit::Contain | ImageFit::ScaleDown => "xMidYMid meet",
693        ImageFit::Fill => "none",
694        ImageFit::None => "xMidYMid meet",
695    }
696}
697
698fn media_object_fit(fit: ImageFit) -> &'static str {
699    match fit {
700        ImageFit::Cover => "cover",
701        ImageFit::Contain => "contain",
702        ImageFit::Fill => "fill",
703        ImageFit::None => "none",
704        ImageFit::ScaleDown => "scale-down",
705    }
706}
707
708#[derive(Clone, Debug, Default, PartialEq)]
709struct MediaPreviewStatus {
710    buffering: HashSet<String>,
711    errors: HashMap<String, String>,
712    revision: u64,
713}
714
715fn mark_media_loading(mut status: Signal<MediaPreviewStatus>, id: &str) {
716    let mut status = status.write();
717    status.buffering.insert(id.to_string());
718    status.errors.remove(id);
719    status.revision = status.revision.wrapping_add(1);
720}
721
722fn mark_media_ready(mut status: Signal<MediaPreviewStatus>, id: &str) {
723    let mut status = status.write();
724    status.buffering.remove(id);
725    status.errors.remove(id);
726    status.revision = status.revision.wrapping_add(1);
727}
728
729fn mark_media_error(mut status: Signal<MediaPreviewStatus>, id: &str, src: &str) {
730    let mut status = status.write();
731    status.buffering.remove(id);
732    status
733        .errors
734        .insert(id.to_string(), format!("Failed to load media: {src}"));
735    status.revision = status.revision.wrapping_add(1);
736}
737
738fn safe_fps(fps: f64) -> f64 {
739    if fps.is_finite() && fps > 0.0 {
740        fps
741    } else {
742        30.0
743    }
744}
745
746fn audio_preview_timing(track: &AudioTrack, timeline_time: f64) -> (f64, bool) {
747    let elapsed = timeline_time - track.timeline_start;
748    let active = elapsed >= 0.0
749        && track
750            .duration
751            .is_none_or(|duration| elapsed < duration.max(0.0));
752    let playback_rate = if track.playback_rate.is_finite() && track.playback_rate > 0.0 {
753        track.playback_rate
754    } else {
755        1.0
756    };
757    let source_time = track.start_from.max(0.0) + elapsed.max(0.0) * playback_rate;
758    (source_time, active)
759}
760
761fn media_signature(scene: &Scene) -> u64 {
762    fn hash_nodes(nodes: &[SceneNode], inherited_volume: f64, hasher: &mut DefaultHasher) {
763        for node in nodes {
764            match node {
765                SceneNode::Video { src, looped, .. } => {
766                    "video".hash(hasher);
767                    src.hash(hasher);
768                    looped.hash(hasher);
769                }
770                SceneNode::Audio { track } => {
771                    "audio".hash(hasher);
772                    track.src.hash(hasher);
773                    track.start_from.to_bits().hash(hasher);
774                    track.timeline_start.to_bits().hash(hasher);
775                    track.duration.map(f64::to_bits).hash(hasher);
776                    (track.volume * inherited_volume).to_bits().hash(hasher);
777                    track.playback_rate.to_bits().hash(hasher);
778                    track.looped.hash(hasher);
779                }
780                SceneNode::Group {
781                    opacity, children, ..
782                } => {
783                    "group".hash(hasher);
784                    opacity.to_bits().hash(hasher);
785                    hash_nodes(children, inherited_volume * f64::from(*opacity), hasher);
786                }
787                SceneNode::Layer {
788                    opacity, children, ..
789                } => {
790                    "layer".hash(hasher);
791                    opacity.to_bits().hash(hasher);
792                    hash_nodes(children, inherited_volume * f64::from(*opacity), hasher);
793                }
794                _ => {}
795            }
796        }
797    }
798
799    let mut hasher = DefaultHasher::new();
800    hash_nodes(&scene.nodes, 1.0, &mut hasher);
801    hasher.finish()
802}
803
804fn build_media_sync_script(
805    root_id: &str,
806    playing: bool,
807    seek_revision: u64,
808    scene_signature: u64,
809    fps: f64,
810    media_event_revision: u64,
811) -> String {
812    let root_id = serde_json::to_string(root_id).expect("element ID is JSON serializable");
813    let revision = serde_json::to_string(&format!("{seek_revision}-{scene_signature}"))
814        .expect("revision is JSON serializable");
815    let drift_threshold = (1.5 / safe_fps(fps)).max(0.04);
816    format!(
817        r#"(() => {{
818  const root = document.getElementById({root_id});
819  if (!root) return;
820  const playing = {playing};
821  const revision = {revision};
822  const mediaEventRevision = {media_event_revision};
823  void mediaEventRevision;
824  const driftThreshold = {drift_threshold:.9};
825  for (const media of root.querySelectorAll('[data-dioxuscut-media]')) {{
826    let expected = Number(media.dataset.dioxuscutTime);
827    if (!Number.isFinite(expected)) continue;
828    const active = media.dataset.dioxuscutActive === 'true';
829    const looped = media.dataset.dioxuscutLoop === 'true';
830    const rate = Number(media.dataset.dioxuscutRate);
831    const volume = Number(media.dataset.dioxuscutVolume);
832    media.loop = looped;
833    media.playbackRate = Number.isFinite(rate) && rate > 0 ? rate : 1;
834    media.volume = Number.isFinite(volume) ? Math.min(1, Math.max(0, volume)) : 1;
835    if (Number.isFinite(media.duration) && media.duration > 0) {{
836      expected = looped
837        ? ((expected % media.duration) + media.duration) % media.duration
838        : Math.min(expected, Math.max(0, media.duration - 0.001));
839    }}
840    const hardSeek = !playing || media.dataset.dioxuscutRevision !== revision;
841    const drifted = !Number.isFinite(media.currentTime)
842      || Math.abs(media.currentTime - expected) > driftThreshold;
843    if ((hardSeek || drifted) && Number.isFinite(expected)) {{
844      try {{ media.currentTime = Math.max(0, expected); }} catch (_) {{}}
845    }}
846    media.dataset.dioxuscutRevision = revision;
847    if (playing && active && media.readyState >= 2) {{
848      const promise = media.play();
849      if (promise) promise.catch((error) => {{
850        media.dataset.dioxuscutPlayError = String(error);
851      }});
852    }} else {{
853      media.pause();
854    }}
855  }}
856}})();"#
857    )
858}
859
860#[cfg(test)]
861mod tests {
862    use super::*;
863    use dioxuscut_composition::HelloWorldComposition;
864
865    #[test]
866    fn composition_handle_renders_the_shared_scene() {
867        let composition = CompositionHandle::new(HelloWorldComposition);
868        let context = NativeCompositionContext {
869            width: 320,
870            height: 180,
871            fps: 30.0,
872            duration_in_frames: 30,
873        };
874        let scene = composition
875            .render_frame(10, &serde_json::json!({"title": "Shared preview"}), context)
876            .unwrap();
877
878        assert!(scene.nodes.iter().any(|node| matches!(
879            node,
880            SceneNode::Text { content, .. } if content == "Shared preview"
881        )));
882    }
883
884    #[test]
885    fn composition_handles_compare_by_identity() {
886        let first = CompositionHandle::new(HelloWorldComposition);
887        let clone = first.clone();
888        let second = CompositionHandle::new(HelloWorldComposition);
889
890        assert!(first == clone);
891        assert!(first != second);
892    }
893
894    #[test]
895    fn image_fit_maps_to_svg_aspect_ratio() {
896        assert_eq!(
897            image_preserve_aspect_ratio(ImageFit::Cover),
898            "xMidYMid slice"
899        );
900        assert_eq!(
901            image_preserve_aspect_ratio(ImageFit::Contain),
902            "xMidYMid meet"
903        );
904        assert_eq!(image_preserve_aspect_ratio(ImageFit::Fill), "none");
905        assert_eq!(media_object_fit(ImageFit::ScaleDown), "scale-down");
906    }
907
908    #[test]
909    fn layer_effects_map_to_svg_css() {
910        assert_eq!(blend_mode_css(BlendMode::ColorDodge), "color-dodge");
911        let filter = layer_filter_css(
912            &[
913                SceneFilter::Blur { sigma: 2.5 },
914                SceneFilter::Grayscale { amount: 0.75 },
915            ],
916            Some(&SceneShadow {
917                offset_x: 4.0,
918                offset_y: 5.0,
919                blur_sigma: 6.0,
920                color: Color::rgba(10, 20, 30, 128),
921            }),
922        );
923
924        assert!(filter.contains("blur(2.5px)"));
925        assert!(filter.contains("grayscale(0.75)"));
926        assert!(filter.contains("drop-shadow(4px 5px 6px rgba(10, 20, 30,"));
927    }
928
929    #[test]
930    fn explicit_fonts_map_to_ordered_font_faces() {
931        let (declarations, family) = text_font_css(
932            "node-2",
933            &["/assets/Primary.ttf".into(), "fonts/Fallback.otf".into()],
934            700,
935        );
936
937        assert!(declarations.contains("file:///assets/Primary.ttf"));
938        assert!(declarations.contains("url('fonts/Fallback.otf')"));
939        assert!(declarations.contains("font-weight:700"));
940        assert_eq!(
941            family,
942            "'dioxuscut-font-node-2-0', 'dioxuscut-font-node-2-1', sans-serif"
943        );
944    }
945
946    #[test]
947    fn audio_preview_respects_timeline_trim_and_rate() {
948        let track = AudioTrack {
949            src: "voice.wav".into(),
950            start_from: 1.5,
951            timeline_start: 2.0,
952            duration: Some(3.0),
953            volume: 0.75,
954            playback_rate: 1.25,
955            looped: false,
956        };
957
958        assert_eq!(audio_preview_timing(&track, 1.0), (1.5, false));
959        assert_eq!(audio_preview_timing(&track, 3.0), (2.75, true));
960        assert_eq!(audio_preview_timing(&track, 5.0), (5.25, false));
961    }
962
963    #[test]
964    fn media_signature_ignores_video_time_but_tracks_source_changes() {
965        let make_scene = |src: &str, time: f64| Scene {
966            nodes: vec![SceneNode::Video {
967                src: src.into(),
968                time,
969                looped: false,
970                x: 0.0,
971                y: 0.0,
972                w: 100.0,
973                h: 100.0,
974                fit: ImageFit::Cover,
975                opacity: 1.0,
976            }],
977        };
978
979        assert_eq!(
980            media_signature(&make_scene("clip.mp4", 0.0)),
981            media_signature(&make_scene("clip.mp4", 1.0))
982        );
983        assert_ne!(
984            media_signature(&make_scene("clip.mp4", 0.0)),
985            media_signature(&make_scene("other.mp4", 0.0))
986        );
987    }
988
989    #[test]
990    fn sync_script_contains_drift_and_transport_controls() {
991        let script = build_media_sync_script("scene-1", true, 7, 11, 30.0, 2);
992        assert!(script.contains("document.getElementById(\"scene-1\")"));
993        assert!(script.contains("Math.abs(media.currentTime - expected)"));
994        assert!(script.contains("media.playbackRate"));
995        assert!(script.contains("media.volume"));
996        assert!(script.contains("media.play()"));
997    }
998}