Skip to content

Commit d69e027

Browse files
authored
fix(loader): resolve relative asset paths against the scenario, not the CWD (#200)
"src": "assets/logo.png" resolved against the process working directory, so the same file rendered from its own folder and failed from anywhere else — the studio runs from the repository root, which is why a scenario authored beside its assets showed nothing there. `include` had always resolved relative to the including file. Two path-like fields in one document following two different rules is the trap, and it is the single cause behind a family of "component X does not render" reports: a hard error for image and gif, a warning for video, silence for an audio track feeding a waveform. Rewrite on the raw JSON before deserialisation, so no component changes: by the time an image or an audio track is constructed its src is already absolute. Applied in the JSON loader, the HTML loader, the CLI's validation pipeline, and per included file — an include's assets belong to the file that names them, not to the parent that pulled it in. Deliberately conservative: a path is rewritten only when the file exists next to the scenario. Anything else is left exactly as written, so a path that used to resolve against the working directory still does, and a genuine typo still reaches the validator with the author's own spelling. `track` is rewritten alongside `src`: the audio analysis is cached under the track's src and a waveform finds it by track, so rewriting one and not the other would make every lookup miss.
1 parent 2e99161 commit d69e027

6 files changed

Lines changed: 255 additions & 0 deletions

File tree

crates/rustmotion-cli/src/commands/validation.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,13 @@ pub fn load_with_vars(
188188
// renders.
189189
expand::expand_directives(&mut json_value, &label)?;
190190

191+
// Assets are relative to the scenario file, like `include` — and this must
192+
// happen before `raw` is captured, so the existence check below and the
193+
// renderer look at the same, already-resolved paths.
194+
if let Some(dir) = source_path.as_ref().and_then(|p| p.parent()) {
195+
rustmotion::assets::rebase_relative_paths(&mut json_value, dir);
196+
}
197+
191198
let scenario: Scenario = serde_json::from_value(json_value.clone())?;
192199
let resolved = include::resolve_includes(scenario, &include_source)?;
193200

crates/rustmotion/src/assets.rs

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
//! Resolve relative asset paths against the scenario file that names them.
2+
//!
3+
//! `"src": "assets/logo.png"` used to resolve against the *process* working
4+
//! directory, so the same scenario rendered from its own folder and failed from
5+
//! anywhere else — including the studio, which runs from the repository root.
6+
//! `include` had always resolved relative to the including file; two path-like
7+
//! fields in one document following two different rules is the trap.
8+
//!
9+
//! The rewrite happens on the raw JSON, before deserialisation, so no component
10+
//! needs to know about it: by the time an `image` or an `audio` track is
11+
//! constructed its `src` is already absolute.
12+
13+
use std::path::Path;
14+
15+
use serde_json::Value;
16+
17+
/// Keys whose string value names a file on disk.
18+
///
19+
/// `src` covers `image`, `video`, `gif`, `avatar` (and each entry of an
20+
/// `avatar_group`), `mockup`, `lottie` and `audio`; `track` is the audio-source
21+
/// reference on `waveform`/`audio_spectrum` and in `style.audio-reactive`,
22+
/// which must name the same string the audio track does or the analysis lookup
23+
/// misses.
24+
const PATH_KEYS: &[&str] = &["src", "track"];
25+
26+
fn is_remote(s: &str) -> bool {
27+
s.starts_with("http://") || s.starts_with("https://") || s.starts_with("data:")
28+
}
29+
30+
/// Rewrite every relative asset path in `value` to an absolute one, resolved
31+
/// against `base_dir`.
32+
///
33+
/// Deliberately conservative: a path is rewritten **only** when the file exists
34+
/// next to the scenario. Anything else is left exactly as written, so a path
35+
/// that used to resolve against the working directory still does, and a genuine
36+
/// typo still reaches the validator with the author's own spelling in the
37+
/// message rather than a rewritten one they never typed.
38+
pub fn rebase_relative_paths(value: &mut Value, base_dir: &Path) {
39+
match value {
40+
Value::Object(map) => {
41+
for (key, child) in map.iter_mut() {
42+
if PATH_KEYS.contains(&key.as_str()) {
43+
if let Value::String(s) = child {
44+
if let Some(abs) = rebased(s, base_dir) {
45+
*s = abs;
46+
continue;
47+
}
48+
}
49+
}
50+
rebase_relative_paths(child, base_dir);
51+
}
52+
}
53+
Value::Array(items) => {
54+
for item in items {
55+
rebase_relative_paths(item, base_dir);
56+
}
57+
}
58+
_ => {}
59+
}
60+
}
61+
62+
/// `Some(absolute)` when `src` is relative and names an existing file under
63+
/// `base_dir`; `None` when it must be left alone.
64+
fn rebased(src: &str, base_dir: &Path) -> Option<String> {
65+
if src.is_empty() || is_remote(src) {
66+
return None;
67+
}
68+
let path = Path::new(src);
69+
if path.is_absolute() {
70+
return None;
71+
}
72+
let candidate = base_dir.join(path);
73+
if !candidate.is_file() {
74+
return None;
75+
}
76+
// `canonicalize` resolves `..` and symlinks so two spellings of the same
77+
// file share one cache key — the audio analysis and the GIF/image caches
78+
// are keyed by this string.
79+
let resolved = std::fs::canonicalize(&candidate).unwrap_or(candidate);
80+
Some(resolved.to_str()?.to_string())
81+
}
82+
83+
#[cfg(test)]
84+
mod tests {
85+
use super::*;
86+
use serde_json::json;
87+
88+
fn scratch() -> std::path::PathBuf {
89+
let dir = std::env::temp_dir().join(format!(
90+
"rustmotion_assets_{}_{:?}",
91+
std::process::id(),
92+
std::thread::current().id()
93+
));
94+
std::fs::create_dir_all(dir.join("assets")).expect("scratch dir");
95+
dir
96+
}
97+
98+
#[test]
99+
fn a_relative_src_next_to_the_scenario_becomes_absolute() {
100+
let dir = scratch();
101+
std::fs::write(dir.join("assets/logo.png"), b"x").expect("fixture");
102+
103+
let mut v =
104+
json!({"scenes": [{"children": [{"type": "image", "src": "assets/logo.png"}]}]});
105+
rebase_relative_paths(&mut v, &dir);
106+
107+
let got = v["scenes"][0]["children"][0]["src"].as_str().expect("src");
108+
assert!(Path::new(got).is_absolute(), "not rewritten: {got}");
109+
assert!(Path::new(got).is_file(), "rewritten to a non-file: {got}");
110+
std::fs::remove_dir_all(&dir).ok();
111+
}
112+
113+
/// The whole point: the rewrite must not depend on where the process runs.
114+
#[test]
115+
fn the_result_does_not_depend_on_the_working_directory() {
116+
let dir = scratch();
117+
std::fs::write(dir.join("assets/logo.png"), b"x").expect("fixture");
118+
119+
let mut a = json!({"src": "assets/logo.png"});
120+
let mut b = json!({"src": "assets/logo.png"});
121+
rebase_relative_paths(&mut a, &dir);
122+
rebase_relative_paths(&mut b, &dir);
123+
assert_eq!(a, b);
124+
assert_ne!(a["src"], json!("assets/logo.png"));
125+
std::fs::remove_dir_all(&dir).ok();
126+
}
127+
128+
/// A path that does not exist beside the scenario keeps the author's own
129+
/// spelling, so the validator's message names what they typed.
130+
#[test]
131+
fn a_missing_file_is_left_untouched() {
132+
let dir = scratch();
133+
let mut v = json!({"src": "assets/absent.png"});
134+
rebase_relative_paths(&mut v, &dir);
135+
assert_eq!(v["src"], json!("assets/absent.png"));
136+
std::fs::remove_dir_all(&dir).ok();
137+
}
138+
139+
#[test]
140+
fn absolute_and_remote_sources_are_left_untouched() {
141+
let dir = scratch();
142+
let mut v = json!({
143+
"a": {"src": "/etc/hosts"},
144+
"b": {"src": "https://example.com/x.png"},
145+
"c": {"src": "data:image/png;base64,AAAA"}
146+
});
147+
let before = v.clone();
148+
rebase_relative_paths(&mut v, &dir);
149+
assert_eq!(v, before);
150+
std::fs::remove_dir_all(&dir).ok();
151+
}
152+
153+
/// `track` must be rewritten the same way as `src`: the audio analysis is
154+
/// cached under the track's `src`, and a `waveform` finds it by `track`.
155+
/// Rewriting one and not the other would make every lookup miss.
156+
#[test]
157+
fn track_is_rebased_like_src_so_the_analysis_lookup_still_matches() {
158+
let dir = scratch();
159+
std::fs::write(dir.join("assets/t.wav"), b"x").expect("fixture");
160+
161+
let mut v = json!({
162+
"audio": [{"src": "assets/t.wav"}],
163+
"scenes": [{"children": [{"type": "waveform", "track": "assets/t.wav"}]}]
164+
});
165+
rebase_relative_paths(&mut v, &dir);
166+
167+
assert_eq!(
168+
v["audio"][0]["src"], v["scenes"][0]["children"][0]["track"],
169+
"src and track must resolve to the same string"
170+
);
171+
std::fs::remove_dir_all(&dir).ok();
172+
}
173+
174+
/// Keys that merely *contain* a path-like string are not touched — only the
175+
/// documented asset fields are.
176+
#[test]
177+
fn unrelated_keys_are_not_rewritten() {
178+
let dir = scratch();
179+
std::fs::write(dir.join("assets/logo.png"), b"x").expect("fixture");
180+
let mut v = json!({"content": "assets/logo.png", "title": "assets/logo.png"});
181+
let before = v.clone();
182+
rebase_relative_paths(&mut v, &dir);
183+
assert_eq!(v, before);
184+
std::fs::remove_dir_all(&dir).ok();
185+
}
186+
}

crates/rustmotion/src/include.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,15 @@ fn fetch_and_resolve(
148148
directive.config.as_ref(),
149149
&directive.include,
150150
)?;
151+
152+
// An included file's assets are relative to *that* file, not to the parent
153+
// that pulled it in — otherwise moving an include would silently break
154+
// every path inside it.
155+
if let IncludeSource::File(ref p) = child_source {
156+
if let Some(dir) = p.parent() {
157+
crate::assets::rebase_relative_paths(&mut json_value, dir);
158+
}
159+
}
151160
// `components` (and any `for-each`/`use` inside this file's own scenes)
152161
// is scoped to this document: expanded here, per included file, using
153162
// ONLY this file's own `components` block — never the parent's, and

crates/rustmotion/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ pub mod engine {
1818
}
1919

2020
// Local modules
21+
pub mod assets;
2122
pub mod encode;
2223
pub mod include;
2324
pub mod loader;

crates/rustmotion/src/loader.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ pub fn load_scenario_with_vars(
2424
let label = input.display().to_string();
2525
variables::apply_variables(&mut json_value, overrides, &label)?;
2626
expand::expand_directives(&mut json_value, &label)?;
27+
// Asset paths are relative to the file that names them, like `include` —
28+
// not to wherever the process happens to run.
29+
if let Some(dir) = input.parent() {
30+
{
31+
crate::assets::rebase_relative_paths(&mut json_value, dir);
32+
}
33+
}
2734

2835
let scenario: Scenario = serde_json::from_value(json_value).map_err(RustmotionError::from)?;
2936
include::resolve_includes(scenario, &include::IncludeSource::File(input.clone()))
@@ -100,6 +107,10 @@ pub fn load_scenario_from_html_with_vars(
100107
let label = input.display().to_string();
101108
variables::apply_variables(&mut value, overrides, &label)?;
102109
expand::expand_directives(&mut value, &label)?;
110+
// Same rule as the JSON loader: assets are relative to the file naming them.
111+
if let Some(dir) = input.parent() {
112+
crate::assets::rebase_relative_paths(&mut value, dir);
113+
}
103114
let scenario: Scenario = serde_json::from_value(value).map_err(RustmotionError::from)?;
104115
include::resolve_includes(scenario, &include::IncludeSource::File(input.clone()))
105116
}

crates/rustmotion/src/tests.rs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1984,6 +1984,47 @@ mod audio_tests {
19841984
count
19851985
}
19861986

1987+
/// The failure the whole rewrite exists for: a scenario naming an asset
1988+
/// beside itself must load identically whatever directory the process
1989+
/// runs from. Authoring from the scenario's folder worked; the studio,
1990+
/// which runs from the repository root, resolved nothing.
1991+
#[test]
1992+
fn a_relative_asset_resolves_against_the_scenario_not_the_cwd() {
1993+
let dir = std::env::temp_dir().join(format!("rustmotion_cwd_{}", nanos()));
1994+
std::fs::create_dir_all(dir.join("assets")).expect("scratch");
1995+
std::fs::write(
1996+
dir.join("assets/t.wav"),
1997+
make_sine_wav(4410, 4410, 440.0, 44100),
1998+
)
1999+
.expect("fixture");
2000+
2001+
let scenario_path = dir.join("scene.json");
2002+
std::fs::write(
2003+
&scenario_path,
2004+
serde_json::json!({
2005+
"video": {"width": 32, "height": 32, "fps": 30},
2006+
"audio": [{"src": "assets/t.wav"}],
2007+
"scenes": [{"duration": 0.1, "children": []}]
2008+
})
2009+
.to_string(),
2010+
)
2011+
.expect("write scenario");
2012+
2013+
let loaded = crate::loader::load_scenario_with_vars(&scenario_path, None)
2014+
.expect("scenario must load");
2015+
let src = &loaded.audio[0].src;
2016+
2017+
assert!(
2018+
std::path::Path::new(src).is_absolute(),
2019+
"the asset path must not stay relative to the process: {src}"
2020+
);
2021+
assert!(
2022+
std::path::Path::new(src).is_file(),
2023+
"and it must point at the file beside the scenario: {src}"
2024+
);
2025+
std::fs::remove_dir_all(&dir).ok();
2026+
}
2027+
19872028
/// A track placed at `start` must be *read* from `start` too.
19882029
///
19892030
/// The mux places the file at `track.start` on the scenario timeline and

0 commit comments

Comments
 (0)