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}