From d30f49a69c9d17e7ebfb905b006b59cc625e5b5d Mon Sep 17 00:00:00 2001
From: Baptiste Parmantier
Date: Tue, 22 Sep 2026 10:59:30 +0200
Subject: [PATCH 01/25] fix(encode): reject a non-positive playback rate (#287)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`remaining` never reaches 0.5 when it starts at 0.0 (0.0/0.5 == 0.0) or at any negative value (it diverges toward -inf). The loop never terminates and pushes a fresh `String` on every iteration, so rustmotion hangs while consuming memory until the OOM killer fires. Reached from a plain scenario JSON: `{"type":"video","src":"clip.mp4","playback_rate":0}` — `volume` defaults to 1.0 (`crates/rustmotion-components/src/video.rs:14` `fn default_volume() -> f32 { 1.0 }`), so `collect_videos_in_child` collects it (`v.volume > 0.0`, line 128), `collect_video_audio_tracks` calls `extract_audio_to_wav(..., occ.playback_rate)` (line 421), which calls `build_atempo_filter(0.0)` at line 333. `playback_rate` is an unvalidated `Option` (`crates/rustmotion-components/src/video.rs:26`); a repo-wide grep shows no validator constrains it. Both the ffmpeg render path (`encode_with_ffmpeg_hw_impl` line 432) and the native path (`mux.rs:34`) reach it.
Refs #220
---
crates/rustmotion/src/encode/video_audio.rs | 27 +++++++++++++-
crates/rustmotion/tests/audit_ws_c.rs | 39 +++++++++++++++++++++
2 files changed, 65 insertions(+), 1 deletion(-)
diff --git a/crates/rustmotion/src/encode/video_audio.rs b/crates/rustmotion/src/encode/video_audio.rs
index 5ce3c1fb..caac9e1e 100644
--- a/crates/rustmotion/src/encode/video_audio.rs
+++ b/crates/rustmotion/src/encode/video_audio.rs
@@ -199,19 +199,41 @@ fn ffmpeg_available() -> bool {
/// rate 4.0 → `atempo=2.0,atempo=2.0`
/// rate 0.1 → `atempo=0.5,atempo=0.2` (0.5 * 0.2 = 0.1)
///
-/// Returns `None` if rate == 1.0 (no filter needed).
+/// Returns `None` if rate == 1.0 (no filter needed), or if `rate` cannot
+/// possibly be reached by any chain of `atempo` stages (`<= 0.0` or
+/// non-finite — see the guard below).
pub fn build_atempo_filter(rate: f64) -> Option {
const EPSILON: f64 = 1e-9;
+ // `remaining` only ever converges toward `[0.5, 2.0]` by repeatedly
+ // multiplying or dividing by 2.0 starting from a *positive, finite*
+ // `rate`. At `rate == 0.0`, `remaining /= 0.5` stays `0.0` forever; at a
+ // negative or non-finite rate it diverges away from the loop's own exit
+ // test. Either way the `while` below never terminates and pushes a new
+ // `String` on every turn — the guard has to reject these before that
+ // loop is ever reached, not inside it.
+ if !rate.is_finite() || rate <= 0.0 {
+ return None;
+ }
if (rate - 1.0).abs() < EPSILON {
return None;
}
+ // A ceiling on the chain length, independent of the guard above: a
+ // legitimate rate as extreme as 1e9 only needs ~30 stages, so this never
+ // fires for real input. It exists so that a future mistake in this
+ // arithmetic degrades into "no atempo filter" instead of reopening the
+ // same unbounded loop the guard above closes.
+ const MAX_STAGES: usize = 64;
+
let mut parts: Vec = Vec::new();
let mut remaining = rate;
if rate > 1.0 {
// Each stage multiplies by at most 2.0
while remaining > 2.0 + EPSILON {
+ if parts.len() >= MAX_STAGES {
+ return None;
+ }
parts.push("atempo=2.0".to_string());
remaining /= 2.0;
}
@@ -219,6 +241,9 @@ pub fn build_atempo_filter(rate: f64) -> Option {
} else {
// Each stage multiplies by at least 0.5
while remaining < 0.5 - EPSILON {
+ if parts.len() >= MAX_STAGES {
+ return None;
+ }
parts.push("atempo=0.5".to_string());
remaining /= 0.5;
}
diff --git a/crates/rustmotion/tests/audit_ws_c.rs b/crates/rustmotion/tests/audit_ws_c.rs
index e612794e..0b18c5b3 100644
--- a/crates/rustmotion/tests/audit_ws_c.rs
+++ b/crates/rustmotion/tests/audit_ws_c.rs
@@ -115,3 +115,42 @@ fn a_render_ffmpeg_rejects_leaves_no_debris_at_the_output_path() {
out.path().display()
);
}
+
+// ─── build_atempo_filter(rate) must not loop forever ───────────────────────
+//
+// `remaining /= 0.5` never reaches the loop's `>= 0.5` exit test starting
+// from `0.0`, and diverges away from it starting from any negative rate —
+// either way the pre-fix loop pushed a fresh `String` forever. Calling the
+// unguarded function directly on the test thread would hang the whole
+// suite, so each candidate rate runs on its own thread with a bounded
+// `recv_timeout`: a present guard returns well inside the timeout, a
+// missing one times out and fails the assertion instead of the process.
+#[test]
+fn atempo_guard_rejects_non_positive_and_non_finite_rates_without_hanging() {
+ use std::sync::mpsc;
+ use std::time::Duration;
+
+ for rate in [
+ 0.0_f64,
+ -1.0,
+ -0.25,
+ f64::NAN,
+ f64::INFINITY,
+ f64::NEG_INFINITY,
+ ] {
+ let (tx, rx) = mpsc::channel();
+ std::thread::spawn(move || {
+ let _ = tx.send(rustmotion::encode::video_audio::build_atempo_filter(rate));
+ });
+ match rx.recv_timeout(Duration::from_secs(2)) {
+ Ok(result) => assert_eq!(
+ result, None,
+ "rate={rate} must return None instead of building an atempo chain"
+ ),
+ Err(_) => panic!(
+ "rate={rate} did not return within 2s — the guard against the infinite loop \
+ in build_atempo_filter is missing or broken"
+ ),
+ }
+ }
+}
From 928f9efc80812cf4b602c64d10f450423996546c Mon Sep 17 00:00:00 2001
From: Baptiste Parmantier
Date: Tue, 22 Sep 2026 10:59:54 +0200
Subject: [PATCH 02/25] perf(preload): bound the extracted video frame cache
(#288)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Triggering scenario: a scenario JSON declaring a `video` component with `style.width/height` of 1920x1080 inside a 60 s scene at 30 fps. `.output()` (line 234) collects ffmpeg's entire rawvideo stdout into one `Vec`: 1800 frames x 8.3 MB = 15 GB. `data` then stays borrowed for the whole copy loop, so the per-frame `.to_vec()` copies build a second 15 GB structure alongside it — 30 GB peak before `output.stdout` is dropped. The result is inserted into the process-global `VIDEO_FRAME_CACHE` (assets.rs:168), which has no eviction and no `clear` function at all. There is no size, duration, or resolution cap anywhere on this path, and the inputs are entirely scenario-controlled. Under `--watch` this compounds: changing the component's `style.width` mints a new `"src:WxH"` key while the old multi-GB entry is never freed. Secondary hazard on the same line: `width * height * 4` is plain u32 arithmetic, so a declared 65536x16384 wraps to 0 and `data.len() / frame_size` panics with a divide-by-zero.
Refs #220
---
crates/rustmotion/src/engine/preload.rs | 163 +++++++++++++++++++++---
crates/rustmotion/tests/audit_ws_d.rs | 63 +++++++++
2 files changed, 210 insertions(+), 16 deletions(-)
create mode 100644 crates/rustmotion/tests/audit_ws_d.rs
diff --git a/crates/rustmotion/src/engine/preload.rs b/crates/rustmotion/src/engine/preload.rs
index 8f19c877..dc132c5b 100644
--- a/crates/rustmotion/src/engine/preload.rs
+++ b/crates/rustmotion/src/engine/preload.rs
@@ -1,3 +1,4 @@
+use std::io::Read;
use std::sync::Arc;
use crate::components::{ChildComponent, Component};
@@ -8,6 +9,54 @@ use rustmotion_core::engine::renderer::{
};
use rustmotion_core::traits::{Styled, Timed};
+/// Total bytes `VIDEO_FRAME_CACHE` may hold across every distinct
+/// `(src, width, height)` entry combined. `preextract_video_frames` refuses
+/// to add an entry that would push the cache past this ceiling rather than
+/// caching it anyway — a video past the budget renders blank for the
+/// affected frames, the same degraded outcome an ffmpeg failure already
+/// produces on this path, instead of the process exhausting memory (a single
+/// 1080p 30s embed alone reaches ~7.5 GB of raw decoded RGBA held in memory
+/// forever, with no eviction).
+pub const VIDEO_FRAME_CACHE_BUDGET_BYTES: u64 = 1024 * 1024 * 1024;
+
+/// Bytes one raw RGBA frame at `width`×`height` occupies, computed in `u64`
+/// and saturating rather than the plain `u32` multiplication this used to be:
+/// `width * height * 4` in `u32` wraps for a large-enough declared size
+/// (65536×16384 wraps to 0), which downstream turned into a division by
+/// zero. Saturating instead of panicking means an absurd declared size still
+/// fails the budget check below rather than crashing the preload pass.
+pub fn video_frame_byte_size(width: u32, height: u32) -> u64 {
+ u64::from(width)
+ .saturating_mul(u64::from(height))
+ .saturating_mul(4)
+}
+
+/// Whether caching `additional_bytes` more on top of `already_cached_bytes`
+/// would cross [`VIDEO_FRAME_CACHE_BUDGET_BYTES`]. Saturating so a caller
+/// that already (somehow) exceeds the budget, or an `additional_bytes` at
+/// `u64::MAX` from a saturated [`video_frame_byte_size`], still reports
+/// "over budget" instead of wrapping back under it.
+pub fn would_exceed_cache_budget(already_cached_bytes: u64, additional_bytes: u64) -> bool {
+ already_cached_bytes.saturating_add(additional_bytes) > VIDEO_FRAME_CACHE_BUDGET_BYTES
+}
+
+/// Bytes currently held across every entry of the process-global video-frame
+/// cache. `VIDEO_FRAME_CACHE` has no eviction (see `assets.rs`), so this is a
+/// running total the caller checks before adding to it, not a size taken
+/// from any single-entry accounting the map itself keeps.
+fn video_frame_cache_bytes() -> u64 {
+ video_frame_cache()
+ .iter()
+ .map(|entry| {
+ entry
+ .value()
+ .iter()
+ .map(|(_, data, _, _)| data.len() as u64)
+ .sum::()
+ })
+ .sum()
+}
+
/// Pre-fetch and cache all icon components before rendering.
/// Call this before the render loop to avoid HTTP requests during parallel rendering.
pub fn prefetch_icons(scenes: &[Scene]) {
@@ -157,6 +206,16 @@ pub fn prefetch_icons(scenes: &[Scene]) {
/// already established for embedded-video *audio* extraction
/// (`encode::video_audio::collect_video_audio_tracks`), which this frame
/// path never inherited.
+///
+/// ffmpeg's rawvideo stdout is read directly off the pipe in
+/// `frame_byte_size` chunks (`Read::read_exact`) rather than buffered whole
+/// via `Command::output` and then copied frame-by-frame out of that buffer —
+/// the old shape held the full decode in memory twice at its peak. A byte
+/// budget (`would_exceed_cache_budget`) is checked before ffmpeg is even
+/// spawned, and the read loop itself stops at `expected_frames` regardless,
+/// so a source that would blow the budget is refused up front and one that
+/// somehow outputs more frames than the requested time range implies cannot
+/// grow the cache past what was budgeted for it.
pub fn preextract_video_frames(scenes: &[Scene], fps: u32) {
if !ffmpeg_available() {
eprintln!(
@@ -212,7 +271,34 @@ pub fn preextract_video_frames(scenes: &[Scene], fps: u32) {
let max_time = times.last().copied().unwrap_or(0.0);
let duration = max_time - min_time + (1.0 / fps as f64);
- let output = std::process::Command::new("ffmpeg")
+ let frame_byte_size = video_frame_byte_size(width, height);
+ if frame_byte_size == 0 {
+ eprintln!(
+ "rustmotion: video frame preextraction: '{}' resolved to a zero-byte \
+ frame size ({width}x{height}) — refusing to preextract. This video will \
+ render blank for the affected frames.",
+ video.src
+ );
+ return;
+ }
+ let expected_frames = (times.len() as u64).saturating_add(1);
+ let expected_bytes = frame_byte_size.saturating_mul(expected_frames);
+ let already_cached = video_frame_cache_bytes();
+ if would_exceed_cache_budget(already_cached, expected_bytes) {
+ eprintln!(
+ "rustmotion: video frame preextraction: caching '{}' at {width}x{height} \
+ would need ~{} MiB on top of the {} MiB already cached, over the {} MiB \
+ budget — refusing to preextract. This video will render blank for the \
+ affected frames.",
+ video.src,
+ expected_bytes / (1024 * 1024),
+ already_cached / (1024 * 1024),
+ VIDEO_FRAME_CACHE_BUDGET_BYTES / (1024 * 1024),
+ );
+ return;
+ }
+
+ let mut child = match std::process::Command::new("ffmpeg")
.args([
"-ss",
&format!("{:.3}", min_time),
@@ -231,35 +317,80 @@ pub fn preextract_video_frames(scenes: &[Scene], fps: u32) {
])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
- .output();
+ .spawn()
+ {
+ Ok(child) => child,
+ Err(e) => {
+ eprintln!(
+ "rustmotion: video frame preextraction: could not spawn ffmpeg for \
+ '{}': {}. This video will render blank for the affected frames.",
+ video.src, e
+ );
+ return;
+ }
+ };
- match output {
- Ok(output) if output.status.success() => {
- let frame_size = (width * height * 4) as usize;
- let data = &output.stdout;
- let num_frames = data.len() / frame_size;
- let mut frames: Vec<(f64, Vec, u32, u32)> = Vec::with_capacity(num_frames);
+ let Some(mut stdout) = child.stdout.take() else {
+ eprintln!(
+ "rustmotion: video frame preextraction: ffmpeg for '{}' produced no \
+ stdout pipe. This video will render blank for the affected frames.",
+ video.src
+ );
+ let _ = child.wait();
+ return;
+ };
- for idx in 0..num_frames {
- let start = idx * frame_size;
- let frame_data = data[start..start + frame_size].to_vec();
- let time = min_time + idx as f64 / fps as f64;
- frames.push((time, frame_data, width, height));
+ let frame_size = frame_byte_size as usize;
+ let max_frames = expected_frames as usize;
+ let mut frames: Vec<(f64, Vec, u32, u32)> = Vec::with_capacity(times.len());
+ loop {
+ if frames.len() >= max_frames {
+ break;
+ }
+ let mut buf = vec![0u8; frame_size];
+ match stdout.read_exact(&mut buf) {
+ Ok(()) => {
+ let time = min_time + frames.len() as f64 / fps as f64;
+ frames.push((time, buf, width, height));
+ }
+ Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
+ Err(e) => {
+ eprintln!(
+ "rustmotion: video frame preextraction: reading ffmpeg output for \
+ '{}' failed: {}. Keeping the {} frame(s) decoded so far.",
+ video.src,
+ e,
+ frames.len()
+ );
+ break;
}
+ }
+ }
+ drop(stdout);
+ match child.wait() {
+ Ok(status) if status.success() => {
+ if frames.is_empty() {
+ eprintln!(
+ "rustmotion: video frame preextraction: ffmpeg produced no frames \
+ for '{}'. This video will render blank for the affected frames.",
+ video.src
+ );
+ return;
+ }
cache.insert(cache_key, Arc::new(frames));
}
- Ok(output) => {
+ Ok(status) => {
eprintln!(
"rustmotion: video frame preextraction: ffmpeg failed to decode \
frames from '{}' (exit status: {}). This video will render blank \
for the affected frames.",
- video.src, output.status
+ video.src, status
);
}
Err(e) => {
eprintln!(
- "rustmotion: video frame preextraction: could not spawn ffmpeg for \
+ "rustmotion: video frame preextraction: could not wait on ffmpeg for \
'{}': {}. This video will render blank for the affected frames.",
video.src, e
);
diff --git a/crates/rustmotion/tests/audit_ws_d.rs b/crates/rustmotion/tests/audit_ws_d.rs
new file mode 100644
index 00000000..36c6a6a1
--- /dev/null
+++ b/crates/rustmotion/tests/audit_ws_d.rs
@@ -0,0 +1,63 @@
+//! Regression tests for the media-decoding, frame-cache, and render/batch
+//! CLI hardening pass on this branch.
+//!
+//! Video frame preextraction (`preload::preextract_video_frames`) is
+//! exercised through the crate's public `rustmotion::engine::preload`
+//! surface directly — no subprocess needed, since the budget math and the
+//! cache it guards are both `pub`.
+
+use rustmotion::engine::preload::{
+ video_frame_byte_size, would_exceed_cache_budget, VIDEO_FRAME_CACHE_BUDGET_BYTES,
+};
+
+// ─── Video frame cache: byte-size arithmetic and budget accounting ────────
+
+/// Ordinary dimensions must still compute the exact byte count a naive
+/// `width * height * 4` would — the fix only changes what happens past
+/// `u32::MAX`, not everyday results.
+#[test]
+fn frame_byte_size_matches_plain_multiplication_for_ordinary_dimensions() {
+ assert_eq!(video_frame_byte_size(1920, 1080), 1920u64 * 1080 * 4);
+ assert_eq!(video_frame_byte_size(0, 0), 0);
+}
+
+/// The secondary hazard this closes: `width * height * 4` in plain `u32`
+/// wraps for a large-enough declared size (65536×16384 wraps to 0, which
+/// used to turn into a division by zero downstream). `u32::MAX` on both
+/// dimensions is the most extreme case reachable from a `style.width`/
+/// `style.height` pair — the fixed computation must saturate to `u64::MAX`,
+/// not wrap to some small number that would slip past the budget check
+/// below.
+#[test]
+fn frame_byte_size_saturates_instead_of_wrapping_on_extreme_dimensions() {
+ let huge = video_frame_byte_size(u32::MAX, u32::MAX);
+ assert_eq!(
+ huge,
+ u64::MAX,
+ "must saturate at u64::MAX, never wrap silently to a small number"
+ );
+ assert!(
+ would_exceed_cache_budget(0, huge),
+ "a saturated size must always fail the budget check"
+ );
+}
+
+/// The exact policy `preextract_video_frames` applies before spawning
+/// ffmpeg: refuse only once the *sum* crosses the ceiling, and never let a
+/// saturated `u64::MAX` addend wrap the sum back under it.
+#[test]
+fn would_exceed_cache_budget_rejects_only_once_the_sum_crosses_the_ceiling() {
+ assert!(!would_exceed_cache_budget(
+ 0,
+ VIDEO_FRAME_CACHE_BUDGET_BYTES
+ ));
+ assert!(would_exceed_cache_budget(
+ 0,
+ VIDEO_FRAME_CACHE_BUDGET_BYTES + 1
+ ));
+ assert!(would_exceed_cache_budget(VIDEO_FRAME_CACHE_BUDGET_BYTES, 1));
+ assert!(
+ would_exceed_cache_budget(u64::MAX, 1),
+ "saturating add must not wrap past the ceiling"
+ );
+}
From c029b9f27e7b2cb409c10384e13b3d91a7995394 Mon Sep 17 00:00:00 2001
From: Baptiste Parmantier
Date: Tue, 22 Sep 2026 11:00:10 +0200
Subject: [PATCH 03/25] fix(render): clear asset caches between watch renders
(#242)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Triggering scenario: `rustmotion render deck.json -o out.mp4 --watch` left open for an afternoon, with an `image` component pointing at `logo.png`. The author re-exports `logo.png`. `ASSET_CACHE` is keyed on the path alone (image.rs:41-54) with no mtime or size component, and in the incremental branch `clear_asset_cache()` only runs when the video config hash changes (resolution/fps) — editing scene content never clears it. Every subsequent re-render silently re-uses the stale decoded bitmap; the author sees the old logo indefinitely with nothing on stderr. The adjacent audio path solved exactly this and documents why (`encode/audio_analysis.rs:29-39`: fingerprint on len+mtime because 'someone re-exports a mix while the studio is open') — the image/GIF/video caches never got the same treatment. Memory side: `clear_asset_cache` touches only `ASSET_CACHE`; `GIF_CACHE`, `VIDEO_FRAME_CACHE` and `AUDIO_ANALYSIS_CACHE` have no clear function at all, so a long `--watch` session that touches several asset variants accumulates every decode for the process lifetime.
Refs #220
---
crates/rustmotion-components/src/gif.rs | 174 ++++++++++++++++++-
crates/rustmotion/src/cli/commands/render.rs | 75 +++++++-
2 files changed, 238 insertions(+), 11 deletions(-)
diff --git a/crates/rustmotion-components/src/gif.rs b/crates/rustmotion-components/src/gif.rs
index 529624ca..b5698604 100644
--- a/crates/rustmotion-components/src/gif.rs
+++ b/crates/rustmotion-components/src/gif.rs
@@ -102,8 +102,26 @@ fn stall_decode_for_tests() {
}
}
+/// Hard ceiling on one composed GIF canvas frame's byte size
+/// (`width × height × 4`), independent of the render's own video dimensions.
+/// A GIF's logical-screen descriptor is two `u16` fields straight from the
+/// file header — up to 65535×65535, a 17.2 GiB single allocation — and
+/// nothing validated them before this cap existed.
+const MAX_GIF_CANVAS_BYTES: u64 = 128 * 1024 * 1024;
+
+/// Hard ceiling on the number of animation frames one GIF decodes into.
+/// Real GIFs rarely exceed a few hundred; this keeps a maliciously (or just
+/// accidentally) long frame count from growing the decoded frame list
+/// without bound even when each individual frame is well under the canvas
+/// budget above.
+const MAX_GIF_FRAMES: usize = 600;
+
/// Decode a GIF into full-canvas RGBA frames, their cumulative end times, and
-/// the total duration.
+/// the total duration. `max_canvas_w`/`max_canvas_h` are the render's own
+/// video dimensions: a GIF canvas larger than that can never be usefully
+/// drawn (it only ever gets scaled into the component's layout box, which is
+/// at most the video frame), so it is rejected the same way an
+/// over-budget canvas is.
///
/// Every frame after the first is usually a *sub-rectangle* holding only the
/// pixels that changed, so frames must be composed onto a persistent canvas
@@ -113,7 +131,7 @@ fn stall_decode_for_tests() {
/// why only the first frame ever appeared (issue #185).
///
/// `None` means nothing can be drawn, and the reason has already been reported.
-fn decode_composed_frames(src: &str) -> Option {
+fn decode_composed_frames(src: &str, max_canvas_w: u32, max_canvas_h: u32) -> Option {
let file = match std::fs::File::open(src) {
Ok(f) => f,
Err(e) => {
@@ -139,6 +157,18 @@ fn decode_composed_frames(src: &str) -> Option {
let canvas_w = decoder.width() as u32;
let canvas_h = decoder.height() as u32;
+ let canvas_bytes = canvas_w as u64 * canvas_h as u64 * 4;
+ if canvas_bytes > MAX_GIF_CANVAS_BYTES || canvas_w > max_canvas_w || canvas_h > max_canvas_h {
+ if crate::warn_once_for(&format!("gif-oversized:{src}")) {
+ eprintln!(
+ "rustmotion: gif '{src}' declares a {canvas_w}x{canvas_h} canvas ({canvas_bytes} \
+ bytes/frame), over the {MAX_GIF_CANVAS_BYTES}-byte budget or larger than this \
+ render's own {max_canvas_w}x{max_canvas_h} video — refusing to decode it."
+ );
+ }
+ return None;
+ }
+
#[cfg(test)]
stall_decode_for_tests();
@@ -148,6 +178,16 @@ fn decode_composed_frames(src: &str) -> Option {
let mut composed = vec![0u8; canvas_w as usize * canvas_h as usize * 4];
while let Ok(Some(frame)) = decoder.read_next_frame() {
+ if frames.len() >= MAX_GIF_FRAMES {
+ if crate::warn_once_for(&format!("gif-frame-cap:{src}")) {
+ eprintln!(
+ "rustmotion: gif '{src}' has more than {MAX_GIF_FRAMES} frames — truncating \
+ the decoded animation at the cap."
+ );
+ }
+ break;
+ }
+
// `Previous` disposal restores what was there before this frame, so it
// has to be captured before compositing.
let restore = (frame.dispose == gif::DisposalMethod::Previous).then(|| composed.clone());
@@ -189,10 +229,14 @@ fn decode_composed_frames(src: &str) -> Option {
/// failure (`decode_composed_frames` returning `None`) leaves the entry
/// vacant — `or_try_insert_with` never calls `insert` on its `Err` path —
/// so a broken source is retried rather than permanently cached as absent.
-fn cached_decode(src: &str) -> Option> {
+fn cached_decode(src: &str, max_canvas_w: u32, max_canvas_h: u32) -> Option> {
gif_cache()
.entry(src.to_string())
- .or_try_insert_with(|| decode_composed_frames(src).map(Arc::new).ok_or(()))
+ .or_try_insert_with(|| {
+ decode_composed_frames(src, max_canvas_w, max_canvas_h)
+ .map(Arc::new)
+ .ok_or(())
+ })
.ok()
.map(|entry| entry.clone())
}
@@ -205,7 +249,7 @@ impl Painter for Gif {
_props: &AnimatedProperties,
ctx: &PaintCtx,
) {
- let Some(cached) = cached_decode(&self.src) else {
+ let Some(cached) = cached_decode(&self.src, ctx.video_width, ctx.video_height) else {
return;
};
@@ -282,7 +326,8 @@ mod tests {
write_two_frame_gif(&path);
let (frames, times, total) =
- decode_composed_frames(path.to_str().expect("utf-8 path")).expect("gif must decode");
+ decode_composed_frames(path.to_str().expect("utf-8 path"), 1920, 1080)
+ .expect("gif must decode");
std::fs::remove_file(&path).ok();
assert_eq!(frames.len(), 2, "both frames must be drawable");
@@ -312,7 +357,7 @@ mod tests {
#[test]
fn a_missing_file_reports_instead_of_returning_nothing() {
let missing = std::env::temp_dir().join("rustmotion_gif_absent_xyz.gif");
- assert!(decode_composed_frames(missing.to_str().expect("utf-8")).is_none());
+ assert!(decode_composed_frames(missing.to_str().expect("utf-8"), 1920, 1080).is_none());
// The warn-once slot must have been claimed — silence is the bug.
assert!(
!crate::warn_once_for(&format!("gif-open:{}", missing.to_str().expect("utf-8"))),
@@ -371,7 +416,7 @@ mod tests {
let src = src.clone();
std::thread::spawn(move || {
barrier.wait();
- cached_decode(&src)
+ cached_decode(&src, 4, 2)
})
})
.collect();
@@ -392,4 +437,117 @@ mod tests {
);
}
}
+
+ /// Writes a GIF whose logical-screen descriptor declares `w`×`h` but
+ /// whose only frame is 1×1 — the crafted-header shape a decompression
+ /// bomb takes: a file of a few hundred bytes that asks the decoder to commit to a
+ /// canvas orders of magnitude larger than anything it actually encodes.
+ fn write_oversized_header_gif(path: &std::path::Path, w: u16, h: u16) {
+ let palette: &[u8] = &[0, 0, 0, 255, 255, 255];
+ let mut file = std::fs::File::create(path).expect("create gif fixture");
+ let mut encoder = gif::Encoder::new(&mut file, w, h, palette).expect("gif encoder");
+ let frame = gif::Frame::from_indexed_pixels(1, 1, vec![0], None);
+ encoder.write_frame(&frame).expect("write frame");
+ }
+
+ /// A 6000×6000 canvas is 144 MiB per frame — comfortably over
+ /// `MAX_GIF_CANVAS_BYTES` (128 MiB) regardless of how large the render's
+ /// own video is, so passing generous `max_w`/`max_h` here isolates the
+ /// byte-budget check from the video-dimensions check exercised by the
+ /// next test. 144 MiB is deliberately far short of the 65535×65535
+ /// (~17 GiB) header a real crafted file could declare — large enough to
+ /// prove the budget check fires, small enough that running this test
+ /// never risks the allocation it is asserting never happens.
+ #[test]
+ fn a_canvas_over_the_byte_budget_is_rejected_without_allocating_it() {
+ let path = std::env::temp_dir().join(format!(
+ "rustmotion_gif_bomb_budget_{}_{}.gif",
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_nanos(),
+ ));
+ write_oversized_header_gif(&path, 6000, 6000);
+
+ let result = decode_composed_frames(path.to_str().expect("utf-8"), 8192, 8192);
+ std::fs::remove_file(&path).ok();
+
+ assert!(
+ result.is_none(),
+ "a 144 MiB canvas must be refused, not decoded"
+ );
+ assert!(
+ !crate::warn_once_for(&format!("gif-oversized:{}", path.to_str().unwrap())),
+ "the rejection must have reported once"
+ );
+ }
+
+ /// The other half of the same guard: a canvas larger than the render's
+ /// own video dimensions can never be usefully drawn either. A
+ /// 3000×2000 canvas is only 24 MiB (well under the byte budget alone)
+ /// but bigger than the render's own 1920×1080 video, so this isolates
+ /// the video-dimensions check from the byte-budget one above.
+ #[test]
+ fn a_canvas_larger_than_the_video_is_rejected_even_under_the_byte_budget() {
+ let path = std::env::temp_dir().join(format!(
+ "rustmotion_gif_bomb_dims_{}_{}.gif",
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_nanos(),
+ ));
+ write_oversized_header_gif(&path, 3000, 2000);
+
+ let result = decode_composed_frames(path.to_str().expect("utf-8"), 1920, 1080);
+ std::fs::remove_file(&path).ok();
+
+ assert!(
+ result.is_none(),
+ "a canvas bigger than the video's own dimensions must be refused"
+ );
+ }
+
+ /// Writes `count` tiny (2×2) frames — cheap regardless of `count`, so
+ /// the frame-count cap can be tested without the per-frame size mattering.
+ fn write_many_frame_gif(path: &std::path::Path, count: u32) {
+ let palette: &[u8] = &[0, 0, 0, 255, 255, 255];
+ let mut file = std::fs::File::create(path).expect("create gif fixture");
+ let mut encoder = gif::Encoder::new(&mut file, 2, 2, palette).expect("gif encoder");
+ for _ in 0..count {
+ let mut frame = gif::Frame::from_indexed_pixels(2, 2, vec![0, 1, 1, 0], None);
+ frame.delay = 1;
+ encoder.write_frame(&frame).expect("write frame");
+ }
+ }
+
+ /// The decode loop had no frame-count limit at all — a
+ /// small canvas with a pathologically large frame count still grows the
+ /// decoded animation without bound. `MAX_GIF_FRAMES` truncates it
+ /// instead of rejecting the whole GIF: a real, merely-too-long animation
+ /// still plays (up to the cap), it just doesn't keep growing memory.
+ #[test]
+ fn frame_count_beyond_the_cap_is_truncated_not_unbounded() {
+ let path = std::env::temp_dir().join(format!(
+ "rustmotion_gif_many_frames_{}_{}.gif",
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_nanos(),
+ ));
+ write_many_frame_gif(&path, MAX_GIF_FRAMES as u32 + 50);
+
+ let (frames, times, _total) = decode_composed_frames(path.to_str().expect("utf-8"), 10, 10)
+ .expect("a small, merely-long gif must still decode");
+ std::fs::remove_file(&path).ok();
+
+ assert_eq!(
+ frames.len(),
+ MAX_GIF_FRAMES,
+ "frame count must be truncated to the cap, not grow past it"
+ );
+ assert_eq!(times.len(), MAX_GIF_FRAMES);
+ }
}
diff --git a/crates/rustmotion/src/cli/commands/render.rs b/crates/rustmotion/src/cli/commands/render.rs
index 2c6a11da..53fd6fd2 100644
--- a/crates/rustmotion/src/cli/commands/render.rs
+++ b/crates/rustmotion/src/cli/commands/render.rs
@@ -8,6 +8,20 @@ use std::path::{Path, PathBuf};
use crate::cli::commands::validation::{self, ValidationSource};
+/// Every process-global decode cache a `--watch` iteration must forget
+/// before re-rendering, so an edited asset is never served from a stale
+/// entry keyed only on its path. `ASSET_CACHE` already had a public
+/// clear function; `GIF_CACHE`/`VIDEO_FRAME_CACHE` did not, so those two are
+/// cleared here by calling `DashMap::clear()` on the map `gif_cache()`/
+/// `video_frame_cache()` already return, rather than adding new functions to
+/// `rustmotion-core`'s `engine::renderer::assets` (owned by a sibling
+/// workstream in this chantier).
+fn clear_all_media_caches() {
+ engine::clear_asset_cache();
+ engine::gif_cache().clear();
+ engine::video_frame_cache().clear();
+}
+
/// Load + validate a scenario for watch mode. On validation failure prints the
/// report and returns the typed error so the caller can decide how to handle it.
///
@@ -373,7 +387,7 @@ pub fn cmd_watch(
Err(e) => eprintln!("Render error: {}", e),
}
} else {
- engine::clear_asset_cache();
+ clear_all_media_caches();
if let Err(e) = cmd_render(
scenario,
output,
@@ -430,6 +444,8 @@ pub fn cmd_watch(
match load_for_watch(input, no_validate, lenient, strict_anim, strict_attrs) {
Ok(scenario) => {
+ clear_all_media_caches();
+
// Reset error backoff on a successful load
if consecutive_err_count > 0 && suppressed {
eprintln!("Recovered from previous errors.");
@@ -452,7 +468,6 @@ pub fn cmd_watch(
let use_prev = if prev_config_hash == Some(config_hash) {
prev_segments.as_deref()
} else {
- engine::clear_asset_cache();
None
};
@@ -528,7 +543,6 @@ pub fn cmd_watch(
Err(e) => eprintln!("Render error: {}", e),
}
} else {
- engine::clear_asset_cache();
if let Err(e) = cmd_render(
scenario,
output,
@@ -596,3 +610,58 @@ fn render_single_frame(
img.save(output)?;
Ok(())
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use rustmotion_core::engine::renderer::{asset_cache, gif_cache, video_frame_cache};
+
+ fn unique_marker(label: &str) -> String {
+ format!(
+ "rustmotion-audit-ws-d-rm25-{label}-{}-{}",
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .expect("system clock before UNIX epoch")
+ .as_nanos()
+ )
+ }
+
+ /// `--watch` only ever called `engine::clear_asset_cache()`, and
+ /// only conditionally in the incremental branch (when the video config
+ /// hash changed). `GIF_CACHE`/`VIDEO_FRAME_CACHE` had no clear function
+ /// at all, so an edited GIF or embedded video stayed stale for the rest
+ /// of a `--watch` session no matter how many times the source file
+ /// changed. This populates all three caches with markers unique to this
+ /// test run — safe against the other tests in this binary that share the
+ /// same process-global caches — and asserts a single call clears every
+ /// one of them, not just the asset cache.
+ #[test]
+ fn clear_all_media_caches_clears_gif_and_video_caches_not_just_images() {
+ let marker = unique_marker("clear-all");
+
+ let mut surface = skia_safe::surfaces::raster_n32_premul((1, 1)).expect("raster surface");
+ asset_cache().insert(marker.clone(), surface.image_snapshot());
+ gif_cache().insert(
+ marker.clone(),
+ std::sync::Arc::new((Vec::new(), Vec::new(), 0.0)),
+ );
+ video_frame_cache().insert(marker.clone(), std::sync::Arc::new(Vec::new()));
+
+ assert!(asset_cache().contains_key(&marker));
+ assert!(gif_cache().contains_key(&marker));
+ assert!(video_frame_cache().contains_key(&marker));
+
+ clear_all_media_caches();
+
+ assert!(!asset_cache().contains_key(&marker));
+ assert!(
+ !gif_cache().contains_key(&marker),
+ "gif cache must be cleared too, not just the asset cache"
+ );
+ assert!(
+ !video_frame_cache().contains_key(&marker),
+ "video frame cache must be cleared too, not just the asset cache"
+ );
+ }
+}
From 9890365bd95be1dca2b53324bead964517830f50 Mon Sep 17 00:00:00 2001
From: Baptiste Parmantier
Date: Tue, 22 Sep 2026 11:00:33 +0200
Subject: [PATCH 04/25] fix(video): loop the picture with the audio (#245)
Refs #220
---
crates/rustmotion-components/src/video.rs | 110 +++++++++-
.../rustmotion-components/tests/audit_ws_d.rs | 188 ++++++++++++++++++
crates/rustmotion/src/cli/commands/batch.rs | 35 +++-
crates/rustmotion/tests/audit_ws_d.rs | 170 +++++++++++++++-
4 files changed, 481 insertions(+), 22 deletions(-)
create mode 100644 crates/rustmotion-components/tests/audit_ws_d.rs
diff --git a/crates/rustmotion-components/src/video.rs b/crates/rustmotion-components/src/video.rs
index 0d64739f..d76cf0fc 100644
--- a/crates/rustmotion-components/src/video.rs
+++ b/crates/rustmotion-components/src/video.rs
@@ -6,7 +6,7 @@ use rustmotion_core::css::CssStyle;
use rustmotion_core::engine::animator::AnimatedProperties;
use rustmotion_core::engine::layout_pass::BoxLayout;
use rustmotion_core::engine::renderer::{
- extract_video_frame, find_closest_frame, video_frame_cache,
+ extract_video_frame, find_closest_frame, probe_video_metadata, video_frame_cache,
};
use rustmotion_core::schema::{ImageFit, TimelineStep};
use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
@@ -46,6 +46,102 @@ rustmotion_core::impl_traits!(Video {
Styled => style,
});
+/// The rectangle an `img_w`×`img_h` source draws into to honour `fit` inside
+/// a `target_w`×`target_h` box — the same three CSS `object-fit` semantics
+/// `image.rs`'s painter already implements for the `image` component.
+fn fit_rect(fit: &ImageFit, img_w: f32, img_h: f32, target_w: f32, target_h: f32) -> Rect {
+ match fit {
+ ImageFit::Fill => Rect::from_xywh(0.0, 0.0, target_w, target_h),
+ ImageFit::Contain => {
+ let scale = (target_w / img_w).min(target_h / img_h);
+ let w = img_w * scale;
+ let h = img_h * scale;
+ Rect::from_xywh((target_w - w) / 2.0, (target_h - h) / 2.0, w, h)
+ }
+ ImageFit::Cover => {
+ let scale = (target_w / img_w).max(target_h / img_h);
+ let w = img_w * scale;
+ let h = img_h * scale;
+ Rect::from_xywh((target_w - w) / 2.0, (target_h - h) / 2.0, w, h)
+ }
+ }
+}
+
+/// Draws `img` into `layout`'s box according to `fit`, clipping to the box
+/// for `Cover` (the only mode whose fitted rectangle can extend past it).
+fn draw_fitted(canvas: &Canvas, img: skia_safe::Image, fit: &ImageFit, layout: &BoxLayout) {
+ let dst = fit_rect(
+ fit,
+ img.width() as f32,
+ img.height() as f32,
+ layout.width,
+ layout.height,
+ );
+ let paint = Paint::default();
+ if matches!(fit, ImageFit::Cover) {
+ canvas.save();
+ canvas.clip_rect(
+ Rect::from_xywh(0.0, 0.0, layout.width, layout.height),
+ skia_safe::ClipOp::Intersect,
+ true,
+ );
+ canvas.draw_image_rect(img, None, dst, &paint);
+ canvas.restore();
+ } else {
+ canvas.draw_image_rect(img, None, dst, &paint);
+ }
+}
+
+/// The source clip's own duration, probed via `ffprobe` and memoized per
+/// `src` for the life of the process — `effective_source_time` below is
+/// called once per painted frame, and re-probing on every one of them would
+/// mean one subprocess spawn per frame for any looping video. `None` on a
+/// probe failure (no ffprobe on `PATH`, or the source can't be read) is
+/// memoized too, so a broken source fails fast on every subsequent frame
+/// instead of retrying the same failing probe.
+fn video_duration_secs(src: &str) -> Option {
+ static CACHE: std::sync::OnceLock<
+ std::sync::Mutex>>,
+ > = std::sync::OnceLock::new();
+ let cache = CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()));
+
+ if let Some(hit) = cache
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ .get(src)
+ {
+ return *hit;
+ }
+ let probed = probe_video_metadata(src).ok().map(|p| p.duration_secs);
+ cache
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ .insert(src.to_string(), probed);
+ probed
+}
+
+impl Video {
+ /// The timestamp to sample from the source clip for a given scene time.
+ /// When `loop_video` is set, playback wraps within the source's own
+ /// probed duration instead of running past it and holding on
+ /// whatever the last extractable frame happens to be.
+ fn effective_source_time(&self, ctx_time: f64) -> f64 {
+ let rate = self.playback_rate.unwrap_or(1.0);
+ let trim_start = self.trim_start.unwrap_or(0.0);
+ let raw = trim_start + ctx_time * rate;
+
+ if self.loop_video == Some(true) {
+ if let Some(duration) = video_duration_secs(&self.src) {
+ if duration > trim_start {
+ return trim_start + (raw - trim_start).rem_euclid(duration - trim_start);
+ }
+ }
+ }
+
+ raw
+ }
+}
+
impl Painter for Video {
fn paint_content(
&self,
@@ -54,9 +150,7 @@ impl Painter for Video {
_props: &AnimatedProperties,
ctx: &PaintCtx,
) {
- let rate = self.playback_rate.unwrap_or(1.0);
- let trim_start = self.trim_start.unwrap_or(0.0);
- let source_time = trim_start + ctx.time * rate;
+ let source_time = self.effective_source_time(ctx.time);
let width = layout.width as u32;
let height = layout.height as u32;
@@ -74,9 +168,7 @@ impl Painter for Video {
let row_bytes = fw as usize * 4;
let data = skia_safe::Data::new_copy(rgba);
if let Some(img) = skia_safe::images::raster_from_data(&img_info, data, row_bytes) {
- let dst = Rect::from_xywh(0.0, 0.0, layout.width, layout.height);
- let paint = Paint::default();
- canvas.draw_image_rect(img, None, dst, &paint);
+ draw_fitted(canvas, img, &self.fit, layout);
}
return;
}
@@ -105,9 +197,7 @@ impl Painter for Video {
};
let skia_data = skia_safe::Data::new_copy(&frame_data);
if let Some(img) = skia_safe::Image::from_encoded(skia_data) {
- let dst = Rect::from_xywh(0.0, 0.0, layout.width, layout.height);
- let paint = Paint::default();
- canvas.draw_image_rect(img, None, dst, &paint);
+ draw_fitted(canvas, img, &self.fit, layout);
}
}
}
diff --git a/crates/rustmotion-components/tests/audit_ws_d.rs b/crates/rustmotion-components/tests/audit_ws_d.rs
new file mode 100644
index 00000000..3cb43373
--- /dev/null
+++ b/crates/rustmotion-components/tests/audit_ws_d.rs
@@ -0,0 +1,188 @@
+//! Regression tests for the `video` component's dead-field fixes: `fit`,
+//! `trim_end`, `loop_video`, and the straight-vs-premultiplied alpha bug on
+//! its cached-frame draw path.
+//!
+//! Every case populates `video_frame_cache()` directly with hand-built RGBA
+//! frames rather than shelling out to a real ffmpeg decode: the field this
+//! module exercises (`Video::paint_content`) is one call away from the
+//! cache, and driving it that way keeps these tests hermetic and fast while
+//! still going through the real, public `Painter` implementation — no
+//! private items from `rustmotion-components` are touched.
+
+use std::sync::Arc;
+
+use rustmotion_components::Video;
+use rustmotion_core::css::CssStyle;
+use rustmotion_core::engine::animator::AnimatedProperties;
+use rustmotion_core::engine::layout_pass::BoxLayout;
+use rustmotion_core::engine::renderer::video_frame_cache;
+use rustmotion_core::schema::ImageFit;
+use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
+
+fn unique_src(label: &str) -> String {
+ format!(
+ "audit-ws-d-video-{label}-{}-{}",
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .expect("system clock before UNIX epoch")
+ .as_nanos()
+ )
+}
+
+#[allow(clippy::too_many_arguments)]
+fn video(
+ src: &str,
+ fit: ImageFit,
+ trim_start: Option,
+ trim_end: Option,
+ loop_video: Option,
+) -> Video {
+ Video {
+ src: src.to_string(),
+ trim_start,
+ trim_end,
+ playback_rate: None,
+ fit,
+ volume: 1.0,
+ loop_video,
+ timing: TimingConfig::default(),
+ style: CssStyle::default(),
+ timeline: Vec::new(),
+ stagger: None,
+ }
+}
+
+fn ctx_at(time: f64) -> PaintCtx {
+ PaintCtx {
+ time,
+ scenario_time: time,
+ scene_duration: 10.0,
+ frame_index: 0,
+ fps: 30,
+ video_width: 1920,
+ video_height: 1080,
+ stagger_offset: 0.0,
+ }
+}
+
+fn solid_rgba(color: [u8; 4], w: u32, h: u32) -> Vec {
+ let mut buf = Vec::with_capacity((w * h * 4) as usize);
+ for _ in 0..(w * h) {
+ buf.extend_from_slice(&color);
+ }
+ buf
+}
+
+/// Paints `video` into a fresh `w`×`h` surface (background transparent if
+/// `transparent_bg`, opaque black otherwise) and reads the composited pixels
+/// back as straight (unpremultiplied) RGBA.
+fn paint_and_read(video: &Video, ctx: &PaintCtx, w: i32, h: i32, transparent_bg: bool) -> Vec {
+ let mut surface = skia_safe::surfaces::raster_n32_premul((w, h)).expect("raster surface");
+ let bg = if transparent_bg {
+ skia_safe::Color4f::new(0.0, 0.0, 0.0, 0.0)
+ } else {
+ skia_safe::Color4f::new(0.0, 0.0, 0.0, 1.0)
+ };
+ surface.canvas().clear(bg);
+
+ let layout = BoxLayout {
+ width: w as f32,
+ height: h as f32,
+ ..Default::default()
+ };
+ let props = AnimatedProperties::default();
+ video.paint_content(surface.canvas(), &layout, &props, ctx);
+
+ let mut pixels = vec![0u8; (w * h * 4) as usize];
+ let info = skia_safe::ImageInfo::new(
+ (w, h),
+ skia_safe::ColorType::RGBA8888,
+ skia_safe::AlphaType::Unpremul,
+ None,
+ );
+ surface.read_pixels(&info, &mut pixels, (w * 4) as usize, (0, 0));
+ pixels
+}
+
+fn px(buf: &[u8], w: i32, x: i32, y: i32) -> [u8; 4] {
+ let i = ((y * w + x) * 4) as usize;
+ buf[i..i + 4].try_into().expect("pixel in bounds")
+}
+
+// ─── `fit` was declared, documented, and never read ────────────────────────
+
+/// A 10×20 source into a 40×40 box under `contain` must letterbox — scale
+/// is `min(40/10, 40/40) = 1`, so the drawn region is 10 wide, centred with
+/// a 15px empty margin on each side. Before the fix, the painter always
+/// stretched to the full box regardless of `fit`, so every pixel — margins
+/// included — came out opaque.
+#[test]
+fn contain_fit_letterboxes_instead_of_stretching() {
+ let src = unique_src("fit-contain");
+ let (fw, fh) = (10u32, 20u32);
+ let cache_key = format!("{src}:40x40");
+ video_frame_cache().insert(
+ cache_key,
+ Arc::new(vec![(
+ 0.0,
+ solid_rgba([255, 255, 255, 255], fw, fh),
+ fw,
+ fh,
+ )]),
+ );
+
+ let v = video(&src, ImageFit::Contain, None, None, None);
+ let ctx = ctx_at(0.0);
+ let pixels = paint_and_read(&v, &ctx, 40, 40, true);
+
+ assert_eq!(
+ px(&pixels, 40, 0, 20)[3],
+ 0,
+ "letterboxed left margin must stay empty, not be stretched into"
+ );
+ assert_eq!(
+ px(&pixels, 40, 39, 20)[3],
+ 0,
+ "letterboxed right margin must stay empty, not be stretched into"
+ );
+ assert_eq!(
+ px(&pixels, 40, 20, 20),
+ [255, 255, 255, 255],
+ "the drawn column itself must still be opaque"
+ );
+}
+
+/// `fill` (the CSS default `object-fit: fill` behaviour) must still stretch
+/// to cover the whole box exactly as before — the fix must not regress the
+/// one mode that already matched the pre-fix behaviour.
+#[test]
+fn fill_fit_still_stretches_to_the_whole_box() {
+ let src = unique_src("fit-fill");
+ let (fw, fh) = (10u32, 20u32);
+ let cache_key = format!("{src}:40x40");
+ video_frame_cache().insert(
+ cache_key,
+ Arc::new(vec![(
+ 0.0,
+ solid_rgba([255, 255, 255, 255], fw, fh),
+ fw,
+ fh,
+ )]),
+ );
+
+ let v = video(&src, ImageFit::Fill, None, None, None);
+ let ctx = ctx_at(0.0);
+ let pixels = paint_and_read(&v, &ctx, 40, 40, true);
+
+ assert_eq!(
+ px(&pixels, 40, 0, 0)[3],
+ 255,
+ "fill must cover every corner"
+ );
+ assert_eq!(
+ px(&pixels, 40, 39, 39)[3],
+ 255,
+ "fill must cover every corner"
+ );
+}
diff --git a/crates/rustmotion/src/cli/commands/batch.rs b/crates/rustmotion/src/cli/commands/batch.rs
index b8ec3643..e68d2f20 100644
--- a/crates/rustmotion/src/cli/commands/batch.rs
+++ b/crates/rustmotion/src/cli/commands/batch.rs
@@ -71,11 +71,14 @@ pub(crate) fn resolve_name_template(
out.push_str(&replacement);
cursor = start + end + 1; // skip past '}'
} else {
- out.push(bytes[cursor] as char);
- cursor += 1;
+ let ch = template[cursor..].chars().next().expect(
+ "cursor sits on a UTF-8 char boundary: every branch above advances it either \
+ past an ASCII '{'/'}' byte or by a full char's own byte length",
+ );
+ out.push(ch);
+ cursor += ch.len_utf8();
}
}
- let _ = bytes;
Ok(out)
}
@@ -492,6 +495,32 @@ mod name_template_tests {
"static.mp4"
);
}
+
+ /// The literal (non-`{field}`) text of the template used to be walked
+ /// byte-by-byte and each byte cast straight to `char` — a Latin-1
+ /// reinterpretation of whatever UTF-8 continuation bytes an accented
+ /// character produced. `é` is `0xC3 0xA9` in UTF-8; cast individually
+ /// that becomes `é`, exactly the corruption this asserts is gone.
+ #[test]
+ fn accented_literal_text_round_trips() {
+ let data = row(&[("id", json!("abc"))]);
+ assert_eq!(
+ resolve_name_template("résumé-{id}.mp4", &data, 0).unwrap(),
+ "résumé-abc.mp4"
+ );
+ }
+
+ /// A non-Latin script exercises characters that are more than two UTF-8
+ /// bytes wide, where a byte-at-a-time cast produces even more mangled
+ /// output than the two-byte Latin-1 case above.
+ #[test]
+ fn cjk_literal_text_round_trips() {
+ let data = row(&[("id", json!("1"))]);
+ assert_eq!(
+ resolve_name_template("动画-{id}.mp4", &data, 0).unwrap(),
+ "动画-1.mp4"
+ );
+ }
}
#[cfg(test)]
diff --git a/crates/rustmotion/tests/audit_ws_d.rs b/crates/rustmotion/tests/audit_ws_d.rs
index 36c6a6a1..23c484e7 100644
--- a/crates/rustmotion/tests/audit_ws_d.rs
+++ b/crates/rustmotion/tests/audit_ws_d.rs
@@ -1,10 +1,29 @@
//! Regression tests for the media-decoding, frame-cache, and render/batch
//! CLI hardening pass on this branch.
//!
-//! Video frame preextraction (`preload::preextract_video_frames`) is
-//! exercised through the crate's public `rustmotion::engine::preload`
-//! surface directly — no subprocess needed, since the budget math and the
-//! cache it guards are both `pub`.
+//! Two kinds of coverage land here:
+//!
+//! - Video frame preextraction (`preload::preextract_video_frames`) is
+//! exercised through the crate's public `rustmotion::engine::preload`
+//! surface directly — no subprocess needed, since the budget math and the
+//! cache it guards are both `pub`.
+//! - The batch `--name-template` byte-vs-char bug
+//! (`cli::commands::batch::resolve_name_template`) cannot be reached this
+//! way: `cli::commands` is a private module (`mod commands;` in
+//! `src/cli/mod.rs`), so — mirroring `audit_ws_b.rs`'s reasoning for the
+//! same constraint — the only externally-observable contract is the
+//! compiled binary itself, driven as a subprocess.
+//!
+//! The GIF cache-stampede/decompression-bomb caps and the video component's
+//! dead-field fixes (`fit`, `loop_video`, `trim_end`, straight-vs-premultiplied
+//! alpha) live in `rustmotion-components`'s own test suite instead — see
+//! that crate's `audit_ws_d.rs` and `gif.rs`'s in-file `mod tests`. The
+//! `--watch` cache-clearing fix needs a private helper in
+//! `cli::commands::render` and lives in that file's own `mod tests` for the
+//! same private-module reason as the name-template test above.
+
+use std::path::{Path, PathBuf};
+use std::process::{Command, Output};
use rustmotion::engine::preload::{
video_frame_byte_size, would_exceed_cache_budget, VIDEO_FRAME_CACHE_BUDGET_BYTES,
@@ -23,11 +42,10 @@ fn frame_byte_size_matches_plain_multiplication_for_ordinary_dimensions() {
/// The secondary hazard this closes: `width * height * 4` in plain `u32`
/// wraps for a large-enough declared size (65536×16384 wraps to 0, which
-/// used to turn into a division by zero downstream). `u32::MAX` on both
-/// dimensions is the most extreme case reachable from a `style.width`/
-/// `style.height` pair — the fixed computation must saturate to `u64::MAX`,
-/// not wrap to some small number that would slip past the budget check
-/// below.
+/// used to turn into a division by zero downstream). `u32::MAX` on both dimensions
+/// is the most extreme case reachable from a `style.width`/`style.height`
+/// pair — the fixed computation must saturate to `u64::MAX`, not wrap to
+/// some small number that would slip past the budget check below.
#[test]
fn frame_byte_size_saturates_instead_of_wrapping_on_extreme_dimensions() {
let huge = video_frame_byte_size(u32::MAX, u32::MAX);
@@ -61,3 +79,137 @@ fn would_exceed_cache_budget_rejects_only_once_the_sum_crosses_the_ceiling() {
"saturating add must not wrap past the ceiling"
);
}
+
+// ─── `batch --name-template` must not mangle non-ASCII bytes ──────────────
+
+/// Minimal RAII scratch directory, mirroring `audit_ws_b.rs`'s `ScratchDir`.
+struct ScratchDir(PathBuf);
+
+impl ScratchDir {
+ fn new(label: &str) -> Self {
+ let unique = format!(
+ "rustmotion-audit-ws-d-{label}-{}-{}",
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .expect("system clock before UNIX epoch")
+ .as_nanos()
+ );
+ let path = std::env::temp_dir().join(unique);
+ std::fs::create_dir_all(&path).expect("create scratch dir");
+ Self(path)
+ }
+}
+
+impl Drop for ScratchDir {
+ fn drop(&mut self) {
+ let _ = std::fs::remove_dir_all(&self.0);
+ }
+}
+
+fn write_file(dir: &Path, name: &str, contents: &str) -> PathBuf {
+ let path = dir.join(name);
+ std::fs::write(&path, contents).expect("write scratch file");
+ path
+}
+
+fn run_batch(file: &Path, data: &Path, output_dir: &Path, name_template: &str) -> Output {
+ Command::new(env!("CARGO_BIN_EXE_rustmotion"))
+ .arg("--quiet")
+ .arg("batch")
+ .arg("--file")
+ .arg(file)
+ .arg("--data")
+ .arg(data)
+ .arg("--output-dir")
+ .arg(output_dir)
+ .arg("--name-template")
+ .arg(name_template)
+ .arg("--format")
+ .arg("png-seq")
+ .output()
+ .expect("failed to spawn `rustmotion batch`")
+}
+
+/// `resolve_name_template` used to walk the template as raw bytes and
+/// cast each one to `char` — a Latin-1 reinterpretation that mangles every
+/// non-ASCII byte: `résumé-{id}.mp4` became `résumé-VAL.mp4` on disk.
+/// `batch`'s own module doc gives `{lang}/{id}
+/// .mp4` as the flagship use case for `--name-template`, which is exactly
+/// the localisation scenario most likely to carry accents.
+///
+/// Driven as a subprocess rather than calling `resolve_name_template`
+/// directly: it is `pub(crate)` inside the private `cli::commands::batch`
+/// module, unreachable from an external integration test.
+#[test]
+fn batch_name_template_round_trips_accented_characters_on_disk() {
+ let scratch = ScratchDir::new("rm30");
+ let template = write_file(
+ &scratch.0,
+ "template.json",
+ &serde_json::json!({
+ "config": { "id": { "type": "string", "default": "x" } },
+ "video": { "width": 32, "height": 32, "fps": 1 },
+ "scenes": [{ "duration": 1.0, "children": [] }]
+ })
+ .to_string(),
+ );
+ let data = write_file(
+ &scratch.0,
+ "data.jsonl",
+ &serde_json::json!({"id": "abc"}).to_string(),
+ );
+ let output_dir = scratch.0.join("out");
+ std::fs::create_dir_all(&output_dir).expect("create output dir");
+
+ let result = run_batch(&template, &data, &output_dir, "résumé-{id}.png");
+
+ assert!(
+ result.status.success(),
+ "batch must succeed: stdout={}\nstderr={}",
+ String::from_utf8_lossy(&result.stdout),
+ String::from_utf8_lossy(&result.stderr)
+ );
+
+ let expected = output_dir.join("résumé-abc.png");
+ assert!(
+ expected.is_dir(),
+ "expected an accent-preserving output directory at {}, found instead: {:?}",
+ expected.display(),
+ std::fs::read_dir(&output_dir)
+ .map(|entries| entries
+ .filter_map(|e| e.ok().map(|e| e.file_name()))
+ .collect::>())
+ .unwrap_or_default()
+ );
+ assert!(expected.join("frame_00000.png").exists());
+}
+
+/// A template with no non-ASCII content must still resolve exactly as
+/// before — the fix changes how a byte becomes a `char`, not the loop's
+/// control flow (the `{`/`}` brace scan is untouched).
+#[test]
+fn batch_name_template_plain_ascii_is_unaffected() {
+ let scratch = ScratchDir::new("rm30-ascii");
+ let template = write_file(
+ &scratch.0,
+ "template.json",
+ &serde_json::json!({
+ "config": { "id": { "type": "string", "default": "x" } },
+ "video": { "width": 32, "height": 32, "fps": 1 },
+ "scenes": [{ "duration": 1.0, "children": [] }]
+ })
+ .to_string(),
+ );
+ let data = write_file(
+ &scratch.0,
+ "data.jsonl",
+ &serde_json::json!({"id": "abc"}).to_string(),
+ );
+ let output_dir = scratch.0.join("out");
+ std::fs::create_dir_all(&output_dir).expect("create output dir");
+
+ let result = run_batch(&template, &data, &output_dir, "clip-{id}.png");
+ assert!(result.status.success());
+ assert!(output_dir.join("clip-abc.png").is_dir());
+}
From 7298fd82e2cdcf311d44e19db21d7a555b2f0ee0 Mon Sep 17 00:00:00 2001
From: Baptiste Parmantier
Date: Tue, 22 Sep 2026 11:00:53 +0200
Subject: [PATCH 05/25] fix(video): treat decoded frames as unpremultiplied
(#247)
Refs #220
---
crates/rustmotion-components/src/gif.rs | 6 +-
crates/rustmotion-components/src/video.rs | 32 +++--
.../rustmotion-components/tests/audit_ws_d.rs | 135 ++++++++++++++++++
3 files changed, 161 insertions(+), 12 deletions(-)
diff --git a/crates/rustmotion-components/src/gif.rs b/crates/rustmotion-components/src/gif.rs
index b5698604..5a0d918d 100644
--- a/crates/rustmotion-components/src/gif.rs
+++ b/crates/rustmotion-components/src/gif.rs
@@ -455,9 +455,9 @@ mod tests {
/// own video is, so passing generous `max_w`/`max_h` here isolates the
/// byte-budget check from the video-dimensions check exercised by the
/// next test. 144 MiB is deliberately far short of the 65535×65535
- /// (~17 GiB) header a real crafted file could declare — large enough to
- /// prove the budget check fires, small enough that running this test
- /// never risks the allocation it is asserting never happens.
+ /// (~17 GiB) header the audit's own crafted file could declare — large
+ /// enough to prove the budget check fires, small enough that running
+ /// this test never risks the allocation it is asserting never happens.
#[test]
fn a_canvas_over_the_byte_budget_is_rejected_without_allocating_it() {
let path = std::env::temp_dir().join(format!(
diff --git a/crates/rustmotion-components/src/video.rs b/crates/rustmotion-components/src/video.rs
index d76cf0fc..cae7b018 100644
--- a/crates/rustmotion-components/src/video.rs
+++ b/crates/rustmotion-components/src/video.rs
@@ -95,10 +95,10 @@ fn draw_fitted(canvas: &Canvas, img: skia_safe::Image, fit: &ImageFit, layout: &
/// The source clip's own duration, probed via `ffprobe` and memoized per
/// `src` for the life of the process — `effective_source_time` below is
/// called once per painted frame, and re-probing on every one of them would
-/// mean one subprocess spawn per frame for any looping video. `None` on a
-/// probe failure (no ffprobe on `PATH`, or the source can't be read) is
-/// memoized too, so a broken source fails fast on every subsequent frame
-/// instead of retrying the same failing probe.
+/// mean one subprocess spawn per frame for any looping video with no
+/// explicit `trim_end`. `None` on a probe failure (no ffprobe on `PATH`, or
+/// the source can't be read) is memoized too, so a broken source fails fast
+/// on every subsequent frame instead of retrying the same failing probe.
fn video_duration_secs(src: &str) -> Option {
static CACHE: std::sync::OnceLock<
std::sync::Mutex>>,
@@ -122,15 +122,29 @@ fn video_duration_secs(src: &str) -> Option {
impl Video {
/// The timestamp to sample from the source clip for a given scene time.
- /// When `loop_video` is set, playback wraps within the source's own
- /// probed duration instead of running past it and holding on
- /// whatever the last extractable frame happens to be.
+ ///
+ /// Honours `trim_end` on the picture the same way the audio track
+ /// already does: past `trim_end`, playback holds on the last in-window
+ /// frame instead of continuing to draw whatever the source contains
+ /// beyond the intended trim point. When `loop_video` is set, playback
+ /// wraps within `[trim_start, trim_end)` instead of clamping — falling
+ /// back to the source's own probed duration as the loop window only
+ /// when `trim_end` is absent, since that is the only case where the
+ /// window cannot otherwise be known at all.
fn effective_source_time(&self, ctx_time: f64) -> f64 {
let rate = self.playback_rate.unwrap_or(1.0);
let trim_start = self.trim_start.unwrap_or(0.0);
let raw = trim_start + ctx_time * rate;
- if self.loop_video == Some(true) {
+ if let Some(end) = self.trim_end {
+ if end > trim_start {
+ return if self.loop_video == Some(true) {
+ trim_start + (raw - trim_start).rem_euclid(end - trim_start)
+ } else {
+ raw.min(end)
+ };
+ }
+ } else if self.loop_video == Some(true) {
if let Some(duration) = video_duration_secs(&self.src) {
if duration > trim_start {
return trim_start + (raw - trim_start).rem_euclid(duration - trim_start);
@@ -162,7 +176,7 @@ impl Painter for Video {
let img_info = ImageInfo::new(
(fw as i32, fh as i32),
ColorType::RGBA8888,
- skia_safe::AlphaType::Premul,
+ skia_safe::AlphaType::Unpremul,
None,
);
let row_bytes = fw as usize * 4;
diff --git a/crates/rustmotion-components/tests/audit_ws_d.rs b/crates/rustmotion-components/tests/audit_ws_d.rs
index 3cb43373..7ac9dbd8 100644
--- a/crates/rustmotion-components/tests/audit_ws_d.rs
+++ b/crates/rustmotion-components/tests/audit_ws_d.rs
@@ -186,3 +186,138 @@ fn fill_fit_still_stretches_to_the_whole_box() {
"fill must cover every corner"
);
}
+
+// ─── `trim_end` was honoured only on the extracted audio ───────────────────
+
+/// Frames beyond `trim_end` sit in the cache (simulating a preextraction
+/// window, or a direct extraction, wider than the intended trim), so the
+/// picture path must never pick one of them once `trim_end` is set: past
+/// `trim_end`, playback holds on the last in-window frame. Before the fix,
+/// `source_time` had no upper bound at all — querying past `trim_end` on a
+/// cache/source that extends further would draw whatever sits further
+/// along the source, not the frame at the trim boundary.
+#[test]
+fn trim_end_clamps_playback_instead_of_running_past_it() {
+ let src = unique_src("trimend");
+ let cache_key = format!("{src}:20x20");
+ let frames = vec![
+ (0.0, solid_rgba([255, 0, 0, 255], 2, 2), 2, 2),
+ (0.5, solid_rgba([0, 0, 255, 255], 2, 2), 2, 2),
+ (1.0, solid_rgba([0, 255, 0, 255], 2, 2), 2, 2),
+ (1.5, solid_rgba([128, 0, 128, 255], 2, 2), 2, 2),
+ (2.0, solid_rgba([255, 165, 0, 255], 2, 2), 2, 2),
+ ];
+ video_frame_cache().insert(cache_key, Arc::new(frames));
+
+ let v = video(&src, ImageFit::Fill, Some(0.0), Some(1.0), None);
+ let ctx = ctx_at(1.8);
+ let pixels = paint_and_read(&v, &ctx, 20, 20, false);
+
+ assert_eq!(
+ px(&pixels, 20, 10, 10),
+ [0, 255, 0, 255],
+ "past trim_end, playback must clamp to the frame at trim_end (green), not the frame \
+ nearest the unclamped query time (orange)"
+ );
+}
+
+// ─── `loop_video` made neither the picture nor the audio loop ─────────────
+
+/// Cache frames only cover `[0.0, 1.0)`; `trim_end: Some(1.0)` gives
+/// `loop_video` a window to wrap within without needing a real source file
+/// to probe. Querying at `ctx.time = 2.1` (raw source time 2.1s, i.e. "2
+/// full loops plus 0.1s") must land near 0.1s once wrapped — nearest to
+/// that among `{0.0, 0.25, 0.5, 0.75}` is red. Before this fix, the same
+/// query — with the trim-end clamp from the previous test already in place
+/// but no loop branch yet — clamped to `min(2.1, 1.0) = 1.0`, whose nearest
+/// cached frame is yellow: a clearly different pixel, which is what proves
+/// this test is exercising the loop path and not being masked by the clamp.
+#[test]
+fn loop_video_wraps_playback_within_the_trim_window() {
+ let src = unique_src("loop");
+ let cache_key = format!("{src}:20x20");
+ let frames = vec![
+ (0.0, solid_rgba([255, 0, 0, 255], 2, 2), 2, 2),
+ (0.25, solid_rgba([0, 255, 0, 255], 2, 2), 2, 2),
+ (0.5, solid_rgba([0, 0, 255, 255], 2, 2), 2, 2),
+ (0.75, solid_rgba([255, 255, 0, 255], 2, 2), 2, 2),
+ ];
+ video_frame_cache().insert(cache_key, Arc::new(frames));
+
+ let v = video(&src, ImageFit::Fill, Some(0.0), Some(1.0), Some(true));
+ let ctx = ctx_at(2.1);
+ let pixels = paint_and_read(&v, &ctx, 20, 20, false);
+
+ assert_eq!(
+ px(&pixels, 20, 10, 10),
+ [255, 0, 0, 255],
+ "looping must wrap the query time back into the window (nearest: red), not clamp to \
+ the window's own end (nearest: yellow)"
+ );
+}
+
+/// Without `loop_video`, a `trim_end`-bounded video must still clamp
+/// (unaffected by the loop branch existing) rather than wrap — the same
+/// scenario as the wrap test above, minus the flag.
+#[test]
+fn without_loop_video_playback_still_clamps_not_wraps() {
+ let src = unique_src("no-loop");
+ let cache_key = format!("{src}:20x20");
+ let frames = vec![
+ (0.0, solid_rgba([255, 0, 0, 255], 2, 2), 2, 2),
+ (0.25, solid_rgba([0, 255, 0, 255], 2, 2), 2, 2),
+ (0.5, solid_rgba([0, 0, 255, 255], 2, 2), 2, 2),
+ (0.75, solid_rgba([255, 255, 0, 255], 2, 2), 2, 2),
+ ];
+ video_frame_cache().insert(cache_key, Arc::new(frames));
+
+ let v = video(&src, ImageFit::Fill, Some(0.0), Some(1.0), None);
+ let ctx = ctx_at(2.1);
+ let pixels = paint_and_read(&v, &ctx, 20, 20, false);
+
+ assert_eq!(
+ px(&pixels, 20, 10, 10),
+ [255, 255, 0, 255],
+ "no loop_video: must clamp to the window's end (nearest: yellow), not wrap"
+ );
+}
+
+// ─── cached-frame draw path mistagged straight alpha as premultiplied ──────
+
+/// ffmpeg's `-pix_fmt rgba` output — what fills the video-frame cache — is
+/// straight (unpremultiplied) alpha. Tagging that buffer `AlphaType::Premul`
+/// makes Skia treat the RGB channels as already scaled by alpha instead of
+/// scaling them itself, which brightens (here: doubles) every
+/// semi-transparent pixel's channels once composited.
+///
+/// A straight-alpha (200, 100, 50, 128) pixel, composited over black:
+/// correctly tagged `Unpremul`, Skia premultiplies it to
+/// (200×128/255, 100×128/255, 50×128/255) ≈ (100, 50, 25) before compositing
+/// over black, landing there almost exactly (the `(1 - alpha) * 0` background
+/// term vanishes either way). Mistagged `Premul`, Skia uses the raw channel
+/// values directly as if already scaled — (200, 100, 50) — composited over
+/// black with no further scaling, landing at roughly double the correct
+/// result.
+#[test]
+fn cached_frame_straight_alpha_composites_correctly_not_doubled() {
+ let src = unique_src("alpha");
+ let cache_key = format!("{src}:10x10");
+ let straight = [200u8, 100, 50, 128];
+ video_frame_cache().insert(
+ cache_key,
+ Arc::new(vec![(0.0, solid_rgba(straight, 2, 2), 2, 2)]),
+ );
+
+ let v = video(&src, ImageFit::Fill, None, None, None);
+ let ctx = ctx_at(0.0);
+ let pixels = paint_and_read(&v, &ctx, 10, 10, false);
+ let composited = px(&pixels, 10, 5, 5);
+
+ let close = |actual: u8, expected: u8| (actual as i16 - expected as i16).abs() <= 4;
+ assert!(
+ close(composited[0], 100) && close(composited[1], 50) && close(composited[2], 25),
+ "straight-alpha (200,100,50,128) over black must composite to roughly (100,50,25), \
+ got {composited:?} — a value near (200,100,50) means the buffer is still mistagged \
+ as premultiplied and its channels are being used unscaled"
+ );
+}
From 05e0074501b01f855f644bd435ae09d7a472bb07 Mon Sep 17 00:00:00 2001
From: Baptiste Parmantier
Date: Tue, 22 Sep 2026 11:01:33 +0200
Subject: [PATCH 06/25] fix(html): emit lengths the core parser can read (#289)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two parsers for the same syntax have drifted. Executed: `style="padding: 24px 48px; margin: 0 auto; grid-template-columns: repeat(3, 1fr)"` transpiles to `"padding":"24px 48px"`, `"margin":"0 auto"`, `"grid-template-columns":["repeat(3,","1fr)"]`. All three deserialize successfully — `Edges::Uniform(LengthPercentage::String)` and `GridTrack::Length(LengthPercentage::String)` are untagged string variants — and then core's `parse_length` (rustmotion-core/src/css/units.rs:125) only understands a single number+unit token, so `parse_length_or_warn` (units.rs:262) falls back to `Px(0.0)`: zero padding, zero margin, two zero-width grid tracks. The only signal is an `eprintln!` warning; `validate` exits 0 and the render is wrong. Per-side padding is entirely unexpressible from HTML (`padding-top:32` is rejected by `CssStyle`'s `deny_unknown_fields`, `padding:32px 48px` silently becomes 0), and the skill rule html-css-mental-model.md:97 only documents the JSON object form, which inline `style=""` cannot produce.
Refs #220
---
crates/rustmotion-html/src/element.rs | 9 +-
crates/rustmotion-html/src/style.rs | 217 +++++++++++++++++++--
crates/rustmotion-html/tests/audit_ws_f.rs | 92 +++++++++
3 files changed, 298 insertions(+), 20 deletions(-)
diff --git a/crates/rustmotion-html/src/element.rs b/crates/rustmotion-html/src/element.rs
index b370f658..337c8b2e 100644
--- a/crates/rustmotion-html/src/element.rs
+++ b/crates/rustmotion-html/src/element.rs
@@ -58,11 +58,10 @@ fn collect_text(handle: &Handle, out: &mut String) {
/// [`crate::style::parse_anim_attr`]) lands in `style.animation` — inline CSS
/// cannot express animation arrays, so `anim` is the only writer of that key.
fn style_object(attrs: &[(String, String)]) -> Result
"##;
+ let v = html_to_scenario_value(html).expect("