//! Where previews live and how their state is recorded. //! //! One original always keeps exactly one derivative next to it: //! //! ```text //! avatars/{uid}.jpg -> avatars/{uid}.preview.jpg //! space-images/{sid}/{kind}.png -> space-images/{sid}/{kind}.preview.webp //! chat/attachments/{uid}/{id}/{f} -> chat/attachments/{uid}/{id}/{stem}.preview.jpg //! ``` //! //! The extension of the derivative is not fixed: opaque pictures become JPEG and //! transparent ones become WebP, so the exact key is recorded rather than //! guessed. Recording it (in the avatar/space `.meta.json` sidecar and in //! `chat_attachment.variants`) is also what stops a skipped source — an //! animation, an oversized photo — from being decoded again on every request. use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; /// `kind` discriminator for the preview entry of a `variants` array. pub const PREVIEW_KIND: &str = "preview"; /// Lock TTL for the single-flight around lazy generation. Generous relative to /// render time, short enough that a crashed pod releases the object quickly. pub const PREVIEW_LOCK_TTL_SECS: u64 = 60; /// Extensions a derivative may carry. Used to clean up a stale derivative when /// an upload switches between opaque and transparent encodings. pub const PREVIEW_EXTENSIONS: [&str; 2] = ["jpg", "webp"]; /// Which representation a read endpoint should hand back. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "lowercase")] pub enum ImageVariant { /// The compressed derivative. This is what list and grid views load. Preview, /// The bytes exactly as uploaded. Original, } /// `/.` -> `/.preview`, and `/` without an /// extension simply gains the suffix. The suffix never changes the directory, so /// a derivative can never collide with another object's original. #[must_use] pub fn preview_key_base(original_key: &str) -> String { let directory_end = original_key.rfind('/').map_or(0, |slash| slash + 1); match original_key.rfind('.') { Some(dot) if dot >= directory_end => format!("{}.preview", &original_key[..dot]), _ => format!("{original_key}.preview"), } } #[must_use] pub fn preview_key(key_base: &str, extension: &str) -> String { format!("{key_base}.{extension}") } /// Every key a derivative of one original could occupy. #[must_use] pub fn preview_candidate_keys(key_base: &str) -> Vec { PREVIEW_EXTENSIONS .iter() .map(|extension| preview_key(key_base, extension)) .collect() } /// Redis key for the single-flight lock. Derived from the derivative base, so a /// lock is per original object and survives restarts but not replacement. #[must_use] pub fn preview_lock_key(key_base: &str) -> String { format!("imaging:preview-lock:{key_base}") } /// A derivative that exists in object storage. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ReadyPreview { pub key: String, pub content_type: String, pub bytes: u64, } /// What is known about one original's derivative. #[derive(Debug, Clone, PartialEq, Eq)] pub enum PreviewState { Ready(ReadyPreview), /// Generation was attempted and deliberately produced nothing; the original /// is the preview. Stored so the attempt is not repeated. Skipped(String), } impl PreviewState { #[must_use] pub fn ready(key: String, content_type: &str, bytes: u64) -> Self { Self::Ready(ReadyPreview { key, content_type: content_type.to_string(), bytes, }) } } /// Read the recorded preview state out of a metadata record. /// /// Records are JSON objects that carry unrelated fields (avatar format, upload /// time, ...), so this only ever looks at its own two keys and tolerates any /// other shape — including a legacy `[]` `variants` value. #[must_use] pub fn preview_from_record(record: &Value) -> Option { let object = record.as_object()?; if let Some(ready) = object.get("preview").and_then(Value::as_object) { let key = ready.get("key").and_then(Value::as_str)?; let content_type = ready .get("content_type") .and_then(Value::as_str) .unwrap_or("image/jpeg"); let bytes = ready.get("bytes").and_then(Value::as_u64).unwrap_or(0); return Some(PreviewState::Ready(ReadyPreview { key: key.to_string(), content_type: content_type.to_string(), bytes, })); } object .get("preview_skipped") .and_then(Value::as_str) .map(|reason| PreviewState::Skipped(reason.to_string())) } /// Record `state` into a metadata record, replacing any previous preview state. pub fn preview_into_record(record: &mut Value, state: &PreviewState) { if !record.is_object() { *record = Value::Object(serde_json::Map::new()); } let Some(object) = record.as_object_mut() else { return; }; object.remove("preview_skipped"); match state { PreviewState::Ready(ready) => { object.insert( "preview".to_string(), json!({ "key": ready.key, "content_type": ready.content_type, "bytes": ready.bytes, }), ); } PreviewState::Skipped(reason) => { object.remove("preview"); object.insert("preview_skipped".to_string(), Value::String(reason.clone())); } } } /// Read the preview entry out of a `chat_attachment.variants` array. /// /// That column is an array (its schema default is `[]`), one object per /// generated variant, so entries are matched by `kind` rather than by position. #[must_use] pub fn preview_from_variants(variants: &Value) -> Option { let entries = variants.as_array()?; for entry in entries { let Some(object) = entry.as_object() else { continue; }; if object.get("kind").and_then(Value::as_str) != Some(PREVIEW_KIND) { continue; } if object.get("skipped").and_then(Value::as_bool) == Some(true) { let reason = object .get("reason") .and_then(Value::as_str) .unwrap_or("skipped"); return Some(PreviewState::Skipped(reason.to_string())); } let key = object.get("key").and_then(Value::as_str)?; let content_type = object .get("content_type") .and_then(Value::as_str) .unwrap_or("image/jpeg"); let bytes = object.get("bytes").and_then(Value::as_u64).unwrap_or(0); return Some(PreviewState::Ready(ReadyPreview { key: key.to_string(), content_type: content_type.to_string(), bytes, })); } None } /// Replace the preview entry of a `variants` array, preserving any other entries. /// A non-array value (including the schema default `[]` shape) is normalized. pub fn preview_into_variants(variants: &mut Value, state: &PreviewState) { if !variants.is_array() { *variants = Value::Array(Vec::new()); } let entries = variants .as_array_mut() .expect("variants value was just normalized to an array"); entries.retain(|entry| { entry .as_object() .and_then(|object| object.get("kind")) .and_then(Value::as_str) != Some(PREVIEW_KIND) }); let entry = match state { PreviewState::Ready(ready) => json!({ "kind": PREVIEW_KIND, "key": ready.key, "content_type": ready.content_type, "bytes": ready.bytes, }), PreviewState::Skipped(reason) => json!({ "kind": PREVIEW_KIND, "skipped": true, "reason": reason, }), }; entries.push(entry); } #[cfg(test)] mod tests { use super::*; #[test] fn key_base_keeps_the_directory_and_strips_the_extension() { assert_eq!(preview_key_base("avatars/abc.jpg"), "avatars/abc.preview"); assert_eq!( preview_key_base("space-images/sid/banner.png"), "space-images/sid/banner.preview" ); assert_eq!( preview_key_base("chat/attachments/u/a/photo.name.jpeg"), "chat/attachments/u/a/photo.name.preview" ); } #[test] fn key_base_handles_dotfiles_in_the_directory() { assert_eq!(preview_key_base("dir.v2/file"), "dir.v2/file.preview"); assert_eq!(preview_key_base("dir.v2/file"), "dir.v2/file.preview"); } #[test] fn candidates_cover_both_encodings() { let keys = preview_candidate_keys("avatars/abc.preview"); assert_eq!( keys, vec!["avatars/abc.preview.jpg", "avatars/abc.preview.webp"] ); } #[test] fn record_round_trips_both_states() { let mut record = json!({ "uid": "abc", "format": "png" }); assert!(preview_from_record(&record).is_none()); preview_into_record( &mut record, &PreviewState::ready("k.webp".into(), "image/webp", 12), ); assert_eq!( preview_from_record(&record), Some(PreviewState::Ready(ReadyPreview { key: "k.webp".to_string(), content_type: "image/webp".to_string(), bytes: 12, })) ); // Skipping must clear a previously recorded derivative. preview_into_record(&mut record, &PreviewState::Skipped("animated".into())); assert_eq!( preview_from_record(&record), Some(PreviewState::Skipped("animated".to_string())) ); assert!(record.get("preview").is_none()); // Unrelated fields survive both writes. assert_eq!(record.get("uid").and_then(Value::as_str), Some("abc")); } #[test] fn variants_array_round_trips_both_states() { let mut variants = json!([]); assert!(preview_from_variants(&variants).is_none()); preview_into_variants( &mut variants, &PreviewState::ready("chat/1/preview.webp".into(), "image/webp", 7), ); assert_eq!( preview_from_variants(&variants), Some(PreviewState::Ready(ReadyPreview { key: "chat/1/preview.webp".to_string(), content_type: "image/webp".to_string(), bytes: 7, })) ); // A second write replaces the first rather than appending a duplicate. preview_into_variants(&mut variants, &PreviewState::Skipped("animated".into())); assert_eq!(variants.as_array().map(Vec::len), Some(1)); assert_eq!( preview_from_variants(&variants), Some(PreviewState::Skipped("animated".to_string())) ); } #[test] fn variants_entries_of_other_kinds_are_preserved() { let mut variants = json!([{ "kind": "waveform", "key": "other" }]); preview_into_variants( &mut variants, &PreviewState::ready("chat/1/preview.jpg".into(), "image/jpeg", 3), ); let entries = variants.as_array().expect("array"); assert_eq!(entries.len(), 2); assert!( entries .iter() .any(|entry| entry["kind"] == json!("waveform")) ); } #[test] fn legacy_non_array_variants_value_is_normalized() { let mut variants = json!({ "preview": { "key": "legacy" } }); assert!(preview_from_variants(&variants).is_none()); preview_into_variants( &mut variants, &PreviewState::ready("chat/1/preview.jpg".into(), "image/jpeg", 3), ); assert_eq!( preview_from_variants(&variants).map(|state| matches!(state, PreviewState::Ready(_))), Some(true) ); } #[test] fn legacy_array_records_read_as_absent() { let record = json!([{ "name": "something-else" }]); assert!(preview_from_record(&record).is_none()); } }