Skip to main content

moss_core/render/
video.rs

1//! Video embed synthesizer.
2//!
3//! Receives a [`TitleParams`] (Stage 2 dispatcher already parsed it), the
4//! source URL, and an [`AssetSnapshot`]. Emits final `<video>` HTML at the
5//! typed-data boundary — including every attribute the legacy
6//! `add_video_placeholder_attributes` regex used to inject.
7//!
8//! # Authoritative byte shape (Phase 2E parity)
9//!
10//! ```html
11//! <video class="moss-embed moss-embed-video" src="URL.mp4"
12//!        data-placeholder-src="URL.original" poster="URL.thumb.jpg"
13//!        data-thumb-src="URL.thumb.jpg" controls preload="metadata"
14//!        { width="W"}?{ height="H"}?></video>
15//! ```
16//!
17//! - Single `src=` attribute on `<video>` (no nested `<source>` child) —
18//!   load-bearing for the surviving `add_video_placeholder_attributes`
19//!   regex pass (preserved until PR3 of Phase 2E). The regex's skip guard
20//!   at `placeholder.rs:435` triggers on `data-placeholder-src`, making
21//!   the post-pass a no-op for synthesizer-emitted videos.
22//!   Source: moss-core pre-Phase-0 `VideoRenderer` at
23//!   `crates/moss-core/src/resolve/embed_renderer.rs:509-571` (commit `efb834a3e`).
24//! - `controls preload="metadata"` emitted on the **default** branch. The
25//!   ambient loop branch (`![[clip.mp4|loop]]`) instead emits
26//!   `autoplay muted loop playsinline preload="metadata"` with no `controls`
27//!   and adds `data-loop` for JS/CSS targeting. See `AMBIENT_PLAYBACK_ATTRS`.
28//! - Width/height priority: (1) `TitleParams` `|WxH` sizing alias wins
29//!   when present (with units like `640px`). (2) `AssetSnapshot.dimensions`
30//!   lookup as fallback, emitted unitless (e.g. `width="1920"`). (3) Both
31//!   attributes omitted when the snapshot dims are `(0, 0)` — that is the
32//!   sentinel for "unknown," NOT a real dimension. Both omitted when no
33//!   entry is in the snapshot.
34//! - `data-placeholder-src` is the ORIGINAL src (e.g. `clip.mov`). The
35//!   iframe-bridge swaps `src` to the `.mp4` payload once the transcode
36//!   completes (`frontend/bridge/iframe-bridge.ts:666`).
37//! - `poster` and `data-thumb-src` both reference `to_thumb(original_src)`.
38//!   The iframe-bridge listens for `moss-thumb-ready` and swaps `poster`
39//!   in when the thumbnail lands.
40//!
41//! # `.mov` → `.mp4` source-extension swap (moved from placeholder.rs)
42//!
43//! moss converts `.mov` source files to `.mp4` during build, so a raw
44//! `.mov` reference in the rendered HTML would 404. Pre-Phase-0 this swap
45//! lived in `placeholder.rs::add_video_placeholder_attributes` as a regex
46//! post-pass. Phase 1's typed-data-boundary architecture moves it here:
47//! the synthesizer is the single source of truth for the URL that ends up
48//! in `<video src=>`.
49//!
50//! # Multi-source HLS form (NOT emitted here)
51//!
52//! This synthesizer never emits `<video><source src=…></video>` (multi-
53//! source / adaptive-bitrate form). That shape is reserved for HLS / DASH
54//! streams that don't need transcode-pending hydration. The regex pass
55//! also skips it (`video_re` requires `src="…"` directly on `<video>`),
56//! and the `data-placeholder-src` / `poster` / `data-thumb-src` injection
57//! only applies to the single-src form by design. If a future callsite
58//! wants the multi-source form, it must NOT route through this
59//! synthesizer.
60
61use crate::asset_paths::{to_mp4, to_thumb};
62use crate::asset_snapshot::AssetSnapshot;
63use crate::resolve::embed_renderer::html_escape_attr;
64use crate::resolve::title_params::TitleParams;
65use std::path::PathBuf;
66
67/// Core playback attributes shared by the ambient loop branch and cover.rs.
68///
69/// The loop branch prepends `autoplay` to this. cover.rs intentionally omits
70/// `autoplay` (covers are hover-played, not auto) — if cover.rs can't import
71/// this const directly, keep its literal with:
72/// `// keep in sync with render::video::AMBIENT_PLAYBACK_ATTRS (covers omit autoplay)`
73pub const AMBIENT_PLAYBACK_ATTRS: &str = r#"muted loop playsinline preload="metadata""#;
74
75/// Synthesize video embed HTML for `Tag::Link` with `moss:kind=video` title.
76///
77/// Owns the `.mov` → `.mp4` source-extension swap at the typed-data
78/// boundary (moved from `placeholder.rs` per the unified-emission
79/// migration). Emits the full attribute set the surviving regex pass
80/// would have injected — `data-placeholder-src`, `poster`,
81/// `data-thumb-src`, and snapshot-derived `width`/`height` — so the
82/// post-pass becomes a no-op for synthesizer-emitted videos. See module
83/// docs for the authoritative byte-shape contract.
84pub fn synthesize_video_html(
85    params: &TitleParams,
86    src: &str,
87    assets: &AssetSnapshot,
88) -> String {
89    // .mov → .mp4 source-extension swap (idempotent for .mp4 / .webm).
90    // Pre-Phase-0 lived in placeholder.rs; moved here because the typed-
91    // data layer is the single source of truth for the served URL.
92    let converted_src = to_mp4(src);
93
94    // Thumbnail path — used for both `poster` (initial frame before
95    // transcode lands) and `data-thumb-src` (iframe-bridge swap target
96    // when `moss-thumb-ready` fires). Mirrors the regex behaviour at
97    // `placeholder.rs:444-446`.
98    let thumb = to_thumb(src);
99
100    // Width/height priority:
101    //   (1) TitleParams alias (e.g. `|640x360` → `width="640px" height="360px"`)
102    //   (2) AssetSnapshot.dimensions lookup (unitless ints)
103    //   (3) omit both
104    // `(0, 0)` from the snapshot is a sentinel for unknown — omit both
105    // when we see it, per the Phase 2E design (the regex emits `width="0"`
106    // in that case; the synthesizer corrects that long-standing bug).
107    let (width_attr, height_attr) = if let (Some(w), Some(h)) = (params.get("width"), params.get("height")) {
108        (
109            format!(r#" width="{}""#, html_escape_attr(w)),
110            format!(r#" height="{}""#, html_escape_attr(h)),
111        )
112    } else if let Some(w) = params.get("width") {
113        // Width-only alias is unusual but historically supported.
114        (
115            format!(r#" width="{}""#, html_escape_attr(w)),
116            String::new(),
117        )
118    } else {
119        match assets.dims(&PathBuf::from(src)) {
120            Some((w, h)) if w > 0 && h > 0 => (
121                format!(r#" width="{}""#, w),
122                format!(r#" height="{}""#, h),
123            ),
124            _ => (String::new(), String::new()),
125        }
126    };
127
128    // Ambient loop branch: `![[clip.mp4|loop]]` → autoplay + ambient set,
129    // no controls, data-loop JS/CSS hook. Default branch keeps controls.
130    let is_loop = params.get("loop").is_some();
131    let (playback, data_loop) = if is_loop {
132        (
133            format!("autoplay {}", AMBIENT_PLAYBACK_ATTRS),
134            r#" data-loop"#,
135        )
136    } else {
137        (r#"controls preload="metadata""#.to_string(), "")
138    };
139
140    format!(
141        r#"<video class="moss-embed moss-embed-video" data-type="video"{data_loop} src="{src}" data-placeholder-src="{orig}" poster="{thumb}" data-thumb-src="{thumb}" {playback}{w}{h}></video>"#,
142        data_loop = data_loop,
143        src = html_escape_attr(&converted_src),
144        orig = html_escape_attr(src),
145        thumb = html_escape_attr(&thumb),
146        playback = playback,
147        w = width_attr,
148        h = height_attr,
149    )
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    fn empty_snapshot() -> AssetSnapshot {
157        AssetSnapshot::new()
158    }
159
160    fn params_with(kvs: &[(&str, &str)]) -> TitleParams {
161        let mut p = TitleParams::default();
162        for (k, v) in kvs {
163            p.insert(*k, *v);
164        }
165        p
166    }
167
168    // --- byte-shape parity with pre-Phase-0 VideoRenderer ---
169
170    #[test]
171    fn video_basic_shape() {
172        let p = params_with(&[("kind", "video")]);
173        let out = synthesize_video_html(&p, "clip.mp4", &empty_snapshot());
174        assert!(out.contains("<video"), "got: {}", out);
175        assert!(out.contains(r#"src="clip.mp4""#), "got: {}", out);
176    }
177
178    #[test]
179    fn video_emits_moss_embed_classes() {
180        let p = params_with(&[("kind", "video")]);
181        let out = synthesize_video_html(&p, "clip.mp4", &empty_snapshot());
182        assert!(
183            out.contains(r#"class="moss-embed moss-embed-video""#),
184            "got: {}",
185            out
186        );
187    }
188
189    #[test]
190    fn video_emits_closing_tag() {
191        // Pre-Phase-0 VideoRenderer ended with `</video>` (not self-closing).
192        // The downstream rewriter regex matches `<video … src="…">` and
193        // expects a separate closing tag.
194        let p = params_with(&[("kind", "video")]);
195        let out = synthesize_video_html(&p, "clip.mp4", &empty_snapshot());
196        assert!(out.ends_with("</video>"), "got: {}", out);
197    }
198
199    #[test]
200    fn video_emits_controls_on_default_path() {
201        // The default wikilink `![[clip.mp4]]` (no `loop` param) emits
202        // `controls preload="metadata"`. The loop branch is the one exception
203        // (it emits the ambient set instead). Relaxed from the original
204        // "unconditionally" test name — loop is the opt-in departure.
205        let p = params_with(&[("kind", "video")]);
206        let out = synthesize_video_html(&p, "clip.mp4", &empty_snapshot());
207        assert!(out.contains(" controls"), "default path must emit controls, got: {}", out);
208    }
209
210    #[test]
211    fn video_emits_preload_metadata() {
212        // `preload="metadata"` is the historical default: browser fetches
213        // duration/dimensions but defers the full payload until play.
214        let p = params_with(&[("kind", "video")]);
215        let out = synthesize_video_html(&p, "clip.mp4", &empty_snapshot());
216        assert!(out.contains(r#"preload="metadata""#), "got: {}", out);
217    }
218
219    #[test]
220    fn video_single_src_no_nested_source() {
221        // Single src= on <video>, NOT a nested <source> child. Load-bearing:
222        // the surviving add_video_placeholder_attributes regex matches
223        // `<video\s+[^>]*?src="…">`. With a nested <source>, the regex
224        // no-ops and the entire post-pass silently drops. See pre-Phase-0
225        // VideoRenderer doc comment at embed_renderer.rs:519-545.
226        let p = params_with(&[("kind", "video")]);
227        let out = synthesize_video_html(&p, "clip.mp4", &empty_snapshot());
228        assert!(!out.contains("<source"), "must not emit <source>: {}", out);
229    }
230
231    // --- .mov → .mp4 source-extension swap ---
232
233    #[test]
234    fn video_mov_extension_swaps_to_mp4() {
235        // moss transcodes .mov source files to .mp4 during build; the
236        // emitted URL must reference the .mp4 output. Pre-Phase-0 the
237        // regex pass in placeholder.rs owned this swap. Phase 1 moves it
238        // to the synthesizer at the typed-data boundary (Decision #6:
239        // zero carve-outs).
240        let p = params_with(&[("kind", "video")]);
241        let out = synthesize_video_html(&p, "clip.mov", &empty_snapshot());
242        assert!(
243            out.contains(r#"src="clip.mp4""#),
244            "expected .mov swapped to .mp4, got: {}",
245            out
246        );
247        // The `src=` attribute (preceded by space, not `-src=` from
248        // data-placeholder-src) must point at the .mp4. Using a leading
249        // space disambiguates `src=` from `data-placeholder-src=`, where
250        // `.mov` legitimately survives as the original-src attribute that
251        // the iframe-bridge listens to (`iframe-bridge.ts:666`).
252        assert!(
253            !out.contains(r#" src="clip.mov""#),
254            "raw .mov must not appear in src= after swap, got: {}",
255            out
256        );
257    }
258
259    #[test]
260    fn video_uppercase_mov_extension_swaps_to_mp4() {
261        // .MOV (uppercase) is the macOS QuickTime export default and must
262        // also swap. Mirrors the to_mp4 helper's case-insensitive contract.
263        let p = params_with(&[("kind", "video")]);
264        let out = synthesize_video_html(&p, "clip.MOV", &empty_snapshot());
265        assert!(
266            out.contains(r#"src="clip.mp4""#),
267            "expected .MOV swapped to .mp4, got: {}",
268            out
269        );
270    }
271
272    #[test]
273    fn video_mp4_extension_pass_through() {
274        // .mp4 is the served form; pass through unchanged.
275        let p = params_with(&[("kind", "video")]);
276        let out = synthesize_video_html(&p, "../assets/clip.mp4", &empty_snapshot());
277        assert!(
278            out.contains(r#"src="../assets/clip.mp4""#),
279            "got: {}",
280            out
281        );
282    }
283
284    #[test]
285    fn video_webm_extension_pass_through() {
286        // .webm is not transcoded; pass through.
287        let p = params_with(&[("kind", "video")]);
288        let out = synthesize_video_html(&p, "clip.webm", &empty_snapshot());
289        assert!(out.contains(r#"src="clip.webm""#), "got: {}", out);
290    }
291
292    // --- width/height from TitleParams (Stage 1 lifted |WxH alias) ---
293
294    #[test]
295    fn video_emits_width_param_when_present() {
296        let p = params_with(&[("kind", "video"), ("width", "640px")]);
297        let out = synthesize_video_html(&p, "clip.mp4", &empty_snapshot());
298        assert!(out.contains(r#"width="640px""#), "got: {}", out);
299    }
300
301    #[test]
302    fn video_emits_height_param_when_present() {
303        let p = params_with(&[("kind", "video"), ("width", "640px"), ("height", "360px")]);
304        let out = synthesize_video_html(&p, "clip.mp4", &empty_snapshot());
305        assert!(out.contains(r#"width="640px""#), "got: {}", out);
306        assert!(out.contains(r#"height="360px""#), "got: {}", out);
307    }
308
309    #[test]
310    fn video_no_width_or_height_attrs_when_snapshot_empty() {
311        // Phase 2E: width/height priority is (1) TitleParams alias, then
312        // (2) AssetSnapshot.dims lookup, then (3) omit. With no alias and
313        // no snapshot entry, the synthesizer omits both — matching the
314        // "no fallback dims" decision in the Phase 2E design (the regex's
315        // 800x600 fallback was a long-standing band-aid; Phase 2E drops it).
316        let p = params_with(&[("kind", "video")]);
317        let out = synthesize_video_html(&p, "clip.mp4", &empty_snapshot());
318        assert!(!out.contains("width="), "got: {}", out);
319        assert!(!out.contains("height="), "got: {}", out);
320    }
321
322    // --- HTML-escape contract ---
323
324    #[test]
325    fn video_src_is_html_escaped() {
326        // & must escape to &amp; in attribute values. The URL Q&A is
327        // contrived but exercises the html_escape pass.
328        let p = params_with(&[("kind", "video")]);
329        let out = synthesize_video_html(&p, "q&a.mp4", &empty_snapshot());
330        assert!(out.contains(r#"src="q&amp;a.mp4""#), "got: {}", out);
331    }
332
333    // --- Phase 2E: regex-parity emission ---
334
335    #[test]
336    fn synth_video_mov_to_mp4() {
337        // `![[clip.mov]]` → `src="clip.mp4"` (transcoded payload) plus
338        // `data-placeholder-src="clip.mov"` (original src — what the
339        // iframe-bridge listens to when transcode-ready fires). Mirrors
340        // the regex contract at `placeholder.rs:440-490` so the post-pass
341        // becomes a no-op for synthesizer output.
342        let p = params_with(&[("kind", "video")]);
343        let out = synthesize_video_html(&p, "clip.mov", &empty_snapshot());
344        assert!(
345            out.contains(r#"src="clip.mp4""#),
346            "expected src= to point at transcoded mp4, got: {}",
347            out
348        );
349        assert!(
350            out.contains(r#"data-placeholder-src="clip.mov""#),
351            "expected data-placeholder-src= to point at original mov, got: {}",
352            out
353        );
354    }
355
356    #[test]
357    fn synth_video_poster_and_thumb() {
358        // `poster` carries the thumbnail URL so the first frame paints
359        // immediately; `data-thumb-src` is the iframe-bridge's swap-target
360        // when `moss-thumb-ready` fires (covers the case where the thumb
361        // arrives after the page renders). Both reference `to_thumb(src)`,
362        // mirroring `placeholder.rs:444-446`.
363        let p = params_with(&[("kind", "video")]);
364        let out = synthesize_video_html(&p, "clip.mov", &empty_snapshot());
365        assert!(
366            out.contains(r#"poster="clip.thumb.jpg""#),
367            "expected poster= from to_thumb(src), got: {}",
368            out
369        );
370        assert!(
371            out.contains(r#"data-thumb-src="clip.thumb.jpg""#),
372            "expected data-thumb-src= from to_thumb(src), got: {}",
373            out
374        );
375    }
376
377    #[test]
378    fn synth_video_dims_from_snapshot() {
379        // When TitleParams has no |WxH alias but the AssetSnapshot has
380        // (w, h) for the source path, the synthesizer emits unitless
381        // width/height. Matches the regex's `lookup.get(original_src)`
382        // behaviour (placeholder.rs:449).
383        let p = params_with(&[("kind", "video")]);
384        let mut snap = AssetSnapshot::new();
385        snap.dimensions.insert(PathBuf::from("clip.mov"), (1920, 1080));
386        let out = synthesize_video_html(&p, "clip.mov", &snap);
387        assert!(out.contains(r#"width="1920""#), "got: {}", out);
388        assert!(out.contains(r#"height="1080""#), "got: {}", out);
389    }
390
391    #[test]
392    fn synth_video_omits_zero_dims() {
393        // `(0, 0)` in AssetSnapshot.dimensions is a sentinel for "unknown"
394        // (e.g. probe failed). The regex emits `width="0" height="0"`
395        // which is invalid; the synthesizer corrects that by omitting both.
396        let p = params_with(&[("kind", "video")]);
397        let mut snap = AssetSnapshot::new();
398        snap.dimensions.insert(PathBuf::from("clip.mov"), (0, 0));
399        let out = synthesize_video_html(&p, "clip.mov", &snap);
400        assert!(
401            !out.contains("width="),
402            "(0, 0) sentinel must NOT produce width=, got: {}",
403            out
404        );
405        assert!(
406            !out.contains("height="),
407            "(0, 0) sentinel must NOT produce height=, got: {}",
408            out
409        );
410    }
411
412    // Phase 2E v5 PR5 (2026-05-26) retired the Stage 3 regex post-pass; the
413    // video synthesizer in this module is now the sole emitter of width /
414    // height / poster / data-thumb-src / .mov→.mp4 src rewriting for
415    // moss-emitted <video> tags. The two regex-parity tests at
416    // `src-tauri/tests/video_synth_regex_parity.rs` were deleted alongside
417    // the regex.
418
419    // --- |loop ambient-video attribute (spec §3.6) -----------------------
420
421    #[test]
422    fn loop_keyword_emits_autoplay_muted_loop_playsinline_no_controls() {
423        // `![[clip.mp4|loop]]` must emit the ambient playback set and must
424        // NOT emit `controls` (the chrome-free ambient branch has no control bar).
425        let p = params_with(&[("kind", "video"), ("loop", "1")]);
426        let out = synthesize_video_html(&p, "clip.mp4", &empty_snapshot());
427        assert!(out.contains(" autoplay"), "missing autoplay, got: {}", out);
428        assert!(out.contains(" muted"), "missing muted, got: {}", out);
429        assert!(out.contains(" loop"), "missing loop, got: {}", out);
430        assert!(out.contains(" playsinline"), "missing playsinline, got: {}", out);
431        assert!(!out.contains(" controls"), "controls must be absent on loop branch, got: {}", out);
432    }
433
434    #[test]
435    fn loop_keyword_emits_data_type_and_data_loop() {
436        // data-type="video" (drift fix) and data-loop (JS/CSS hook) must both
437        // be present on the loop branch.
438        let p = params_with(&[("kind", "video"), ("loop", "1")]);
439        let out = synthesize_video_html(&p, "clip.mp4", &empty_snapshot());
440        assert!(out.contains(r#"data-type="video""#), "missing data-type=video, got: {}", out);
441        assert!(out.contains(" data-loop"), "missing data-loop attribute, got: {}", out);
442    }
443
444    #[test]
445    fn default_path_emits_controls() {
446        // The default `![[clip.mp4]]` (no loop param) must still emit `controls`.
447        // Relaxed from the previous test name "video_emits_controls_unconditionally"
448        // — the loop branch is the one exception.
449        let p = params_with(&[("kind", "video")]);
450        let out = synthesize_video_html(&p, "clip.mp4", &empty_snapshot());
451        assert!(out.contains(" controls"), "default path must emit controls, got: {}", out);
452    }
453
454    #[test]
455    fn loop_with_size_emits_width_height_and_loop_set() {
456        // `![[clip.mp4|640x360 loop]]` must set width AND height AND the loop
457        // ambient attribute set. The parser arm is tested end-to-end here via
458        // the synthesizer (width/height come from TitleParams the parser sets).
459        let p = params_with(&[("kind", "video"), ("loop", "1"), ("width", "640px"), ("height", "360px")]);
460        let out = synthesize_video_html(&p, "clip.mp4", &empty_snapshot());
461        assert!(out.contains(r#"width="640px""#), "missing width, got: {}", out);
462        assert!(out.contains(r#"height="360px""#), "missing height, got: {}", out);
463        assert!(out.contains(" autoplay"), "missing autoplay, got: {}", out);
464        assert!(out.contains(" loop"), "missing loop, got: {}", out);
465        assert!(!out.contains(" controls"), "controls must be absent on loop branch, got: {}", out);
466    }
467
468    #[test]
469    fn data_type_video_emitted_on_default_branch() {
470        // data-type="video" must be on the default (non-loop) branch too — this
471        // is the drift fix bundled with the loop feature.
472        let p = params_with(&[("kind", "video")]);
473        let out = synthesize_video_html(&p, "clip.mp4", &empty_snapshot());
474        assert!(out.contains(r#"data-type="video""#), "missing data-type=video on default branch, got: {}", out);
475    }
476}