Skip to main content

moss_core/
asset_snapshot.rs

1//! Pre-fetched asset metadata available to Stage 1 + Stage 2.
2//!
3//! src-tauri's asset pipeline populates this before any markdown processing
4//! runs. moss-core consumes it as input — pure Rust, zero I/O, full data.
5//!
6//! Mirrors the architectural shape of [`crate::content_graph::ContentGraph`]:
7//! src-tauri does the I/O, moss-core takes typed data IN.
8//!
9//! ## Variant keying
10//!
11//! `variants` is keyed by the **stem path** (path with file extension stripped),
12//! not the source path. Rationale: a single source asset (e.g. `assets/photo.jpg`)
13//! may have multiple registered variants (`assets/photo.webp`, `assets/photo.avif`)
14//! and the synthesizer asks "does this source have a webp variant?" without
15//! caring about the source's own extension. src-tauri's
16//! `AssetRegistry::iter_registered_variants` derives variant kinds from URL
17//! extension and folds them under the shared stem. Consumers should look up
18//! variants via [`AssetSnapshot::has_webp_for_source`] /
19//! [`has_avif_for_source`], which normalize the source path to its stem
20//! before the lookup.
21
22use std::collections::HashMap;
23use std::path::PathBuf;
24
25/// Width fallback when AssetSnapshot.dimensions has no entry for a source path.
26/// Returned by `MediaDimensionLookup::get` (in src-tauri) and consumed by
27/// image_render's render_img_tag. The 800x600 pair is the legacy aspect ratio
28/// from pre-Phase-2E days; semantically a layout-shift hint, not a real
29/// dimension. Synthesizer callers may choose to omit width/height entirely
30/// when the dimension lookup returns the fallback (honest-degradation path),
31/// but production keeps them today for byte-shape parity with the
32/// (since-retired) Stage 3 regex post-pass.
33pub const FALLBACK_WIDTH: u32 = 800;
34pub const FALLBACK_HEIGHT: u32 = 600;
35
36/// Pre-fetched asset metadata available to moss-core's synthesizer.
37/// Populated by src-tauri's asset pipeline before any markdown processing runs.
38#[derive(Debug, Default, Clone)]
39pub struct AssetSnapshot {
40    /// Original-image dimensions. Path is the source path as it appears in markdown.
41    pub dimensions: HashMap<PathBuf, (u32, u32)>,
42
43    /// Base64-encoded LQIP data URI. Empty string if no LQIP computed (e.g.
44    /// SVG, decorative images that don't participate in placeholder rendering).
45    pub lqip: HashMap<PathBuf, String>,
46
47    /// Registered variant URLs per source-stem. A variant is "registered" if
48    /// it's in `AssetRegistry::set_pending` (Pending or Ready per ADR-013) —
49    /// moss may emit a `<source srcset=…>` for it. Keyed by stem path
50    /// (extension stripped); see module docs.
51    pub variants: HashMap<PathBuf, VariantKindSet>,
52
53    /// Dominant color hex (e.g. "#a0a0a0") for color-block fallback when
54    /// LQIP isn't viable.
55    pub dominant_color: HashMap<PathBuf, String>,
56
57    /// Whether the source is an animated image (multi-frame GIF or WebP with an
58    /// ANIM chunk). Keyed EXACTLY like `dimensions` — the source path as it
59    /// appears in markdown (with its own extension), NOT the stem-keyed
60    /// `variants` map. A missing key reads as `false` via [`is_animated`], so
61    /// only src-tauri's scanned media populate it. The synthesizer consults it
62    /// to suppress the responsive ladder for animated sources (which must never
63    /// be resized/re-encoded).
64    ///
65    /// [`is_animated`]: AssetSnapshot::is_animated
66    pub animated: HashMap<PathBuf, bool>,
67}
68
69#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
70pub struct VariantKindSet {
71    pub webp: bool,
72    pub avif: bool,
73}
74
75impl AssetSnapshot {
76    pub fn new() -> Self {
77        Self::default()
78    }
79
80    pub fn dims(&self, path: &PathBuf) -> Option<(u32, u32)> {
81        self.dimensions.get(path).copied()
82    }
83
84    pub fn lqip(&self, path: &PathBuf) -> Option<&str> {
85        self.lqip.get(path).map(String::as_str)
86    }
87
88    /// True if the source asset is an animated image (multi-frame GIF or an
89    /// ANIM-chunk WebP). Missing key → `false`.
90    ///
91    /// `path` is the source path as it appears in markdown (with its own
92    /// extension) — keyed identically to [`dims`], so a caller can probe both
93    /// with the same key/normalization side by side.
94    ///
95    /// [`dims`]: AssetSnapshot::dims
96    pub fn is_animated(&self, path: &PathBuf) -> bool {
97        self.animated.get(path).copied().unwrap_or(false)
98    }
99
100    /// True if the source asset has a registered WebP variant.
101    ///
102    /// `src` is the source path as it appears in markdown (with its own
103    /// extension, e.g. `assets/photo.jpg`). The stem is computed and used
104    /// as the lookup key — see module docs for the keying rationale.
105    pub fn has_webp_for_source(&self, src: &PathBuf) -> bool {
106        let stem = path_strip_extension(src);
107        self.variants.get(&stem).map_or(false, |v| v.webp)
108    }
109
110    /// True if the source asset has a registered AVIF variant.
111    pub fn has_avif_for_source(&self, src: &PathBuf) -> bool {
112        let stem = path_strip_extension(src);
113        self.variants.get(&stem).map_or(false, |v| v.avif)
114    }
115}
116
117/// Strip the final file extension from a path:
118/// `assets/photo.webp` → `assets/photo`.
119///
120/// Files with no extension pass through unchanged. The parent directory is
121/// preserved. Used as the canonical keying transform for
122/// [`AssetSnapshot::variants`].
123///
124/// Takes `&PathBuf` (not `&Path`) so callers can write
125/// `path_strip_extension(&"a/b.jpg".into())` and let inference pick `PathBuf`
126/// — `&str` does not coerce to `&Path` through `.into()`.
127pub fn path_strip_extension(p: &PathBuf) -> PathBuf {
128    use std::path::Path;
129    let stem = p.file_stem().unwrap_or_default();
130    p.parent().unwrap_or_else(|| Path::new("")).join(stem)
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    use std::path::PathBuf;
137
138    #[test]
139    fn snapshot_default_is_empty() {
140        let s = AssetSnapshot::new();
141        assert_eq!(s.dims(&PathBuf::from("x.jpg")), None);
142        assert_eq!(s.lqip(&PathBuf::from("x.jpg")), None);
143        assert!(!s.has_webp_for_source(&PathBuf::from("x.jpg")));
144    }
145
146    #[test]
147    fn snapshot_lookups() {
148        let mut s = AssetSnapshot::new();
149        s.dimensions.insert("photo.jpg".into(), (1024, 768));
150        s.lqip
151            .insert("photo.jpg".into(), "data:image/jpeg;base64,xx".into());
152        // Variants are keyed by stem, not source path — see module docs.
153        s.variants.insert(
154            "photo".into(),
155            VariantKindSet {
156                webp: true,
157                avif: false,
158            },
159        );
160
161        assert_eq!(s.dims(&"photo.jpg".into()), Some((1024, 768)));
162        assert_eq!(
163            s.lqip(&"photo.jpg".into()),
164            Some("data:image/jpeg;base64,xx")
165        );
166        assert!(s.has_webp_for_source(&"photo.jpg".into()));
167        assert!(!s.has_avif_for_source(&"photo.jpg".into()));
168    }
169
170    #[test]
171    fn snapshot_is_animated_lookup() {
172        let mut s = AssetSnapshot::new();
173        // Animated is keyed like `dimensions`: the source path as it appears
174        // in markdown (with its own extension), NOT the stem-keyed variant key.
175        s.animated.insert("assets/loop.gif".into(), true);
176        s.animated.insert("assets/still.jpg".into(), false);
177
178        // Present-true.
179        assert!(s.is_animated(&"assets/loop.gif".into()));
180        // Present-false.
181        assert!(!s.is_animated(&"assets/still.jpg".into()));
182        // Missing key → false.
183        assert!(!s.is_animated(&"assets/unknown.png".into()));
184    }
185
186    #[test]
187    fn snapshot_has_webp_for_source_strips_extension() {
188        let mut s = AssetSnapshot::new();
189        // Variants are keyed by stem (no extension), per the spec.
190        let stem = path_strip_extension(&"assets/photo.jpg".into());
191        s.variants.insert(
192            stem,
193            VariantKindSet {
194                webp: true,
195                avif: false,
196            },
197        );
198        // Looking up by the source path (with .jpg) should find the variant.
199        assert!(s.has_webp_for_source(&"assets/photo.jpg".into()));
200        assert!(!s.has_avif_for_source(&"assets/photo.jpg".into()));
201        assert!(!s.has_webp_for_source(&"assets/other.jpg".into()));
202    }
203
204    #[test]
205    fn path_strip_extension_basic() {
206        assert_eq!(
207            path_strip_extension(&PathBuf::from("assets/photo.jpg")),
208            PathBuf::from("assets/photo")
209        );
210        assert_eq!(
211            path_strip_extension(&PathBuf::from("photo.webp")),
212            PathBuf::from("photo")
213        );
214        // No extension — passes through.
215        assert_eq!(
216            path_strip_extension(&PathBuf::from("assets/photo")),
217            PathBuf::from("assets/photo")
218        );
219        // Nested path.
220        assert_eq!(
221            path_strip_extension(&PathBuf::from("a/b/c/photo.png")),
222            PathBuf::from("a/b/c/photo")
223        );
224    }
225}