main
//! 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,
}
/// `<dir>/<stem>.<ext>` -> `<dir>/<stem>.preview`, and `<dir>/<name>` 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<String> {
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<PreviewState> {
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<PreviewState> {
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());
}
}