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