1use 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#[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 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#[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#[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}