Skip to content

Commit 6cee0f6

Browse files
authored
fix(icons): cache one source per icon, retry rate limits, stop panicking (#390)
Three faults, one cause each. **One download per colour and per size.** The disk entry was named `{slug}-{color}-{w}x{h}.svg` because the Iconify URL baked colour and size into the SVG. A cache could hold 21 copies of `lucide:sparkles`, and changing only a colour sent an icon that rendered fine offline back to the network. The source is now fetched once per `prefix:name`, cached under `{slug}.svg`, and recoloured and resized locally — Lucide draws with `stroke="currentColor"`, so recolouring is a substitution, and resizing rewrites the root `width`/`height` while leaving the `viewBox` alone. Done without a regex crate: the attribute rewrite is a short scan of the root tag. **No retry.** A single `GET`, so a `429` from `api.iconify.design` under parallel renders lost the icon. Four attempts with exponential backoff, and only for `429` and `5xx` — a `404` is not going to resolve on the fourth try, and retrying it would make a typo take four times as long to report. **`render` panicked where `still` and `sheet` warned.** The panic was not an oversight: `an_unresolvable_icon_must_fail_the_preload_not_be_swallowed` exists to enforce it, and says "must panic (or otherwise hard-fail)". So the severity stays and the mechanism changes. `prefetch_icons` returns a named `IconsUnresolved` error, the encode paths propagate it, and `still` and `sheet` now preload too, so all three fail the same way with the same message and a clean exit code instead of a backtrace. The studio warns and keeps its preview alive, which is the one caller that must not die. `validate` stays offline. $ rustmotion still -f missing-icon.json --time 0 -o out.png Error: 1 icon(s) could not be loaded — checked the disk cache at ~/.cache/rustmotion/icons and the network, both failed: - 'lucide:no-such-icon-xyz' (color #2A2F6B, target 84x84px): Failed to fetch icon: http status: 404 [exit 1] `disk_cache_is_keyed_by_icon_color_and_size` asserted the first fault as a guarantee — "different colors must not share a cache file". It is replaced by its opposite rather than worked around. Closes #365
1 parent cbcde05 commit 6cee0f6

9 files changed

Lines changed: 234 additions & 64 deletions

File tree

‎crates/rustmotion-core/src/engine/renderer/assets.rs‎

Lines changed: 193 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,89 @@ pub fn icon_cache_dir() -> PathBuf {
6767
base.join("rustmotion").join("icons")
6868
}
6969

70-
fn icon_cache_file(cache_dir: &Path, icon: &str, color: &str, width: u32, height: u32) -> PathBuf {
71-
let slug = icon.replace(':', "_");
72-
let hex_color = color.trim_start_matches('#').to_lowercase();
73-
cache_dir.join(format!("{slug}-{hex_color}-{width}x{height}.svg"))
70+
fn icon_source_cache_file(cache_dir: &Path, icon: &str) -> PathBuf {
71+
cache_dir.join(format!("{}.svg", icon.replace(':', "_")))
72+
}
73+
74+
fn set_root_attribute(svg: &str, attribute: &str, value: &str) -> String {
75+
let Some(tag_start) = svg.find("<svg") else {
76+
return svg.to_string();
77+
};
78+
let Some(tag_len) = svg[tag_start..].find('>') else {
79+
return svg.to_string();
80+
};
81+
let tag = &svg[tag_start..tag_start + tag_len];
82+
83+
let needle = format!(" {attribute}=\"");
84+
let replaced = match tag.find(&needle) {
85+
Some(at) => {
86+
let value_start = at + needle.len();
87+
match tag[value_start..].find('"') {
88+
Some(value_len) => format!(
89+
"{}{}\"{}",
90+
&tag[..value_start],
91+
value,
92+
&tag[value_start + value_len..]
93+
),
94+
None => tag.to_string(),
95+
}
96+
}
97+
None => format!("<svg {attribute}=\"{value}\"{}", &tag["<svg".len()..]),
98+
};
99+
100+
format!(
101+
"{}{}{}",
102+
&svg[..tag_start],
103+
replaced,
104+
&svg[tag_start + tag_len..]
105+
)
106+
}
107+
108+
fn recolour_and_resize(source: &[u8], hex_color: &str, width: u32, height: u32) -> Vec<u8> {
109+
let coloured =
110+
String::from_utf8_lossy(source).replace("currentColor", &format!("#{hex_color}"));
111+
let sized = set_root_attribute(&coloured, "width", &width.to_string());
112+
set_root_attribute(&sized, "height", &height.to_string()).into_bytes()
113+
}
114+
115+
const ICON_FETCH_ATTEMPTS: u32 = 4;
116+
117+
fn status_is_worth_retrying(error: &ureq::Error) -> bool {
118+
matches!(error, ureq::Error::StatusCode(code) if *code == 429 || *code >= 500)
119+
}
120+
121+
fn fetch_icon_source(icon: &str, prefix: &str, name: &str) -> Result<Vec<u8>> {
122+
let url = format!("https://api.iconify.design/{prefix}/{name}.svg");
123+
let mut backoff = Duration::from_millis(250);
124+
let mut last_reason = String::new();
125+
126+
for attempt in 0..ICON_FETCH_ATTEMPTS {
127+
match http_agent().get(&url).call() {
128+
Ok(response) => {
129+
return response
130+
.into_body()
131+
.read_to_vec()
132+
.map_err(|e| RustmotionError::IconFetch {
133+
icon: icon.to_string(),
134+
reason: e.to_string(),
135+
})
136+
}
137+
Err(e) => {
138+
last_reason = e.to_string();
139+
let is_last_attempt = attempt + 1 == ICON_FETCH_ATTEMPTS;
140+
if !status_is_worth_retrying(&e) || is_last_attempt {
141+
break;
142+
}
143+
std::thread::sleep(backoff);
144+
backoff *= 2;
145+
}
146+
}
147+
}
148+
149+
Err(RustmotionError::IconFetch {
150+
icon: icon.to_string(),
151+
reason: last_reason,
152+
})
74153
}
75154

76155
pub fn fetch_icon_svg(icon: &str, color: &str, width: u32, height: u32) -> Result<Vec<u8>> {
@@ -93,37 +172,21 @@ pub fn fetch_icon_svg_in(
93172
let width = width.max(1);
94173
let height = height.max(1);
95174

96-
let cache_file = icon_cache_file(cache_dir, icon, color, width, height);
97-
if let Ok(data) = std::fs::read(&cache_file) {
98-
if !data.is_empty() {
99-
return Ok(data);
175+
let hex_color = hex_color.to_lowercase();
176+
let source_file = icon_source_cache_file(cache_dir, icon);
177+
178+
let source = match std::fs::read(&source_file) {
179+
Ok(data) if !data.is_empty() => data,
180+
_ => {
181+
let fetched = fetch_icon_source(icon, prefix, name)?;
182+
if std::fs::create_dir_all(cache_dir).is_ok() {
183+
let _ = std::fs::write(&source_file, &fetched);
184+
}
185+
fetched
100186
}
101-
}
102-
103-
let url = format!(
104-
"https://api.iconify.design/{}/{}.svg?color=%23{}&width={}&height={}",
105-
prefix, name, hex_color, width, height
106-
);
107-
let response = http_agent()
108-
.get(&url)
109-
.call()
110-
.map_err(|e| RustmotionError::IconFetch {
111-
icon: icon.to_string(),
112-
reason: e.to_string(),
113-
})?;
114-
let body = response
115-
.into_body()
116-
.read_to_vec()
117-
.map_err(|e| RustmotionError::IconFetch {
118-
icon: icon.to_string(),
119-
reason: e.to_string(),
120-
})?;
121-
122-
if std::fs::create_dir_all(cache_dir).is_ok() {
123-
let _ = std::fs::write(&cache_file, &body);
124-
}
187+
};
125188

126-
Ok(body)
189+
Ok(recolour_and_resize(&source, &hex_color, width, height))
127190
}
128191

129192
static VIDEO_FRAME_CACHE: OnceLock<VideoFrameCacheMap> = OnceLock::new();
@@ -419,21 +482,34 @@ mod tests {
419482
let (w, h) = (48, 48);
420483
let svg_bytes = b"<svg>fake cached icon for the test suite</svg>".to_vec();
421484

422-
let cache_file = icon_cache_file(&cache_dir, icon, color, w, h);
485+
let cache_file = icon_source_cache_file(&cache_dir, icon);
486+
std::fs::create_dir_all(&cache_dir).unwrap();
423487
std::fs::write(&cache_file, &svg_bytes).unwrap();
424488

425489
let result = fetch_icon_svg_in(icon, color, w, h, &cache_dir).expect("cache hit");
426-
assert_eq!(result, svg_bytes);
490+
let result = String::from_utf8(result).unwrap();
491+
assert!(
492+
result.contains("fake cached icon for the test suite"),
493+
"the cached source must be what is served — the network was never reached: {result}"
494+
);
495+
assert!(
496+
result.contains(r#"width="48""#),
497+
"and it is resized on the way out, which is what lets one cached source serve \
498+
every colour and size: {result}"
499+
);
427500
}
428501

429502
#[test]
430-
fn disk_cache_is_keyed_by_icon_color_and_size() {
503+
fn disk_cache_is_keyed_by_the_icon_alone() {
431504
let cache_dir = unique_temp_dir("cache-keying");
432-
let a = icon_cache_file(&cache_dir, "lucide:home", "#FFFFFF", 80, 80);
433-
let b = icon_cache_file(&cache_dir, "lucide:home", "#000000", 80, 80);
434-
let c = icon_cache_file(&cache_dir, "lucide:home", "#FFFFFF", 40, 40);
435-
assert_ne!(a, b, "different colors must not share a cache file");
436-
assert_ne!(a, c, "different sizes must not share a cache file");
505+
assert_eq!(
506+
icon_source_cache_file(&cache_dir, "lucide:home"),
507+
icon_source_cache_file(&cache_dir, "lucide:home"),
508+
);
509+
assert_ne!(
510+
icon_source_cache_file(&cache_dir, "lucide:home"),
511+
icon_source_cache_file(&cache_dir, "lucide:check"),
512+
);
437513
}
438514

439515
#[test]
@@ -457,7 +533,7 @@ mod tests {
457533
let first = fetch_icon_svg_in(icon, color, w, h, &cache_dir).expect("live fetch");
458534
assert!(!first.is_empty());
459535

460-
let cache_file = icon_cache_file(&cache_dir, icon, color, w, h);
536+
let cache_file = icon_source_cache_file(&cache_dir, icon);
461537
assert!(
462538
cache_file.exists(),
463539
"a successful live fetch must be persisted to disk"
@@ -647,3 +723,78 @@ mod tests {
647723
let _ = ffprobe_available();
648724
}
649725
}
726+
727+
#[cfg(test)]
728+
mod icon_source_cache_tests {
729+
use super::*;
730+
731+
const LUCIDE_CHECK: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 6 9 17l-5-5"/></svg>"#;
732+
733+
#[test]
734+
fn the_disk_entry_is_named_by_the_icon_alone() {
735+
let dir = Path::new("/tmp/icons");
736+
assert_eq!(
737+
icon_source_cache_file(dir, "lucide:check"),
738+
dir.join("lucide_check.svg"),
739+
"one entry per icon: naming it by colour and size is what filled a cache with 20 \
740+
copies of the same glyph and made a colour change hit the network"
741+
);
742+
}
743+
744+
#[test]
745+
fn recolouring_substitutes_current_color_everywhere() {
746+
let out = recolour_and_resize(LUCIDE_CHECK.as_bytes(), "2a2f6b", 84, 84);
747+
let out = String::from_utf8(out).unwrap();
748+
assert!(!out.contains("currentColor"));
749+
assert!(out.contains(r##"stroke="#2a2f6b""##), "got {out}");
750+
}
751+
752+
#[test]
753+
fn resizing_rewrites_the_root_size_and_leaves_the_viewbox_alone() {
754+
let out = recolour_and_resize(LUCIDE_CHECK.as_bytes(), "ffffff", 84, 96);
755+
let out = String::from_utf8(out).unwrap();
756+
assert!(out.contains(r#"width="84""#), "got {out}");
757+
assert!(out.contains(r#"height="96""#), "got {out}");
758+
assert!(
759+
out.contains(r#"viewBox="0 0 24 24""#),
760+
"the viewBox is the glyph's own coordinate space and must survive a resize: {out}"
761+
);
762+
}
763+
764+
#[test]
765+
fn a_root_without_a_size_gains_one() {
766+
let bare = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"></svg>"#;
767+
let out =
768+
String::from_utf8(recolour_and_resize(bare.as_bytes(), "000000", 32, 32)).unwrap();
769+
assert!(
770+
out.contains(r#"width="32""#) && out.contains(r#"height="32""#),
771+
"got {out}"
772+
);
773+
}
774+
775+
#[test]
776+
fn two_colours_of_one_icon_share_a_single_source_entry() {
777+
let dir = Path::new("/tmp/icons");
778+
assert_eq!(
779+
icon_source_cache_file(dir, "lucide:sparkles"),
780+
icon_source_cache_file(dir, "lucide:sparkles")
781+
);
782+
let red = recolour_and_resize(LUCIDE_CHECK.as_bytes(), "ff0000", 24, 24);
783+
let blue = recolour_and_resize(LUCIDE_CHECK.as_bytes(), "0000ff", 24, 24);
784+
assert_ne!(
785+
red, blue,
786+
"the same source still produces different renders"
787+
);
788+
}
789+
790+
#[test]
791+
fn only_rate_limits_and_server_faults_are_retried() {
792+
assert!(status_is_worth_retrying(&ureq::Error::StatusCode(429)));
793+
assert!(status_is_worth_retrying(&ureq::Error::StatusCode(503)));
794+
assert!(
795+
!status_is_worth_retrying(&ureq::Error::StatusCode(404)),
796+
"a missing icon is not going to appear on the fourth try — retrying it would just \
797+
make a typo take four times as long to report"
798+
);
799+
}
800+
}

‎crates/rustmotion-core/src/error.rs‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,17 @@ pub enum RustmotionError {
4949
#[error("Failed to parse icon SVG '{icon}': {reason}")]
5050
IconParse { icon: String, reason: String },
5151

52+
#[error(
53+
"{count} icon(s) could not be loaded — checked the disk cache at {cache_dir} and the \
54+
network, both failed:\n - {details}\nA render must not silently omit an icon: fix \
55+
the identifier(s), or connect once so they are downloaded and cached for offline use."
56+
)]
57+
IconsUnresolved {
58+
count: usize,
59+
cache_dir: String,
60+
details: String,
61+
},
62+
5263
#[error("Failed to create Skia image from {target}")]
5364
SkiaImageCreation { target: String },
5465

‎crates/rustmotion/src/cli/commands/sheet.rs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,10 @@ pub fn cmd_sheet(
228228
engine::renderer::load_custom_fonts(&scenario.fonts);
229229
}
230230

231+
for view in &scenario.views {
232+
engine::prefetch_icons(&view.scenes)?;
233+
}
234+
231235
let start_time = std::time::Instant::now();
232236
let config = &scenario.video;
233237
let fps = config.fps;

‎crates/rustmotion/src/cli/commands/still.rs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ pub fn cmd_still(
4343
engine::renderer::load_custom_fonts(&scenario.fonts);
4444
}
4545

46+
for view in &scenario.views {
47+
engine::prefetch_icons(&view.scenes)?;
48+
}
49+
4650
let config = &scenario.video;
4751
let fps = config.fps;
4852

‎crates/rustmotion/src/encode/video/ffmpeg.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,7 @@ fn encode_with_ffmpeg_hw_impl(
313313
let fps = config.fps;
314314

315315
for view in &scenario.views {
316-
prefetch_icons(&view.scenes);
316+
prefetch_icons(&view.scenes)?;
317317
}
318318
for failure in analyze_scenario_audio(scenario) {
319319
eprintln!("rustmotion: audio-reactive: {failure} — waveform/audio_spectrum will render flat for this track.");

‎crates/rustmotion/src/encode/video/formats.rs‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ fn encode_png_sequence_to_dir(
5353
let height = config.height;
5454

5555
for view in &scenario.views {
56-
prefetch_icons(&view.scenes);
56+
prefetch_icons(&view.scenes)?;
5757
}
5858

5959
let tasks = build_frame_tasks(scenario);
@@ -146,7 +146,7 @@ fn encode_gif_to_path(
146146
let fps = config.fps;
147147

148148
for view in &scenario.views {
149-
prefetch_icons(&view.scenes);
149+
prefetch_icons(&view.scenes)?;
150150
}
151151

152152
let tasks = build_frame_tasks(scenario);
@@ -232,7 +232,7 @@ fn encode_raw_frames(scenario: &Scenario, quiet: bool, writer: &mut dyn Write) -
232232
let config = &scenario.video;
233233

234234
for view in &scenario.views {
235-
prefetch_icons(&view.scenes);
235+
prefetch_icons(&view.scenes)?;
236236
}
237237

238238
let tasks = build_frame_tasks(scenario);

‎crates/rustmotion/src/encode/video/h264.rs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ fn encode_video_impl(
5656

5757
for view in &scenario.views {
5858
preextract_video_frames(&view.scenes, fps);
59-
prefetch_icons(&view.scenes);
59+
prefetch_icons(&view.scenes)?;
6060
}
6161
for failure in analyze_scenario_audio(scenario) {
6262
eprintln!("rustmotion: audio-reactive: {failure} — waveform/audio_spectrum will render flat for this track.");
@@ -158,7 +158,7 @@ pub fn encode_video_incremental(
158158

159159
for view in &scenario.views {
160160
preextract_video_frames(&view.scenes, fps);
161-
prefetch_icons(&view.scenes);
161+
prefetch_icons(&view.scenes)?;
162162
}
163163
for failure in analyze_scenario_audio(scenario) {
164164
eprintln!("rustmotion: audio-reactive: {failure} — waveform/audio_spectrum will render flat for this track.");

0 commit comments

Comments
 (0)