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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,23 @@ Shows information about a scenario (duration, scene count, dimensions, ...).
rustmotion info scenario.json
```

### `rustmotion icons`

Fills the icon cache so a later render needs no network. `icon` resolves names
like `lucide:home` through `api.iconify.design`, and a scenario chooses that
name, so an icon missing from the cache is refused by default — the same rule as
Google Fonts.

```bash
rustmotion icons check -f scenario.json # list what is missing, fetch nothing
rustmotion icons prefetch -f scenario.json # download them once
rustmotion render -f scenario.json # renders offline from here on
```

Running `prefetch` is itself the opt-in, so it needs no flag. For a one-shot
render where filling the cache first is not worth a second command, pass
`--allow-remote-icons`.

### `rustmotion skills`

Manages the built-in Claude Code skills — `install [--global]`, `uninstall [--global]`, `list`, `show <name>`. See [Claude Code Skills](#claude-code-skills).
Expand Down
137 changes: 136 additions & 1 deletion crates/rustmotion-core/src/engine/renderer/assets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,35 @@ pub fn icon_source_cache_file(cache_dir: &Path, icon: &str) -> PathBuf {
cache_dir.join(format!("{}.svg", icon.replace(':', "_")))
}

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

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

pub fn set_remote_icon_policy(policy: RemoteIconPolicy) {
REMOTE_ICON_POLICY.store(
policy == RemoteIconPolicy::Allow,
std::sync::atomic::Ordering::Release,
);
}

pub fn remote_icon_policy() -> RemoteIconPolicy {
if REMOTE_ICON_POLICY.load(std::sync::atomic::Ordering::Acquire) {
RemoteIconPolicy::Allow
} else {
RemoteIconPolicy::Deny
}
}

pub fn icon_source_url(prefix: &str, name: &str) -> String {
format!("https://api.iconify.design/{prefix}/{name}.svg")
}

fn set_root_attribute(svg: &str, attribute: &str, value: &str) -> String {
let Some(tag_start) = svg.find("<svg") else {
return svg.to_string();
Expand Down Expand Up @@ -155,7 +184,7 @@ fn status_is_worth_retrying(error: &ureq::Error) -> bool {
}

fn fetch_icon_source(icon: &str, prefix: &str, name: &str) -> Result<Vec<u8>> {
let url = format!("https://api.iconify.design/{prefix}/{name}.svg");
let url = icon_source_url(prefix, name);
let mut backoff = Duration::from_millis(250);
let mut last_reason = String::new();

Expand Down Expand Up @@ -219,6 +248,13 @@ pub fn fetch_icon_svg_in(
migrated
}
None => {
if remote_icon_policy() != RemoteIconPolicy::Allow {
return Err(RustmotionError::RemoteIconDenied {
icon: icon.to_string(),
url: icon_source_url(prefix, name),
cache_hint: source_file.display().to_string(),
});
}
let fetched = fetch_icon_source(icon, prefix, name)?;
if std::fs::create_dir_all(cache_dir).is_ok() {
let _ = std::fs::write(&source_file, &fetched);
Expand Down Expand Up @@ -928,3 +964,102 @@ mod icon_source_cache_tests {
);
}
}

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

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

fn a_cache_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"rm_icon_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
}

const A_SQUARE: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<rect x="2" y="2" width="20" height="20" fill="currentColor"/>
</svg>"#;

#[test]
fn the_default_is_deny() {
assert_eq!(
RemoteIconPolicy::default(),
RemoteIconPolicy::Deny,
"a scenario chooses which icon is fetched, so it chooses the target of the \
request — the same reason fonts are denied by default"
);
}

#[test]
fn an_icon_that_is_not_cached_is_refused_by_name_without_touching_the_network() {
let _restore = Restore(remote_icon_policy());
set_remote_icon_policy(RemoteIconPolicy::Deny);
let dir = a_cache_dir("denied");

let message = fetch_icon_svg_in("lucide:sparkles", "#FFFFFF", 24, 24, &dir)
.expect_err("an icon absent from the cache would have to be fetched")
.to_string();

assert!(
message.contains("lucide:sparkles"),
"the refusal has to name the icon: {message}"
);
assert!(
message.contains("api.iconify.design"),
"and the target it declined to reach: {message}"
);
assert!(
message.contains("rustmotion icons prefetch")
&& message.contains("--allow-remote-icons"),
"and both ways out — fill the cache once, or opt in for this run: {message}"
);
std::fs::remove_dir_all(&dir).ok();
}

#[test]
fn an_icon_already_in_the_cache_needs_no_flag() {
let _restore = Restore(remote_icon_policy());
set_remote_icon_policy(RemoteIconPolicy::Deny);
let dir = a_cache_dir("cached");
std::fs::write(icon_source_cache_file(&dir, "lucide:check"), A_SQUARE).expect("seed");

let svg = fetch_icon_svg_in("lucide:check", "#FF3366", 24, 24, &dir)
.expect("nothing has to be fetched, so nothing is denied");
assert!(
String::from_utf8_lossy(&svg).contains("ff3366"),
"the cached source must still be recoloured and returned under a deny policy"
);
std::fs::remove_dir_all(&dir).ok();
}

#[test]
fn a_cache_left_by_the_old_naming_is_migrated_without_the_flag() {
let _restore = Restore(remote_icon_policy());
set_remote_icon_policy(RemoteIconPolicy::Deny);
let dir = a_cache_dir("legacy");
let baked = A_SQUARE.replace("currentColor", "#ffffff");
std::fs::write(dir.join("lucide_home-ffffff-24x24.svg"), baked).expect("seed");

let svg = fetch_icon_svg_in("lucide:home", "#33CCFF", 24, 24, &dir)
.expect("the legacy entry is on disk, so the deny gate must never be reached");
assert!(
String::from_utf8_lossy(&svg).contains("33ccff"),
"an offline render that already has its icons under the pre-#425 naming must keep \
working: the gate belongs after the migration, not before it"
);
std::fs::remove_dir_all(&dir).ok();
}
}
15 changes: 14 additions & 1 deletion crates/rustmotion-core/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ pub enum RustmotionError {
#[error(
"{count} icon(s) could not be loaded — checked the disk cache at {cache_dir} and the \
network, both failed:\n - {details}\nA render must not silently omit an icon: fix \
the identifier(s), or connect once so they are downloaded and cached for offline use."
the identifier(s), or run `rustmotion icons prefetch -f <scenario>` once to fill the \
cache for offline use."
)]
IconsUnresolved {
count: usize,
Expand Down Expand Up @@ -108,6 +109,18 @@ pub enum RustmotionError {
cache_hint: String,
},

#[error(
"Icons: '{icon}' is not in the icon cache, and fetching it would reach {url} — a \
scenario chooses that target, so it is denied by default.\n\
Run `rustmotion icons prefetch -f <scenario>` once to fill the cache, pass \
--allow-remote-icons to opt in for this run, or place the SVG at:\n {cache_hint}"
)]
RemoteIconDenied {
icon: 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
26 changes: 26 additions & 0 deletions crates/rustmotion/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,32 @@ 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.

## Icons: the network is denied by default too

`icon` resolves `lucide:home` through `api.iconify.design`, and a scenario picks
that name, so the same argument as fonts applies: the file chooses the target of
the request. An icon **missing from the cache** (`~/.cache/rustmotion/icons`) is
refused by name, with the URL that was not called and the path to drop an SVG
into. An icon already cached asks for nothing, so an offline render that has its
icons keeps working.

Two ways in, and the first is the one to reach for:

```bash
rustmotion icons check -f scenario.json # what is missing, fetches nothing
rustmotion icons prefetch -f scenario.json # fill the cache once
rustmotion render -f scenario.json # then render offline, no flag
```

`prefetch` needs no `--allow-remote-icons`: running it *is* the opt-in. The flag
exists for a one-shot render where filling the cache first is not worth the extra
command.

> The gate sits **after** the cache read and after the pre-#425 name migration, so
> a cache filled under the old `{slug}-{colour}-{w}x{h}.svg` naming still resolves
> offline. Denying the network must not break a render that already has what it
> needs.

## Encodage

- ffmpeg is auto-detected and used by default. H.264 output is **8-bit** (`yuv420p`, `high` profile), which QuickTime and Safari play; `--codec h264_10bit` trades that for `yuv420p10le`/`high10`, which is banding-free on dark gradients and refused by both players.
Expand Down
93 changes: 93 additions & 0 deletions crates/rustmotion/src/cli/commands/icons.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
use rustmotion::engine::preload::scenario_icon_names;
use rustmotion::engine::renderer::{
icon_cache_dir, icon_source_cache_file, set_remote_icon_policy, RemoteIconPolicy,
};
use rustmotion::error::Result;
use rustmotion::loader::load_scenario_from_source;
use std::path::{Path, PathBuf};

fn scenario_at(file: &Path) -> Result<rustmotion::schema::ResolvedScenario> {
load_scenario_from_source(Some(&PathBuf::from(file)), None)
}

fn icons_named_by(file: &Path) -> Result<Vec<String>> {
Ok(scenario_icon_names(&scenario_at(file)?))
}

fn split_by_cache_presence(icons: &[String]) -> (Vec<String>, Vec<String>) {
let cache_dir = icon_cache_dir();
icons.iter().cloned().partition(|icon| {
icon_source_cache_file(&cache_dir, icon)
.metadata()
.is_ok_and(|m| m.len() > 0)
})
}

pub fn cmd_icons_check(file: &Path, quiet: bool) -> Result<()> {
let icons = icons_named_by(file)?;
let (cached, missing) = split_by_cache_presence(&icons);

if quiet {
for icon in &missing {
println!("{icon}");
}
return Ok(());
}

println!("Icon cache: {}", icon_cache_dir().display());
println!(
"{} icon(s) named, {} already cached",
icons.len(),
cached.len()
);

if missing.is_empty() {
println!("Nothing to fetch — this scenario renders offline.");
return Ok(());
}

println!("\n{} missing:", missing.len());
for icon in &missing {
println!(" {icon}");
}
println!(
"\nRun `rustmotion icons prefetch -f {}` to download them.",
file.display()
);
Ok(())
}

pub fn cmd_icons_prefetch(file: &Path, quiet: bool) -> Result<()> {
let icons = icons_named_by(file)?;
let (_, missing) = split_by_cache_presence(&icons);

if missing.is_empty() {
if !quiet {
println!(
"All {} icon(s) are already in {}.",
icons.len(),
icon_cache_dir().display()
);
}
return Ok(());
}

set_remote_icon_policy(RemoteIconPolicy::Allow);

let scenario = scenario_at(file)?;
for view in &scenario.views {
rustmotion::engine::preload::prefetch_icons(&view.scenes)?;
}

let (cached_after, still_missing) = split_by_cache_presence(&icons);
if !quiet {
println!(
"Fetched {} icon(s) into {}. {} of {} now cached.",
missing.len() - still_missing.len(),
icon_cache_dir().display(),
cached_after.len(),
icons.len()
);
}
Ok(())
}
2 changes: 2 additions & 0 deletions crates/rustmotion/src/cli/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ mod audio_report;
mod batch;
mod captions;
mod geometry;
mod icons;
mod info;
mod migrate;
mod render;
Expand All @@ -15,6 +16,7 @@ pub mod validation;

pub use batch::cmd_batch;
pub use captions::cmd_captions;
pub use icons::{cmd_icons_check, cmd_icons_prefetch};
pub use info::cmd_info;
pub use migrate::cmd_migrate;
pub use render::{cmd_render, cmd_watch};
Expand Down
Loading
Loading