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
9 changes: 4 additions & 5 deletions crates/rustmotion-core/src/engine/animator.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use crate::schema::{
Animation, AnimationEffect, AnimationPreset, CharAnimPreset, EasingType, GlowConfig, Keyframe,
KeyframeValue, MotionPathConfig, OrbitConfig, PresetConfig, SpringConfig, TextAnimDirection,
TextAnimGranularity, WiggleConfig,
Animation, AnimationEffect, AnimationPreset, BurstConfig, CharAnimPreset, EasingType,
GlowConfig, Keyframe, KeyframeValue, MotionPathConfig, OrbitConfig, PresetConfig, SpringConfig,
TextAnimDirection, TextAnimGranularity, WiggleConfig,
};

pub const DEFAULT_CHAR_BLUR_SIGMA: f32 = 14.0;
Expand Down Expand Up @@ -338,7 +338,7 @@ pub fn shatter_progress(cfg: &crate::schema::ShatterConfig, time: f64) -> Option
}
}

pub fn burst_progress(cfg: &crate::schema::BurstConfig, time: f64) -> Option<f32> {
pub fn burst_progress(cfg: &BurstConfig, time: f64) -> Option<f32> {
if cfg.duration <= 0.0 {
return None;
}
Expand Down Expand Up @@ -3270,7 +3270,6 @@ mod repeat_cycle_tests {
#[cfg(test)]
mod burst_progress_tests {
use super::*;
use crate::schema::BurstConfig;

fn cfg() -> BurstConfig {
BurstConfig {
Expand Down
17 changes: 7 additions & 10 deletions crates/rustmotion-core/src/engine/paint_pass.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ use crate::css::style::{
use crate::css::units::{
parse_origin_component, Length, LengthContext, LengthPercentage, ParsedLength,
};
use crate::engine::animator::{burst_progress, burst_stroke_span};
use crate::engine::box_tree::{BoxKind, BoxNode, NodeId};
use crate::engine::layout_pass::{BoxLayout, LayoutResult};
use crate::schema::{AnimationEffect as SchemaAnimationEffect, BurstConfig};

#[derive(Debug, Clone, Copy)]
pub struct PaintFrame {
Expand Down Expand Up @@ -902,12 +904,12 @@ fn paint_shattered_node(

const MAX_BURST_COUNT: u32 = 64;

fn active_burst(css: &CssStyle, time: f64) -> Option<(&crate::schema::BurstConfig, f32)> {
fn active_burst(css: &CssStyle, time: f64) -> Option<(&BurstConfig, f32)> {
let cfg = css.animation.iter().find_map(|e| match e {
crate::schema::AnimationEffect::Burst(c) => Some(c),
SchemaAnimationEffect::Burst(c) => Some(c),
_ => None,
})?;
crate::engine::animator::burst_progress(cfg, time).map(|progress| (cfg, progress))
burst_progress(cfg, time).map(|progress| (cfg, progress))
}

fn burst_track_start(half_width: f32, half_height: f32, dir: (f32, f32)) -> f32 {
Expand All @@ -929,12 +931,7 @@ fn burst_track_start(half_width: f32, half_height: f32, dir: (f32, f32)) -> f32
}
}

fn paint_burst(
canvas: &Canvas,
box_layout: &BoxLayout,
cfg: &crate::schema::BurstConfig,
progress: f32,
) {
fn paint_burst(canvas: &Canvas, box_layout: &BoxLayout, cfg: &BurstConfig, progress: f32) {
let count = cfg.count.clamp(1, MAX_BURST_COUNT);
let length = cfg.length.max(0.0);
let width = cfg.width.max(0.0);
Expand Down Expand Up @@ -962,7 +959,7 @@ fn paint_burst(
let dir = (angle.cos(), angle.sin());
let stroke_length = length * (1.0 + (shatter_hash(cfg.seed, i, 12) - 0.5) * jitter);
let phase = shatter_hash(cfg.seed, i, 13) * jitter * 0.5;
let (tail, head) = crate::engine::animator::burst_stroke_span(progress, phase);
let (tail, head) = burst_stroke_span(progress, phase);
if (head - tail) * stroke_length < 0.5 {
continue;
}
Expand Down
132 changes: 132 additions & 0 deletions crates/rustmotion-core/src/engine/renderer/google_fonts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,31 @@ pub fn font_cache_dir() -> PathBuf {
base.join("rustmotion").join("fonts")
}

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum RemoteFontPolicy {
#[default]
Deny,
Allow,
}

static REMOTE_FONT_POLICY: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);

pub fn set_remote_font_policy(policy: RemoteFontPolicy) {
REMOTE_FONT_POLICY.store(
policy == RemoteFontPolicy::Allow,
std::sync::atomic::Ordering::Release,
);
}

pub fn remote_font_policy() -> RemoteFontPolicy {
if REMOTE_FONT_POLICY.load(std::sync::atomic::Ordering::Acquire) {
RemoteFontPolicy::Allow
} else {
RemoteFontPolicy::Deny
}
}

pub fn resolve_google_font(
family: &str,
weights: &[u16],
Expand All @@ -41,6 +66,18 @@ pub fn resolve_google_font(
}

let url = build_css2_url(family, &missing_weights);
if remote_font_policy() != RemoteFontPolicy::Allow {
return Err(RustmotionError::RemoteFontDenied {
family: family.to_string(),
weights: missing_weights
.iter()
.map(u16::to_string)
.collect::<Vec<_>>()
.join(", "),
url: url.clone(),
cache_hint: cache_dir.display().to_string(),
});
}
let css = fetch_css2(&url, family)?;

let ttf_urls = parse_ttf_urls(&css);
Expand Down Expand Up @@ -291,3 +328,98 @@ mod tests {
assert_eq!(family_slug("Inter"), "inter");
}
}

#[cfg(test)]
mod remote_font_policy_tests {
use super::*;

struct Restore(RemoteFontPolicy);
impl Drop for Restore {
fn drop(&mut self) {
set_remote_font_policy(self.0);
}
}

fn a_cache_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"rm_font_policy_{}_{}_{name}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).expect("cache dir");
dir
}

#[test]
fn the_default_is_deny() {
assert_eq!(
RemoteFontPolicy::default(),
RemoteFontPolicy::Deny,
"a scenario is untrusted input and it chooses the target of the request"
);
}

#[test]
fn a_family_that_is_not_cached_is_refused_by_name_without_touching_the_network() {
let _restore = Restore(remote_font_policy());
set_remote_font_policy(RemoteFontPolicy::Deny);
let dir = a_cache_dir("denied");

let err = resolve_google_font("Zilla Slab Highlight", &[700], &dir)
.expect_err("a missing weight would have to be fetched");
let message = err.to_string();
assert!(
message.contains("Zilla Slab Highlight") && message.contains("700"),
"the refusal has to name the family and the weights: {message}"
);
assert!(
message.contains("fonts.googleapis.com"),
"and the target it declined to reach: {message}"
);
assert!(
message.contains("--allow-remote-fonts"),
"and the one flag that opts in: {message}"
);
std::fs::remove_dir_all(&dir).ok();
}

#[test]
fn a_family_already_in_the_cache_needs_no_flag() {
let _restore = Restore(remote_font_policy());
set_remote_font_policy(RemoteFontPolicy::Deny);
let dir = a_cache_dir("cached");
let slug = family_slug("Zilla Slab Highlight");
std::fs::write(dir.join(format!("{slug}-700.ttf")), b"not a real face").expect("seed");

let paths = resolve_google_font("Zilla Slab Highlight", &[700], &dir)
.expect("nothing has to be fetched, so nothing is denied");
assert_eq!(
paths.len(),
1,
"denying the network must not break an offline render that already has its fonts"
);
std::fs::remove_dir_all(&dir).ok();
}

#[test]
fn only_the_weights_that_are_missing_are_named_in_the_refusal() {
let _restore = Restore(remote_font_policy());
set_remote_font_policy(RemoteFontPolicy::Deny);
let dir = a_cache_dir("partial");
let slug = family_slug("Inter");
std::fs::write(dir.join(format!("{slug}-400.ttf")), b"not a real face").expect("seed");

let message = resolve_google_font("Inter", &[400, 700], &dir)
.expect_err("700 is missing")
.to_string();
assert!(
message.contains("700") && !message.contains("400, 700"),
"400 is already on disk and needs no request, so it has no business in the \
refusal: {message}"
);
std::fs::remove_dir_all(&dir).ok();
}
}
1 change: 1 addition & 0 deletions crates/rustmotion-core/src/engine/renderer/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ pub mod audio_analysis;
mod colors;
mod fonts;
pub mod google_fonts;
pub use google_fonts::{remote_font_policy, set_remote_font_policy, RemoteFontPolicy};
mod shapes;
mod text;
mod yuv;
Expand Down
12 changes: 12 additions & 0 deletions crates/rustmotion-core/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,18 @@ pub enum RustmotionError {
cache_hint: String,
},

#[error(
"Google Fonts: '{family}' (weights {weights}) is not in the font cache, and fetching \
it would reach {url} — a scenario chooses that target, so it is denied by default.\n\
Pass --allow-remote-fonts to opt in, or place a TTF for each weight at:\n {cache_hint}"
)]
RemoteFontDenied {
family: String,
weights: String,
url: String,
cache_hint: String,
},

#[error(
"Google Fonts: no font URLs found in CSS response for '{family}' — \
the family name may be misspelled or unavailable"
Expand Down
25 changes: 25 additions & 0 deletions crates/rustmotion/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,31 @@ CLI :
- `--strict-attrs` — promeut en erreurs les attributs inconnus (détection schéma + did-you-mean, activée par défaut en warnings)
- `--lenient` — warnings au lieu d'errors

## Google Fonts: the network is denied by default

A scenario declaring `fonts: [{ "family": "Inter", "source": "google", "weights": [400, 700] }]`
downloads the face from `fonts.googleapis.com` into the cache
(`~/.cache/rustmotion/fonts`). The declared consumer of a scenario is a model
generating JSON, so the file is **untrusted input** — and it is the file that
chooses the target of the request. Validating someone else's scenario should not
be a network operation, and on a CI runner it turns a lint step into an outbound
fetch.

`--allow-remote-fonts` (global, on any subcommand) is the only way to consent to
it. Without it, a weight **missing from the cache** is refused by naming the
family, the missing weights, the URL that was not called, and the directory to
drop a TTF into by hand:

```
Google Fonts: 'Zilla Slab Highlight' (weights 700) is not in the font cache, and
fetching it would reach https://fonts.googleapis.com/css2?family=... — a scenario
chooses that target, so it is denied by default.
```

A face **already in the cache** asks for nothing: the refusal fires at the exact
moment a request would leave. An offline render that has its fonts keeps working
without the flag.

## Encodage

- ffmpeg est auto-détecté et utilisé par défaut (10-bit H.264, meilleure qualité sur les gradients sombres)
Expand Down
3 changes: 2 additions & 1 deletion crates/rustmotion/src/cli/commands/info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use rustmotion::components::intrinsic::{GradientTextIntrinsic, TextIntrinsic};
use rustmotion::components::{ChildComponent, Component};
use rustmotion::core::engine::box_tree::{AvailableSpace, IntrinsicMeasure};
use rustmotion::encode::build_frame_tasks;
use rustmotion::encode::video::slide_scene_spans;
use rustmotion::engine::animator::spring_rest_time;
use rustmotion::engine::render::deserialize_children;
use rustmotion::error::Result;
Expand Down Expand Up @@ -52,7 +53,7 @@ pub fn cmd_info(input: &PathBuf) -> Result<()> {
view.scenes.len()
);
let spans = match view.view_type {
schema::ViewType::Slide => rustmotion::encode::video::slide_scene_spans(view, fps),
schema::ViewType::Slide => slide_scene_spans(view, fps),
schema::ViewType::World => Vec::new(),
};
for (si, scene) in view.scenes.iter().enumerate() {
Expand Down
3 changes: 2 additions & 1 deletion crates/rustmotion/src/cli/commands/migrate.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use rustmotion::encode::video::quantised_spans;
use rustmotion::error::{Result, RustmotionError};
use serde_json::Value;
use std::path::{Path, PathBuf};
Expand Down Expand Up @@ -35,7 +36,7 @@ fn migrate_scenes_array(scenes: &mut [Value], fps: u32, label: &str) -> Result<(
.collect::<Result<_>>()?;
let incoming: Vec<f64> = scenes.iter().map(incoming_transition_seconds).collect();

let spans = rustmotion::encode::video::quantised_spans(&durations, &incoming, fps);
let spans = quantised_spans(&durations, &incoming, fps);

let n = scenes.len();
let at_frames: Vec<u32> = spans.iter().map(|s| s.start).collect();
Expand Down
3 changes: 2 additions & 1 deletion crates/rustmotion/src/cli/commands/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use crate::cli::tui;
use crate::cli::OutputFormat;
use rustmotion::encode;
use rustmotion::engine;
use rustmotion::engine::renderer::invalidate_font_caches;
use rustmotion::error::{Result, RustmotionError};
use rustmotion::schema::ResolvedScenario;
use std::path::{Path, PathBuf};
Expand All @@ -12,7 +13,7 @@ fn clear_all_media_caches() {
engine::clear_asset_cache();
engine::gif_cache().clear();
engine::video_frame_cache().clear();
rustmotion_core::engine::renderer::invalidate_font_caches();
invalidate_font_caches();
}

#[allow(clippy::too_many_arguments)]
Expand Down
10 changes: 10 additions & 0 deletions crates/rustmotion/src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ mod skills;
mod tui;

use clap::{CommandFactory, Parser, Subcommand};
use rustmotion::engine::renderer::{set_remote_font_policy, RemoteFontPolicy};
use rustmotion::error::{Result, RustmotionError};
use rustmotion::schema::ResolvedScenario;
use std::collections::HashMap;
Expand All @@ -27,6 +28,11 @@ struct Cli {
/// Number of parallel rendering threads (defaults to all cores)
#[arg(long, global = true)]
threads: Option<usize>,

/// Allow fetching a Google font that is not already in the font cache. A scenario
/// chooses the target of that request, so it is denied by default.
#[arg(long, global = true)]
allow_remote_fonts: bool,
}

#[derive(Subcommand)]
Expand Down Expand Up @@ -670,6 +676,10 @@ pub fn run() -> Result<()> {
.ok();
}

if cli.allow_remote_fonts {
set_remote_font_policy(RemoteFontPolicy::Allow);
}

match cli.command {
Commands::Render {
file,
Expand Down
3 changes: 2 additions & 1 deletion crates/rustmotion/src/encode/video_audio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use std::hash::{Hash, Hasher};
use std::path::PathBuf;

use crate::components::{ChildComponent, Component};
use crate::encode::video::slide_scene_spans;
use crate::schema::{AudioTrack, ResolvedScenario, ViewType};

pub struct TimelineOffsets {
Expand All @@ -29,7 +30,7 @@ pub fn timeline_offsets(scenario: &ResolvedScenario) -> TimelineOffsets {

match view.view_type {
ViewType::Slide => {
let spans = crate::encode::video::slide_scene_spans(view, scenario.video.fps);
let spans = slide_scene_spans(view, scenario.video.fps);
let scene_offsets: Vec<f64> = spans
.iter()
.map(|span| cursor + span.start as f64 / fps)
Expand Down
Loading