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
58#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
59pub struct VariantKindSet {
60    pub webp: bool,
61    pub avif: bool,
62}
63
64impl AssetSnapshot {
65    pub fn new() -> Self {
66        Self::default()
67    }
68
69    pub fn dims(&self, path: &PathBuf) -> Option<(u32, u32)> {
70        self.dimensions.get(path).copied()
71    }
72
73    pub fn lqip(&self, path: &PathBuf) -> Option<&str> {
74        self.lqip.get(path).map(String::as_str)
75    }
76
77    /// True if the source asset has a registered WebP variant.
78    ///
79    /// `src` is the source path as it appears in markdown (with its own
80    /// extension, e.g. `assets/photo.jpg`). The stem is computed and used
81    /// as the lookup key — see module docs for the keying rationale.
82    pub fn has_webp_for_source(&self, src: &PathBuf) -> bool {
83        let stem = path_strip_extension(src);
84        self.variants.get(&stem).map_or(false, |v| v.webp)
85    }
86
87    /// True if the source asset has a registered AVIF variant.
88    pub fn has_avif_for_source(&self, src: &PathBuf) -> bool {
89        let stem = path_strip_extension(src);
90        self.variants.get(&stem).map_or(false, |v| v.avif)
91    }
92}
93
94/// Strip the final file extension from a path:
95/// `assets/photo.webp` → `assets/photo`.
96///
97/// Files with no extension pass through unchanged. The parent directory is
98/// preserved. Used as the canonical keying transform for
99/// [`AssetSnapshot::variants`].
100///
101/// Takes `&PathBuf` (not `&Path`) so callers can write
102/// `path_strip_extension(&"a/b.jpg".into())` and let inference pick `PathBuf`
103/// — `&str` does not coerce to `&Path` through `.into()`.
104pub fn path_strip_extension(p: &PathBuf) -> PathBuf {
105    use std::path::Path;
106    let stem = p.file_stem().unwrap_or_default();
107    p.parent().unwrap_or_else(|| Path::new("")).join(stem)
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113    use std::path::PathBuf;
114
115    #[test]
116    fn snapshot_default_is_empty() {
117        let s = AssetSnapshot::new();
118        assert_eq!(s.dims(&PathBuf::from("x.jpg")), None);
119        assert_eq!(s.lqip(&PathBuf::from("x.jpg")), None);
120        assert!(!s.has_webp_for_source(&PathBuf::from("x.jpg")));
121    }
122
123    #[test]
124    fn snapshot_lookups() {
125        let mut s = AssetSnapshot::new();
126        s.dimensions.insert("photo.jpg".into(), (1024, 768));
127        s.lqip
128            .insert("photo.jpg".into(), "data:image/jpeg;base64,xx".into());
129        // Variants are keyed by stem, not source path — see module docs.
130        s.variants.insert(
131            "photo".into(),
132            VariantKindSet {
133                webp: true,
134                avif: false,
135            },
136        );
137
138        assert_eq!(s.dims(&"photo.jpg".into()), Some((1024, 768)));
139        assert_eq!(
140            s.lqip(&"photo.jpg".into()),
141            Some("data:image/jpeg;base64,xx")
142        );
143        assert!(s.has_webp_for_source(&"photo.jpg".into()));
144        assert!(!s.has_avif_for_source(&"photo.jpg".into()));
145    }
146
147    #[test]
148    fn snapshot_has_webp_for_source_strips_extension() {
149        let mut s = AssetSnapshot::new();
150        // Variants are keyed by stem (no extension), per the spec.
151        let stem = path_strip_extension(&"assets/photo.jpg".into());
152        s.variants.insert(
153            stem,
154            VariantKindSet {
155                webp: true,
156                avif: false,
157            },
158        );
159        // Looking up by the source path (with .jpg) should find the variant.
160        assert!(s.has_webp_for_source(&"assets/photo.jpg".into()));
161        assert!(!s.has_avif_for_source(&"assets/photo.jpg".into()));
162        assert!(!s.has_webp_for_source(&"assets/other.jpg".into()));
163    }
164
165    #[test]
166    fn path_strip_extension_basic() {
167        assert_eq!(
168            path_strip_extension(&PathBuf::from("assets/photo.jpg")),
169            PathBuf::from("assets/photo")
170        );
171        assert_eq!(
172            path_strip_extension(&PathBuf::from("photo.webp")),
173            PathBuf::from("photo")
174        );
175        // No extension — passes through.
176        assert_eq!(
177            path_strip_extension(&PathBuf::from("assets/photo")),
178            PathBuf::from("assets/photo")
179        );
180        // Nested path.
181        assert_eq!(
182            path_strip_extension(&PathBuf::from("a/b/c/photo.png")),
183            PathBuf::from("a/b/c/photo")
184        );
185    }
186}