Skip to main content

thread_engine/
assets.rs

1//! Asset resolution — turn a World Manifest asset URI into a readable local file,
2//! fetching remote ones into an on-disk cache.
3//!
4//! A manifest references glTF meshes and textures by [`Asset`] URI, which may be a
5//! path relative to the world, an absolute `http(s)://` URL, or `ipfs://…`. An
6//! [`AssetSource`] resolves any of these to a local path a renderer can open —
7//! downloading remote ones once and caching them by URL, so revisits are instant.
8//!
9//! It is renderer-agnostic on purpose: the browser hands it to its `WorldLoader`,
10//! which calls [`AssetSource::resolve`] wherever it used to join a local path.
11//! [`AssetSource::prefetch`] downloads a whole world's assets up front (call it on
12//! the background travel thread), so the main thread only ever reads local files.
13
14use std::path::PathBuf;
15
16use infinite_manifest::WorldManifest;
17
18/// Where a world's assets live, and where fetched ones are cached.
19pub struct AssetSource {
20    /// Base for relative URIs when the world is local (a `world.json` directory).
21    base_dir: PathBuf,
22    /// Base URL for relative URIs when the world is hosted (e.g. its `.well-known`
23    /// directory). `None` for a local world.
24    base_url: Option<String>,
25    /// Directory fetched remote assets are cached in.
26    cache_dir: PathBuf,
27}
28
29impl AssetSource {
30    /// A local world: relative URIs resolve against `base_dir`; absolute URLs still
31    /// fetch to a shared cache.
32    pub fn local(base_dir: impl Into<PathBuf>) -> Self {
33        Self {
34            base_dir: base_dir.into(),
35            base_url: None,
36            cache_dir: default_cache(),
37        }
38    }
39
40    /// A hosted world: `base_url` is the world's directory URL (relative URIs resolve
41    /// against it); remote assets cache under `cache_dir`.
42    pub fn hosted(
43        base_dir: impl Into<PathBuf>,
44        base_url: Option<String>,
45        cache_dir: impl Into<PathBuf>,
46    ) -> Self {
47        Self {
48            base_dir: base_dir.into(),
49            base_url,
50            cache_dir: cache_dir.into(),
51        }
52    }
53
54    /// Resolve an asset URI to a readable local path, fetching + caching if remote.
55    /// Returns `None` if a remote fetch failed (the caller degrades gracefully —
56    /// e.g. falls back to a built-in mesh or a flat colour).
57    pub fn resolve(&self, uri: &str) -> Option<PathBuf> {
58        match self.absolute_url(uri) {
59            Some(url) => self.fetch_cached(&url),
60            None => Some(self.base_dir.join(uri)),
61        }
62    }
63
64    /// Download every remote asset in `manifest` into the cache (idempotent). Run on
65    /// a background thread before the main-thread GPU load so it only reads locally.
66    pub fn prefetch(&self, manifest: &WorldManifest) {
67        for a in &manifest.assets {
68            if self.absolute_url(&a.uri).is_some() && self.resolve(&a.uri).is_none() {
69                tracing::warn!("asset: could not fetch '{}'", a.uri);
70            }
71        }
72    }
73
74    /// The absolute URL an asset URI resolves to, or `None` if it's a local relative
75    /// path (a local world with no base URL).
76    fn absolute_url(&self, uri: &str) -> Option<String> {
77        if uri.starts_with("http://") || uri.starts_with("https://") {
78            Some(uri.to_string())
79        } else if let Some(rest) = uri.strip_prefix("ipfs://") {
80            Some(format!("https://ipfs.io/ipfs/{rest}"))
81        } else if let Some(base) = &self.base_url {
82            join_url(base, uri)
83        } else {
84            None
85        }
86    }
87
88    /// Fetch `url` into the cache (skipping the download if already present).
89    fn fetch_cached(&self, url: &str) -> Option<PathBuf> {
90        let path = self.cache_dir.join(cache_name(url));
91        if path.exists() {
92            return Some(path);
93        }
94        let bytes = match http_get_bytes(url) {
95            Ok(b) => b,
96            Err(e) => {
97                tracing::warn!("asset: fetch failed for {url}: {e}");
98                return None;
99            }
100        };
101        std::fs::create_dir_all(&self.cache_dir).ok()?;
102        std::fs::write(&path, &bytes).ok()?;
103        tracing::info!("asset: fetched {} ({} KB)", url, bytes.len() / 1024);
104        Some(path)
105    }
106}
107
108/// Default shared cache directory for fetched assets.
109fn default_cache() -> PathBuf {
110    std::env::temp_dir().join("infinite_thread_assets")
111}
112
113/// Join a base directory URL with a relative URI (`base` should end in `/`).
114fn join_url(base: &str, uri: &str) -> Option<String> {
115    reqwest::Url::parse(base)
116        .ok()?
117        .join(uri)
118        .ok()
119        .map(|u| u.to_string())
120}
121
122/// A stable cache filename for a URL: a hash of the URL + the asset's extension
123/// (extension preserved so glTF/image loaders can sniff the format).
124fn cache_name(url: &str) -> String {
125    let mut h: u64 = 0xcbf29ce484222325;
126    for b in url.bytes() {
127        h ^= b as u64;
128        h = h.wrapping_mul(0x100000001b3);
129    }
130    let ext = url
131        .rsplit('/')
132        .next()
133        .and_then(|seg| seg.split(['?', '#']).next())
134        .and_then(|name| name.rsplit_once('.'))
135        .map(|(_, e)| e)
136        .filter(|e| e.len() <= 5 && e.chars().all(|c| c.is_ascii_alphanumeric()))
137        .unwrap_or("bin");
138    format!("{h:016x}.{ext}")
139}
140
141/// Blocking HTTP GET returning the body bytes (on a throwaway runtime).
142fn http_get_bytes(url: &str) -> Result<Vec<u8>, String> {
143    let rt = tokio::runtime::Builder::new_current_thread()
144        .enable_all()
145        .build()
146        .map_err(|e| e.to_string())?;
147    rt.block_on(async {
148        // Some hosts (Wikimedia among them) refuse UA-less requests.
149        let client = reqwest::Client::builder()
150            .user_agent("Infinite-Thread-Browser/0.1 (+https://github.com/Pixygon/Infinite)")
151            .build()
152            .map_err(|e| e.to_string())?;
153        let resp = client.get(url).send().await.map_err(|e| e.to_string())?;
154        if !resp.status().is_success() {
155            return Err(format!("HTTP {}", resp.status().as_u16()));
156        }
157        resp.bytes()
158            .await
159            .map(|b| b.to_vec())
160            .map_err(|e| e.to_string())
161    })
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use std::path::Path;
168
169    #[test]
170    fn local_relative_uris_join_the_base_dir() {
171        let src = AssetSource::local("/worlds/gallery");
172        assert_eq!(
173            src.resolve("meshes/statue.glb"),
174            Some(PathBuf::from("/worlds/gallery/meshes/statue.glb"))
175        );
176        // A local world with no base URL never treats a relative path as remote.
177        assert!(src.absolute_url("meshes/statue.glb").is_none());
178    }
179
180    #[test]
181    fn absolute_and_ipfs_uris_become_urls() {
182        let src = AssetSource::local("/w");
183        assert_eq!(
184            src.absolute_url("https://cdn.example/tree.glb").as_deref(),
185            Some("https://cdn.example/tree.glb")
186        );
187        assert_eq!(
188            src.absolute_url("ipfs://Qm123/model.glb").as_deref(),
189            Some("https://ipfs.io/ipfs/Qm123/model.glb")
190        );
191    }
192
193    #[test]
194    fn hosted_relative_uris_resolve_against_the_world_url() {
195        let src = AssetSource::hosted(
196            Path::new("/cache"),
197            Some("https://studio.example/.well-known/thread/gallery/".into()),
198            "/cache",
199        );
200        assert_eq!(
201            src.absolute_url("meshes/statue.glb").as_deref(),
202            Some("https://studio.example/.well-known/thread/gallery/meshes/statue.glb")
203        );
204    }
205
206    /// The live remote-asset walk: fetch a real glTF over the network through the
207    /// same path the background travel thread uses (`prefetch` → `resolve`).
208    /// Ignored by default (network); run with `cargo test -p loom -- --ignored`.
209    /// Once the hosted worlds' assetBase points at the CDN, this is the code path
210    /// every remote world walks through.
211    #[test]
212    #[ignore = "requires network"]
213    fn a_remote_gltf_fetches_once_and_caches() {
214        // Khronos' sample Box.glb — tiny (~1.6 KB), stable, canonical.
215        const URL: &str = "https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/main/Models/Box/glTF-Binary/Box.glb";
216        let cache = std::env::temp_dir().join(format!("loom-remote-asset-{}", std::process::id()));
217        let _ = std::fs::remove_dir_all(&cache);
218        let src = AssetSource::hosted(Path::new("/nowhere"), None, &cache);
219
220        // Prefetch via a manifest, exactly as the travel thread does.
221        let manifest: WorldManifest = serde_json::from_value(serde_json::json!({
222            "thread": "0.1", "world": { "id": "remote-test", "title": "Remote" },
223            "assets": [{ "id": "box", "uri": URL, "kind": "gltf" }]
224        }))
225        .unwrap();
226        src.prefetch(&manifest);
227
228        let path = src
229            .resolve(URL)
230            .expect("remote glb resolves to a local path");
231        let bytes = std::fs::read(&path).expect("cached file is readable");
232        assert_eq!(
233            &bytes[..4],
234            b"glTF",
235            "the cached bytes are a real binary glTF"
236        );
237
238        // Second resolve is a cache hit — same path, no re-download needed.
239        let mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
240        assert_eq!(src.resolve(URL).as_deref(), Some(path.as_path()));
241        assert_eq!(
242            std::fs::metadata(&path).unwrap().modified().unwrap(),
243            mtime,
244            "not re-fetched"
245        );
246
247        let _ = std::fs::remove_dir_all(&cache);
248    }
249
250    #[test]
251    fn cache_names_are_stable_and_keep_the_extension() {
252        let a = cache_name("https://cdn/tree.glb");
253        assert_eq!(a, cache_name("https://cdn/tree.glb"), "deterministic");
254        assert!(a.ends_with(".glb"));
255        assert!(
256            cache_name("https://cdn/img.png?v=2").ends_with(".png"),
257            "strips query"
258        );
259        assert!(
260            cache_name("https://cdn/noext").ends_with(".bin"),
261            "falls back"
262        );
263        assert_ne!(
264            cache_name("https://cdn/a.glb"),
265            cache_name("https://cdn/b.glb")
266        );
267    }
268}