lightcraft/crates/catalog/src/model.rs

300 lines
9.5 KiB
Rust

//! Catalog records.
use std::sync::Arc;
use lightcraft_develop::DevelopSettings;
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct PhotoId(pub u64);
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct AlbumId(pub u64);
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct StackId(pub u64);
/// A stack: photos grouped under a top photo (bursts, brackets, merge results with their sources).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Stack {
pub id: StackId,
/// Members in stack order; `photos[0]` is the top of the stack. At least two.
pub photos: Vec<PhotoId>,
/// Collapsed stacks show only their top photo in the grid.
#[serde(default)]
pub collapsed: bool,
}
impl Stack {
pub fn top(&self) -> PhotoId {
self.photos[0]
}
pub fn position(&self, id: PhotoId) -> Option<usize> {
self.photos.iter().position(|p| *p == id)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MediaKind {
#[default]
Image,
Raw,
Video,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Flag {
#[default]
None,
Pick,
Reject,
}
impl Flag {
pub fn parse(s: &str) -> Option<Flag> {
match s.to_ascii_lowercase().as_str() {
"pick" | "picked" | "flagged" => Some(Flag::Pick),
"reject" | "rejected" => Some(Flag::Reject),
"none" | "unflagged" | "" => Some(Flag::None),
_ => None,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ColorLabel {
Red,
Yellow,
Green,
Blue,
Purple,
}
impl ColorLabel {
pub const ALL: [ColorLabel; 5] = [ColorLabel::Red, ColorLabel::Yellow, ColorLabel::Green, ColorLabel::Blue, ColorLabel::Purple];
pub fn parse(s: &str) -> Option<ColorLabel> {
ColorLabel::ALL.into_iter().find(|c| format!("{c:?}").eq_ignore_ascii_case(s))
}
}
/// Where the pixels come from.
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum Source {
/// A file on disk (native) or in the browser's storage (web).
File { path: String },
/// A procedurally generated demo scene (by `lightcraft-scenes` id).
Demo { scene: u32 },
}
/// Descriptive + capture metadata.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Meta {
pub camera: String,
pub lens: String,
pub focal_mm: Option<f32>,
pub aperture: Option<f32>,
pub shutter: String,
pub iso: Option<u32>,
pub location: String,
pub gps: Option<(f64, f64)>,
pub title: String,
pub caption: String,
pub copyright: String,
pub creator: String,
pub keywords: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Version {
pub name: String,
pub created: String,
pub settings: Arc<DevelopSettings>,
#[serde(default)]
pub auto: bool,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct HistoryStep {
pub label: String,
pub settings: Arc<DevelopSettings>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Photo {
pub id: PhotoId,
pub source: Source,
pub file_name: String,
#[serde(default)]
pub kind: MediaKind,
pub format: String,
pub width: u32,
pub height: u32,
#[serde(default)]
pub file_size: u64,
/// ISO 8601 local capture time.
#[serde(default)]
pub captured: Option<String>,
pub imported: String,
#[serde(default)]
pub meta: Meta,
#[serde(default)]
pub rating: u8,
#[serde(default)]
pub flag: Flag,
#[serde(default)]
pub label: Option<ColorLabel>,
pub develop: Arc<DevelopSettings>,
#[serde(default)]
pub edited: Option<String>,
#[serde(default)]
pub versions: Vec<Version>,
#[serde(default)]
pub history: Vec<HistoryStep>,
/// In "Recently Deleted".
#[serde(default)]
pub deleted: bool,
/// Video duration in seconds.
#[serde(default)]
pub duration: Option<f64>,
/// Raw files: the camera's as-shot white balance (Kelvin, tint).
#[serde(default)]
pub as_shot_wb: Option<(f64, f64)>,
/// Hash of the file's bytes (hex), for duplicate detection and preview-cache keys.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content_hash: Option<String>,
/// Lens corrections embedded in the file (DNG `WarpRectilinear` / `FixVignetteRadial`), applied when
/// "Enable Profile Corrections" is on.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub embedded_lens: Option<lightcraft_develop::EmbeddedLens>,
/// A virtual copy: the photo it was copied from (it shares that photo's file but has its own
/// settings, metadata and history).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub copy_of: Option<PhotoId>,
/// Virtual copies' name ("Copy 1").
#[serde(default, skip_serializing_if = "Option::is_none")]
pub copy_name: Option<String>,
/// The settings import gave the photo when a user default (a raw/JPEG default preset, see
/// the engine's import defaults) changed them from [`Photo::camera_defaults`]. They count as
/// unedited, and Reset returns to them.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub import_look: Option<Arc<DevelopSettings>>,
}
impl Photo {
pub fn new(id: PhotoId, source: Source, file_name: &str, format: &str, width: u32, height: u32, imported: &str) -> Photo {
Photo {
id,
source,
file_name: file_name.to_string(),
kind: MediaKind::Image,
format: format.to_string(),
width,
height,
file_size: 0,
captured: None,
imported: imported.to_string(),
meta: Meta::default(),
rating: 0,
flag: Flag::None,
label: None,
develop: Arc::new(DevelopSettings::default()),
edited: None,
versions: Vec::new(),
history: Vec::new(),
deleted: false,
duration: None,
as_shot_wb: None,
content_hash: None,
embedded_lens: None,
copy_of: None,
copy_name: None,
import_look: None,
}
}
/// The develop settings import gave this photo: [`Photo::camera_defaults`], or the user's
/// default preset applied on top of them ([`Photo::import_look`]).
pub fn import_defaults(&self) -> DevelopSettings {
match &self.import_look {
Some(l) => (**l).clone(),
None => self.camera_defaults(),
}
}
/// The built-in defaults for this photo, before any user default preset: raws start from
/// their as-shot white balance; embedded lens corrections on when the file has them.
pub fn camera_defaults(&self) -> DevelopSettings {
let mut d = match self.as_shot_wb {
Some((t, tint)) => DevelopSettings::for_raw(t, tint),
None => DevelopSettings::default(),
};
if self.embedded_lens.is_some() {
d.optics.lens_profile = true;
}
d
}
/// Edited by the user: settings differ from what import gave the photo.
pub fn is_edited(&self) -> bool {
*self.develop != self.import_defaults() && !self.develop.is_unedited()
}
/// Capture time if known, else import time (sort key).
pub fn date(&self) -> &str {
self.captured.as_deref().unwrap_or(&self.imported)
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Album {
pub id: AlbumId,
pub name: String,
/// Containing folder (an album with `folder = true`).
#[serde(default)]
pub parent: Option<AlbumId>,
/// Folders hold albums, not photos.
#[serde(default)]
pub folder: bool,
#[serde(default)]
pub photos: Vec<PhotoId>,
#[serde(default)]
pub cover: Option<PhotoId>,
/// A smart album: its photos are every (non-deleted) photo matching these rules, evaluated
/// live; `photos` stays empty.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub smart: Option<Box<crate::Filter>>,
}
impl Album {
/// A regular (manual) album.
pub fn new(id: AlbumId, name: impl Into<String>) -> Album {
Album { id, name: name.into(), parent: None, folder: false, photos: Vec::new(), cover: None, smart: None }
}
pub fn is_smart(&self) -> bool {
self.smart.is_some()
}
}
#[cfg(test)]
mod edited_tests {
use super::*;
#[test]
fn raw_with_import_settings_is_not_edited() {
let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, "a.arw", "ARW", 10, 10, "2026-10-01T00:00:00");
p.as_shot_wb = Some((5200.0, 4.0));
p.develop = Arc::new(p.import_defaults());
assert!(!p.is_edited(), "import look counts as unedited");
let mut d = (*p.develop).clone();
d.light.exposure = 0.5;
p.develop = Arc::new(d);
assert!(p.is_edited());
p.develop = Arc::new(DevelopSettings::default());
assert!(!p.is_edited(), "a full reset is unedited too");
}
}