Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
d30f49a
fix(encode): reject a non-positive playback rate (#287)
LeadcodeDev Sep 22, 2026
928f9ef
perf(preload): bound the extracted video frame cache (#288)
LeadcodeDev Sep 22, 2026
c029b9f
fix(render): clear asset caches between watch renders (#242)
LeadcodeDev Sep 22, 2026
9890365
fix(video): loop the picture with the audio (#245)
LeadcodeDev Sep 22, 2026
7298fd8
fix(video): treat decoded frames as unpremultiplied (#247)
LeadcodeDev Sep 22, 2026
05e0074
fix(html): emit lengths the core parser can read (#289)
LeadcodeDev Sep 22, 2026
23c2bed
fix(studio): surface a render panic instead of caching an empty frame…
LeadcodeDev Sep 22, 2026
e20a7d3
fix(assets): restrict the protocols a scenario src may use (#291)
LeadcodeDev Sep 22, 2026
b22b910
fix(components): apply the css cascade before painters read style (#292)
LeadcodeDev Sep 22, 2026
e37518f
fix(paint): orient linear gradients to the css angle convention (#225)
LeadcodeDev Sep 22, 2026
4274f9f
perf(animator): cache the spring settle time (#226)
LeadcodeDev Sep 22, 2026
0e47992
fix(validate): keep asset paths relative when rewriting a scenario (#…
LeadcodeDev Sep 22, 2026
e36dfec
fix(geometry): measure text overflow against the content box (#230)
LeadcodeDev Sep 22, 2026
e1381bd
fix(gif): cap decoded gif dimensions and frame count (#241)
LeadcodeDev Sep 22, 2026
e068341
fix(batch): index the name template by character (#243)
LeadcodeDev Sep 22, 2026
4ac841d
fix(video): honour the fit property (#244)
LeadcodeDev Sep 22, 2026
41689b8
fix(video): stop the picture at trim_end (#246)
LeadcodeDev Sep 22, 2026
0a0d175
refactor(scene): paint decorative fullscreen through the box tree (#283)
LeadcodeDev Sep 22, 2026
477618f
fix(background): canonicalize the heropattern colour before templatin…
LeadcodeDev Sep 22, 2026
3a49519
fix(html): reject unsupported children of text tags (#254)
LeadcodeDev Sep 22, 2026
fbf64bb
fix(html): preserve the document around the rustmotion element (#255)
LeadcodeDev Sep 22, 2026
0f3dc12
fix(expand): bound the total nodes produced by for-each (#261)
LeadcodeDev Sep 22, 2026
a37916f
fix(include): require opt-in for remote includes (#262)
LeadcodeDev Sep 22, 2026
3e5708b
fix(components): paint table columns at their measured widths (#265)
LeadcodeDev Sep 22, 2026
a190eb8
fix(encode): trust the decoded signal spec for rate and channels
LeadcodeDev Sep 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions crates/rustmotion-components/src/box_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -636,7 +636,7 @@ fn build_child<'a>(
time_remap,
&css,
);
let intrinsic = component_intrinsic(&child.component);
let intrinsic = component_intrinsic(&child.component, &css);

let principal = BoxNode {
id,
Expand Down Expand Up @@ -1131,12 +1131,22 @@ fn apply_glow_effect(css: &mut CssStyle, effects: &[rustmotion_core::schema::Ani
/// Build an [`IntrinsicMeasure`] for components whose box size depends on
/// their content (text, codeblock, terminal, etc.). Returns `None` for
/// components with explicit dimensions or pure containers.
///
/// `cascaded_css` is this node's own `CssStyle` after `cascade::inherit_from`
/// has already merged it against the parent, plus every subsequent overlay
/// (timeline states, animation) — the exact same value `LegacyPaintDispatcher`
/// receives at paint time. `Text` uses it so the reserved box always matches
/// what `Text::paint_content` (which reads the same cascade through
/// `with_cascaded_style`) actually draws.
fn component_intrinsic(
component: &Component,
cascaded_css: &CssStyle,
) -> Option<Arc<dyn rustmotion_core::engine::box_tree::IntrinsicMeasure>> {
use Component::*;
match component {
Text(t) => Some(Arc::new(crate::intrinsic::TextIntrinsic::from_text(t))),
Text(t) => Some(Arc::new(crate::intrinsic::TextIntrinsic::from_text(
&t.with_cascaded_style(cascaded_css),
))),
GradientText(t) => Some(Arc::new(
crate::intrinsic::GradientTextIntrinsic::from_gradient_text(t),
)),
Expand Down
174 changes: 166 additions & 8 deletions crates/rustmotion-components/src/gif.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<DecodedGif> {
fn decode_composed_frames(src: &str, max_canvas_w: u32, max_canvas_h: u32) -> Option<DecodedGif> {
let file = match std::fs::File::open(src) {
Ok(f) => f,
Err(e) => {
Expand All @@ -139,6 +157,18 @@ fn decode_composed_frames(src: &str) -> Option<DecodedGif> {
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();

Expand All @@ -148,6 +178,16 @@ fn decode_composed_frames(src: &str) -> Option<DecodedGif> {
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());
Expand Down Expand Up @@ -189,10 +229,14 @@ fn decode_composed_frames(src: &str) -> Option<DecodedGif> {
/// 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<Arc<DecodedGif>> {
fn cached_decode(src: &str, max_canvas_w: u32, max_canvas_h: u32) -> Option<Arc<DecodedGif>> {
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())
}
Expand All @@ -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;
};

Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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"))),
Expand Down Expand Up @@ -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();
Expand All @@ -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);
}
}
47 changes: 8 additions & 39 deletions crates/rustmotion-components/src/intrinsic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -875,17 +875,18 @@ impl IntrinsicMeasure for TerminalIntrinsic {
// Table intrinsic measurer
// ─────────────────────────────────────────────────────────────────────────────

use crate::table::{
Table, DEFAULT_CELL_PADDING, DEFAULT_FONT_SIZE as TABLE_FONT_SIZE, DEFAULT_ROW_HEIGHT_RATIO,
};
use crate::table::{Table, DEFAULT_FONT_SIZE as TABLE_FONT_SIZE, DEFAULT_ROW_HEIGHT_RATIO};

/// Intrinsic measurer for [`Table`].
///
/// Natural size formula (matches the painter exactly):
/// Natural size formula (matches the painter exactly, since both now read
/// the same per-column distribution — see [`Table::natural_column_widths`]):
/// - `row_height = font_size × DEFAULT_ROW_HEIGHT_RATIO`
/// - `height = (1 + row_count) × row_height` (header + data rows)
/// - `width`: if `column_widths` are provided, their sum; otherwise each
/// column gets `max(header_text_width + 2 × cell_padding, min_col_width)`.
/// - `width`: if `column_widths` are provided, their sum; otherwise the sum
/// of `Table::natural_column_widths`, which the painter's own
/// `resolve_column_widths` scales proportionally to whatever width the
/// box actually laid out at.
pub struct TableIntrinsic {
row_height: f32,
row_count: usize, // data rows only; header adds 1
Expand All @@ -909,44 +910,12 @@ impl TableIntrinsic {
}

fn compute_width(t: &Table, font_size: f32) -> f32 {
// Explicit column widths provided → sum them.
if let Some(widths) = &t.column_widths {
if !widths.is_empty() {
return widths.iter().sum();
}
}

// Measure each header with the bold font; add 2× cell_padding per column.
let font_style = skia_safe::FontStyle::bold();
let family = t.style.font_family.as_deref().unwrap_or("Inter");
let Ok(typeface) = typeface_with_fallback(family, font_style) else {
// Font unavailable: fall back to col_count × a reasonable minimum.
let col_count = t.headers.len().max(1) as f32;
return col_count * (TABLE_FONT_SIZE * 8.0 + DEFAULT_CELL_PADDING * 2.0);
};
let font = Font::from_typeface(typeface, font_size);
let emoji_font = emoji_typeface().map(|tf| Font::from_typeface(tf, font_size));
let cell_padding = t.cell_padding;

// Also consider data cell widths to size columns appropriately.
let col_count = t.headers.len().max(1);
let mut col_widths: Vec<f32> = vec![0.0; col_count];

for (i, header) in t.headers.iter().enumerate() {
let w = measure_text_with_fallback(header, &font, &emoji_font, 0.0);
col_widths[i] = col_widths[i].max(w + cell_padding * 2.0);
}
for row in &t.rows {
for (i, cell) in row.iter().enumerate() {
if i >= col_count {
break;
}
let w = measure_text_with_fallback(cell, &font, &emoji_font, 0.0);
col_widths[i] = col_widths[i].max(w + cell_padding * 2.0);
}
}

col_widths.iter().sum()
t.natural_column_widths(font_size).iter().sum()
}
}

Expand Down
23 changes: 19 additions & 4 deletions crates/rustmotion-components/src/legacy_dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,22 @@
//! skipped: paint_pass already paints their box decorations and recurses into
//! children — so calling the container's own `paint_content` would do nothing
//! anyway, and we save a no-op call.

//!
//! `dispatch` receives the node's cascaded `CssStyle` (`css` below) from
//! `paint_pass`, but `Painter::paint_content` has no `CssStyle` parameter —
//! its signature is frozen — and every painter reads typography off its own
//! `self.style` instead. `Text` is the one component wired to see the
//! cascade anyway: `Text::with_cascaded_style` rebuilds it with `css`
//! folded into its own `style` field before painting, the same technique
//! `box_builder::component_intrinsic` uses for `TextIntrinsic` so measure
//! and paint agree.

use rustmotion_core::css::CssStyle;
use rustmotion_core::engine::animator::{resolve_props_for_effects, AnimatedProperties};
use rustmotion_core::engine::box_tree::NodeId;
use rustmotion_core::engine::layout_pass::BoxLayout;
use rustmotion_core::engine::paint_pass::{PaintDispatcher, PaintFrame};
use rustmotion_core::traits::PaintCtx;
use rustmotion_core::traits::{PaintCtx, Painter};
use skia_safe::Canvas;

use crate::{ChildComponent, Component};
Expand Down Expand Up @@ -65,7 +75,7 @@ impl<'a> PaintDispatcher for LegacyPaintDispatcher<'a> {
&self,
canvas: &Canvas,
payload: &(dyn std::any::Any + Send + Sync),
_css: &rustmotion_core::css::CssStyle,
css: &CssStyle,
layout: &BoxLayout,
frame: &PaintFrame,
) {
Expand Down Expand Up @@ -165,7 +175,12 @@ impl<'a> PaintDispatcher for LegacyPaintDispatcher<'a> {
video_height: frame.video_height,
stagger_offset: stagger_delay,
};
painter.paint_content(canvas, &local, &props, &paint_ctx);
if let Component::Text(t) = &child.component {
t.with_cascaded_style(css)
.paint_content(canvas, &local, &props, &paint_ctx);
} else {
painter.paint_content(canvas, &local, &props, &paint_ctx);
}

canvas.restore();
}
Expand Down
Loading