Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
27 changes: 24 additions & 3 deletions crates/rustmotion-core/src/engine/text/cosmic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,11 @@ pub fn measure_text(text: &str, style: &TextStyle) -> TextMetrics {
height: metrics_for(style).line_height,
};
}
let mut fs = font_system().lock().unwrap();
// Poison-tolerant: a panic on another render thread (e.g. a Skia panic
// caught by a preview worker's panic fence) must not poison text shaping
// for every subsequent frame — the FontSystem stays usable, each shape
// call builds its own Buffer.
let mut fs = font_system().lock().unwrap_or_else(|e| e.into_inner());
let metrics = metrics_for(style);
let mut buf = Buffer::new(&mut fs, metrics);
buf.set_size(style.max_width, None);
Expand Down Expand Up @@ -128,15 +132,16 @@ pub fn paint_text(
if text.is_empty() {
return;
}
let mut fs = font_system().lock().unwrap();
// Poison-tolerant for the same reason as in `measure_text`.
let mut fs = font_system().lock().unwrap_or_else(|e| e.into_inner());
let metrics = metrics_for(style);
let mut buf = Buffer::new(&mut fs, metrics);
buf.set_size(style.max_width, None);
buf.set_wrap(if style.wrap { Wrap::Word } else { Wrap::None });
buf.set_text(text, &attrs_for(style), Shaping::Advanced, None);
buf.shape_until_scroll(&mut fs, false);

let mut sc = swash_cache().lock().unwrap();
let mut sc = swash_cache().lock().unwrap_or_else(|e| e.into_inner());
let ccolor = CColor::rgba(color.r(), color.g(), color.b(), color.a());

for run in buf.layout_runs() {
Expand Down Expand Up @@ -264,4 +269,20 @@ mod tests {
let m = measure_text("the quick brown fox", &style);
assert!(m.height < 16.0 * 1.2 * 2.0, "should be one line");
}

/// A panic on another thread while it holds the font mutex (e.g. a Skia
/// panic caught by a preview worker's panic fence) poisons the lock.
/// Shaping must survive that — otherwise one panic turns every subsequent
/// text render on every thread into a panic cascade.
#[test]
fn measure_survives_poisoned_font_mutex() {
let _ = std::thread::spawn(|| {
let _guard = font_system().lock().unwrap_or_else(|e| e.into_inner());
panic!("deliberate poison");
})
.join();
assert!(font_system().is_poisoned(), "setup must have poisoned");
let m = measure_text("still shaping", &TextStyle::default());
assert!(m.width > 0.0, "measure works despite the poisoned mutex");
}
}
58 changes: 58 additions & 0 deletions crates/rustmotion-studio/src/editor/frames.rs
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,64 @@ mod tests {
assert_eq!(&jpeg[0..2], &[0xFF, 0xD8], "must be a JPEG");
}

/// TEMP diagnostic (not part of CI): replicate the prefetch worker pool on
/// the dynamic-glass example and print RSS growth. Run with
/// `RM_STRESS_SCALE=0.5 cargo test -p rustmotion-studio --release stress_rss -- --ignored --nocapture`
#[test]
#[ignore]
fn stress_rss_worker_pool() {
use std::sync::Arc;
let Ok(src) = std::fs::read_to_string("../../examples/dynamic-glass.json") else {
eprintln!("skipped: examples/dynamic-glass.json not present");
return;
};
let scenario =
Arc::new(rustmotion::loader::load_scenario_from_source(None, Some(&src)).unwrap());
let tasks = Arc::new(rustmotion::encode::build_frame_tasks(&scenario));
let scale: f32 = std::env::var("RM_STRESS_SCALE")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(0.5);
let rss_kb = || -> u64 {
let out = std::process::Command::new("ps")
.args(["-o", "rss=", "-p", &std::process::id().to_string()])
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).trim().parse().unwrap()
};
println!("scale={scale} start rss={} MB", rss_kb() / 1024);
let workers: Vec<_> = (0..6u32)
.map(|w| {
let s = scenario.clone();
let t = tasks.clone();
std::thread::spawn(move || {
for _pass in 0..6 {
for f in (130..190).filter(|f| f % 6 == w) {
let _ = render_frame(&s, &t, f, scale);
}
}
})
})
.collect();
while workers.iter().any(|w| !w.is_finished()) {
std::thread::sleep(std::time::Duration::from_secs(2));
println!("rss={} MB", rss_kb() / 1024);
}
for w in workers {
w.join().unwrap();
}
println!("end rss={} MB", rss_kb() / 1024);
}

#[test]
fn renders_frame_at_reduced_scale() {
let scenario = rustmotion::loader::load_scenario_from_source(None, Some(SCENARIO)).unwrap();
let tasks = rustmotion::encode::build_frame_tasks(&scenario);
let jpeg = render_frame(&scenario, &tasks, 0, 0.5);
let img = image::load_from_memory(&jpeg).expect("decodable JPEG");
assert_eq!((img.width(), img.height()), (640, 360), "half of 1280x720");
}

#[test]
fn frame_hits_are_in_percent_and_have_kind() {
let json = r##"{ "video": { "width": 800, "height": 600, "background": "#101418" }, "scenes": [ { "duration": 1.0, "children": [ { "type": "text", "content": "Hi", "style": { "font-size": 40 } } ] } ] }"##;
Expand Down
32 changes: 32 additions & 0 deletions crates/rustmotion-studio/src/editor/playback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ use dioxus::prelude::*;
use dioxus_icons::lucide::{Pause, Play};

use crate::components::button::{Button, ButtonSize, ButtonVariant};
use crate::components::select::{Select, SelectOption};
use crate::scenario::Shared;

use super::prefetch::{set_preview_scale_pct, PREVIEW_SCALE_CHOICES};

/// What a playback keyboard shortcut does (see [`playback_action`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlaybackAction {
Expand Down Expand Up @@ -93,6 +96,7 @@ pub fn PlaybackBar(
total: u32,
diff_active: Signal<bool>,
mut diff_side: Signal<super::diff_panel::DiffSide>,
mut preview_scale: Signal<u16>,
) -> Element {
use super::diff_panel::DiffSide;

Expand Down Expand Up @@ -162,6 +166,34 @@ pub fn PlaybackBar(
}
},
}
// Preview quality: render scale of the preview frames only (the
// export always renders at 100%). Lower = smoother playback on
// heavy scenarios (glass, camera, parallax).
div {
title: "Preview quality (export is always 100%)",
style: "width:96px; flex:none;",
Select::<u16> {
default_value: Some(preview_scale()),
on_value_change: move |v: Option<u16>| {
if let Some(pct) = v {
// Atomic first: the render threads and the asset
// handler must see the new scale before the signal
// change triggers the <img> refetch.
set_preview_scale_pct(pct);
preview_scale.set(pct);
}
},
for (i, pct) in PREVIEW_SCALE_CHOICES.iter().enumerate() {
SelectOption::<u16> {
key: "{pct}",
index: i,
value: *pct,
text_value: "{pct}%",
"{pct}%"
}
}
}
}
div { style: "min-width:120px; text-align:right; color:var(--rm-text-muted);",
"{cur} / {max}"
}
Expand Down
Loading
Loading