300 lines
9.5 KiB
Rust
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");
|
|
}
|
|
}
|