From 92077d080710d762791c85954e9c667b24be3a80 Mon Sep 17 00:00:00 2001 From: Jason Baldwin <33934381+bitshiftza@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:31:38 +0200 Subject: [PATCH 1/6] LRC-SS-PLAYBACK: slideshow playback: fades, random order, repeat, previews prepared in advance, playback controls --- ROADMAP.md | 2 +- crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/slideshow.rs | 32 ++ crates/engine/src/lib.rs | 4 + crates/engine/src/library.rs | 4 + crates/engine/src/slideshow.rs | 622 +++++++++++++++++++++++++ crates/engine/src/tests_prefs.rs | 40 ++ crates/ui-egui/src/headless.rs | 5 + crates/ui-egui/src/icons.rs | 9 + crates/ui-egui/src/lib.rs | 29 +- crates/ui-egui/src/menus.rs | 39 +- crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/panels/settings.rs | 99 +++- crates/ui-egui/src/panels/slideshow.rs | 389 ++++++++++++++++ crates/ui-egui/src/render.rs | 28 +- crates/ui-egui/src/shortcuts.rs | 17 +- crates/ui-egui/src/state.rs | 5 +- crates/ui-egui/src/tests_masking.rs | 32 -- crates/ui-egui/src/tests_slideshow.rs | 242 ++++++++++ docs/parity.md | 12 +- docs/whats-new.md | 10 + 21 files changed, 1530 insertions(+), 93 deletions(-) create mode 100644 crates/engine/src/cmd/slideshow.rs create mode 100644 crates/engine/src/slideshow.rs create mode 100644 crates/ui-egui/src/panels/slideshow.rs create mode 100644 crates/ui-egui/src/tests_slideshow.rs diff --git a/ROADMAP.md b/ROADMAP.md index 9bb4744b9..e7c8feb07 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -27,7 +27,7 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78). | **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW, NEF, RW2, RAF, CR3, CR2 and PEF have guarded camera-JPEG colour estimates, with bundled ILCE-7CR, ILCE-7M4, X-H2S and X-T4 profiles (docs/camera-preview-colour.md); 52 models whose JPEG fit is rejected start from matrices fitted to measured spectral sensitivities; other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) | | **AI & computational** | ~15–20% | Assisted culling (focus, bursts), auto tone, HDR/panorama merge; subject/sky/background masks as classical heuristics, or the photo's own segmentation mattes when it ships them (DNG semantic masks, e.g. iPhone ProRAW's sky matte); non-destructive Bayer RAW denoise with opt-in weights on pure-Rust CPU/GPU; **faces (MVP, [docs/faces.md](docs/faces.md))**: a pure-Rust detector (a 232 KB opt-in download), opt-in recognition models (one-click download or add a file; they run on the shared pure-Rust CPU interpreter; pinned SFace/AuraFace embeddings agree with an independent reference runtime on 40 public crops; one-thread medians 34–36 / 233–252 ms (AuraFace remains expensive)), a background scan that finds and embeds faces, name suggestions, a person's page with look-alike faces to confirm, an Unnamed faces section (look-alikes together, select a group and name it at once) | Real segmentation masks (subject, sky, people, objects, landscape, depth), denoise model policy / linear RGB / quality verification, super resolution, lens blur, generative remove, natural-language search; for faces: clusters with separators between groups, small faces in large photos (the detector looks at 640 px), thresholds calibrated on live faces, writing face regions back to XMP. **Blocked on a model strategy** (licensable weights or our own training; pure-Rust inference is feasible) | | **Workflow & library** | ~85% (single machine) | Robust catalog (journal + snapshots, background compaction, crash-tested), 85k-photo libraries stay responsive, Local browsing with automatic cleanup, XMP interop, keywords, smart albums, Move import | Opening an 85k library takes 1.7–4.7 s; no cloud sync (out of scope), no tablet companion (#74, roadmap), shared albums, publish services, tethering | -| **Classic modules** | ~30% | Geotagging from GPX track logs, soft proofing (partial), slideshow (basic), paginated contact-sheet PDF export (A4/Letter, layout/captions, current edits, 150 dpi sRGB) | **Map view, Book, full Print module, Slideshow module, Web, publish services**: contact sheets work, custom print packages and printer colour management remain missing | +| **Classic modules** | ~30% | Geotagging from GPX track logs, soft proofing (partial), slideshow playback (fades, random order, repeat, previews prepared ahead, playback controls; no Slideshow module view yet), paginated contact-sheet PDF export (A4/Letter, layout/captions, current edits, 150 dpi sRGB) | **Map view, Book, full Print module, Slideshow module, Web, publish services**: contact sheets work, custom print packages and printer colour management remain missing | | **HDR & video** | ~25% | HDR editing (headroom limit, SDR rendition, Visualize HDR); gain map JPEG, PQ AVIF and float TIFF export | On-screen HDR display, HLG, HDR settings from crs XMP; video play/trim/edit/export | | **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; English, Simplified/Traditional Chinese, Japanese, Brazilian Portuguese, Spanish, German, Russian and Ukrainian UI (see docs/localization.md); remaining technical errors; Traditional Chinese uses the Simplified Chinese font until craft-fonts has a TC face; accessibility partial; headless UI tests time out under machine load | diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index e7def13d3..1b3fcfd4b 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -32,6 +32,7 @@ mod prefs; mod preset_files; pub mod previews; mod query; +mod slideshow; mod sync; mod xmp; @@ -144,6 +145,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(xmp::specs()); v.extend(preset_files::specs()); v.extend(prefs::specs()); + v.extend(slideshow::specs()); v.extend(export::specs()); v.extend(before::specs()); v.extend(browse::specs()); diff --git a/crates/engine/src/cmd/slideshow.rs b/crates/engine/src/cmd/slideshow.rs new file mode 100644 index 000000000..e6cd2d008 --- /dev/null +++ b/crates/engine/src/cmd/slideshow.rs @@ -0,0 +1,32 @@ +//! `slideshow.playback`: the slideshow's Playback settings (slide and fade durations, fade colour, +//! random order, repeat, preparing previews in advance). A library preference, saved in the +//! library's `prefs.json`; the app's slideshow (View ▸ Slideshow) plays with them. + +use serde_json::Value; + +use super::{CommandSpec, always, bad, cmd}; +use crate::{Result, Session}; + +fn playback(s: &mut Session, p: &Value) -> Result { + let changes = p.as_object().is_some_and(|o| !o.is_empty()); + if changes { + let next = s.slideshow.merged(p).map_err(|e| bad("slideshow.playback", e))?; + if next != s.slideshow { + s.slideshow = next; + s.save_prefs()?; + } + } + Ok(s.slideshow.to_json()) +} + +pub fn specs() -> Vec { + vec![cmd!( + "slideshow.playback", + "Slideshow Playback Settings", + [], + None, + "{slideDuration?: seconds (0.5–120), fadeDuration?: seconds (0 = cut; at most 10 and the slide duration), fadeColor?: \"#rrggbb\"|[r,g,b]|null (fade through a colour instead of crossfading), randomOrder?: bool, repeat?: bool, preparePreviews?: bool (render the next slides before they are due)} — omitted keys keep their value, {} reads them; saved with the library → {slideDuration, fadeDuration, fadeColor, randomOrder, repeat, preparePreviews}", + always, + playback + )] +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 66acfe4a7..d10abd193 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -55,6 +55,7 @@ pub mod presets; pub mod rename; pub mod segment; pub mod sidecar; +pub mod slideshow; pub mod smart; pub mod sync; mod view; @@ -271,6 +272,8 @@ pub struct Session { pub import_defaults: import::ImportDefaults, /// Disk budget of the library's thumbnail cache in MB (0 = default; persisted in prefs.json). pub cache_mb: u32, + /// Slideshow playback settings (`slideshow.playback`; persisted in prefs.json). + pub slideshow: slideshow::PlaybackSettings, /// Where smart previews are kept when not in the library folder (persisted in prefs.json). pub smart_previews_dir: Option, /// Untouched Local records of folders not browsed for this many days are forgotten when the @@ -353,6 +356,7 @@ impl Session { preview_build: None, import_defaults: import::ImportDefaults::default(), cache_mb: 0, + slideshow: slideshow::PlaybackSettings::default(), smart_previews_dir: None, forget_local_days: lightcraft_catalog::DEFAULT_FORGET_DAYS, } diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 4c8bf28aa..4e843054e 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -158,6 +158,8 @@ struct PrefsFile { /// Days after which untouched Local records of unbrowsed folders are forgotten (missing = /// the default, 0 = never). forget_local_days: Option, + /// Slideshow playback settings. + slideshow: crate::slideshow::PlaybackSettings, } fn presets_json(s: &Session) -> String { @@ -340,6 +342,7 @@ impl Session { self.recent_keywords = prefs.recent_keywords; self.import_defaults = prefs.import; self.cache_mb = prefs.cache_mb; + self.slideshow = prefs.slideshow.sanitized(); self.forget_local_days = prefs.forget_local_days.unwrap_or(lightcraft_catalog::DEFAULT_FORGET_DAYS); self.smart_previews_dir = prefs.smart_previews_dir.filter(|_| on_disk).map(PathBuf::from); if let Some(d) = &self.smart_previews_dir { @@ -576,6 +579,7 @@ impl Session { cache_mb: self.cache_mb, smart_previews_dir: self.smart_previews_dir.as_ref().map(|d| d.to_string_lossy().to_string()), forget_local_days: Some(self.forget_local_days), + slideshow: self.slideshow.clone(), }) .unwrap_or_default(); let Some(lib) = self.library.as_mut() else { return Ok(()) }; diff --git a/crates/engine/src/slideshow.rs b/crates/engine/src/slideshow.rs new file mode 100644 index 000000000..bbe146a65 --- /dev/null +++ b/crates/engine/src/slideshow.rs @@ -0,0 +1,622 @@ +//! Slideshow playback: the settings of Lightroom Classic's Playback panel and the timeline that +//! plays the photos in view (durations, fades, random order, repeat, pause, stepping). +//! +//! No UI here: [`Playback`] is a pure state machine driven with injected times (seconds on any +//! monotonic clock), so the desktop app, a later Slideshow module and tests run the same timeline. +//! The frontend asks [`Playback::frame`] what to draw (the current slide, and while a transition +//! runs the previous one and its progress) and [`Playback::upcoming`] which slides to prepare; it +//! tells [`Playback::tick`] which slides are ready, so a transition never starts on a slide that +//! has not been rendered yet (the current one is held instead). +//! +//! The settings are a library preference (`prefs.json`, see [`crate::library`]), read and changed +//! with the `slideshow.playback` command. + +use lightcraft_catalog::PhotoId; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +/// Shortest and longest time a slide stays up (seconds). +pub const SLIDE_MIN: f64 = 0.5; +pub const SLIDE_MAX: f64 = 120.0; +/// Longest fade (seconds); a fade is never longer than the slide duration. +pub const FADE_MAX: f64 = 10.0; + +/// How playback runs (Lightroom Classic's Playback panel, without music and pan & zoom). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct PlaybackSettings { + /// Seconds from the start of one slide's transition to the start of the next one's (the + /// fade in is part of it). + pub slide_duration: f64, + /// Seconds a transition takes (0 = cut). + pub fade_duration: f64, + /// Fade through this colour (out to it, then in from it) instead of crossfading. + pub fade_color: Option<[u8; 3]>, + /// Shuffle the photos (a new order for every pass when repeating). + pub random_order: bool, + /// Start over after the last slide; off: playback ends there. + pub repeat: bool, + /// Render the next slides before they are due. + pub prepare_previews: bool, +} + +impl Default for PlaybackSettings { + fn default() -> Self { + PlaybackSettings { slide_duration: 4.0, fade_duration: 1.0, fade_color: None, random_order: false, repeat: true, prepare_previews: true } + } +} + +impl PlaybackSettings { + /// The settings with every value in range (a non-finite duration takes the default; the fade + /// is never longer than the slide). + pub fn sanitized(mut self) -> Self { + let d = PlaybackSettings::default(); + self.slide_duration = if self.slide_duration.is_finite() { self.slide_duration.clamp(SLIDE_MIN, SLIDE_MAX) } else { d.slide_duration }; + let fade = if self.fade_duration.is_finite() { self.fade_duration } else { d.fade_duration }; + self.fade_duration = fade.clamp(0.0, FADE_MAX).min(self.slide_duration); + self + } + + pub fn to_json(&self) -> Value { + json!({ + "slideDuration": self.slide_duration, + "fadeDuration": self.fade_duration, + "fadeColor": self.fade_color.map(hex), + "randomOrder": self.random_order, + "repeat": self.repeat, + "preparePreviews": self.prepare_previews, + }) + } + + /// These settings with the keys `p` names changed (the others kept), validated and clamped. + /// A wrong type, an unknown key or a non-finite number is an error; nothing changes then. + pub fn merged(&self, p: &Value) -> std::result::Result { + let Some(obj) = p.as_object() else { return Err("parameters must be an object".into()) }; + let mut s = self.clone(); + for (k, v) in obj { + match k.as_str() { + "slideDuration" => s.slide_duration = finite(k, v)?, + "fadeDuration" => s.fade_duration = finite(k, v)?, + "fadeColor" => s.fade_color = color(v)?, + "randomOrder" => s.random_order = boolean(k, v)?, + "repeat" => s.repeat = boolean(k, v)?, + "preparePreviews" => s.prepare_previews = boolean(k, v)?, + other => { + return Err(format!("unknown setting `{other}` (slideDuration, fadeDuration, fadeColor, randomOrder, repeat, preparePreviews)")); + } + } + } + // a fade longer than a slide asked for together with a shorter slide: the slide wins + Ok(s.sanitized()) + } +} + +fn finite(k: &str, v: &Value) -> std::result::Result { + match v.as_f64() { + Some(x) if x.is_finite() => Ok(x), + _ => Err(format!("`{k}` must be a finite number of seconds, not {v}")), + } +} + +fn boolean(k: &str, v: &Value) -> std::result::Result { + v.as_bool().ok_or_else(|| format!("`{k}` must be true or false, not {v}")) +} + +fn hex(c: [u8; 3]) -> String { + format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2]) +} + +/// `null` / `""` / `"none"` = crossfade; `"#rrggbb"` or `[r, g, b]` (0–255) = fade through it. +fn color(v: &Value) -> std::result::Result, String> { + let bad = || format!("`fadeColor` must be \"#rrggbb\", [r, g, b] or null, not {v}"); + match v { + Value::Null => Ok(None), + Value::String(s) if s.is_empty() || s.eq_ignore_ascii_case("none") => Ok(None), + Value::String(s) => { + let h = s.strip_prefix('#').unwrap_or(s); + if h.len() != 6 || !h.is_ascii() { + return Err(bad()); + } + let byte = |i: usize| h.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok()).ok_or_else(bad); + Ok(Some([byte(0)?, byte(2)?, byte(4)?])) + } + Value::Array(a) if a.len() == 3 => { + let mut c = [0u8; 3]; + for (o, x) in c.iter_mut().zip(a) { + *o = x.as_u64().filter(|n| *n <= 255).ok_or_else(bad)? as u8; + } + Ok(Some(c)) + } + _ => Err(bad()), + } +} + +/// What a tick did. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Tick { + /// Nothing changed (the slide is up, paused, or a transition is running). + Idle, + /// The next slide is due but not ready: the current one is held. + Waiting(PhotoId), + /// A new slide came up (its transition starts now). + Advanced(PhotoId), + /// The last slide was shown and repeat is off. + Ended, +} + +/// What to draw at a moment of playback. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Frame { + pub current: PhotoId, + /// The slide being faded out, while a transition runs. + pub from: Option, + /// How far the transition is (0–1; 1 when there is none). + pub progress: f32, + pub fade_color: Option<[u8; 3]>, +} + +/// One run of a slideshow over a fixed list of photos. +#[derive(Clone, Debug)] +pub struct Playback { + settings: PlaybackSettings, + photos: Vec, + /// This pass's order (indices into `photos`). + order: Vec, + /// Index into `order` of the slide up now. + pos: usize, + pass: u64, + seed: u64, + /// When the current slide came up (its transition started). + started: f64, + /// The slide fading out (until the transition ends). + from: Option, + /// Paused at this time. + paused_at: Option, + ended: bool, + /// The next slide was due but not ready at the last tick. + held: Option, +} + +/// SplitMix64: a small, well-mixed generator for the shuffle (deterministic per seed). +fn splitmix(state: &mut u64) -> u64 { + *state = state.wrapping_add(0x9e37_79b9_7f4a_7c15); + let mut z = *state; + z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + z ^ (z >> 31) +} + +/// Fisher–Yates shuffle of `v` with `seed`. +fn shuffle(v: &mut [usize], seed: u64) { + let mut st = seed; + for i in (1..v.len()).rev() { + let j = (splitmix(&mut st) % (i as u64 + 1)) as usize; + v.swap(i, j); + } +} + +impl Playback { + /// Start playing `photos` (the ones in view, in view order) at `start` (the selected photo; + /// the first one when absent or not among them), at time `now`. `seed` fixes the random order. + /// `None` when there is nothing to show. + pub fn new(photos: Vec, start: Option, settings: PlaybackSettings, seed: u64, now: f64) -> Option { + if photos.is_empty() { + return None; + } + let settings = settings.sanitized(); + let n = photos.len(); + let first = start.and_then(|s| photos.iter().position(|p| *p == s)).unwrap_or(0); + let order: Vec = if settings.random_order { + // the starting photo first, the others shuffled + let mut rest: Vec = (0..n).filter(|i| *i != first).collect(); + shuffle(&mut rest, seed); + std::iter::once(first).chain(rest).collect() + } else { + // from the starting photo to the end, then the ones before it: every photo once + (first..n).chain(0..first).collect() + }; + let now = if now.is_finite() { now } else { 0.0 }; + Some(Playback { settings, photos, order, pos: 0, pass: 0, seed, started: now, from: None, paused_at: None, ended: false, held: None }) + } + + pub fn settings(&self) -> &PlaybackSettings { + &self.settings + } + + /// The order of pass `pass` (after the first), given the order of the pass before it. + fn order_after(&self, pass: u64, prev: &[usize]) -> Vec { + let mut order = prev.to_vec(); + if self.settings.random_order { + order.sort_unstable(); + shuffle(&mut order, self.seed ^ pass.wrapping_mul(0xa076_1d64_78bd_642f)); + // never the same photo twice in a row across passes + if order.len() > 1 && order.first() == prev.last() { + order.swap(0, 1); + } + } + order + } + + fn photo_at(&self, order: &[usize], pos: usize) -> Option { + order.get(pos).and_then(|i| self.photos.get(*i)).copied() + } + + /// The slide up now. + pub fn current(&self) -> PhotoId { + self.photo_at(&self.order, self.pos).or_else(|| self.photos.first().copied()).unwrap_or(PhotoId(0)) + } + + /// (slide number from 1, slides per pass). + pub fn position(&self) -> (usize, usize) { + (self.pos + 1, self.order.len()) + } + + pub fn is_paused(&self) -> bool { + self.paused_at.is_some() + } + + pub fn is_ended(&self) -> bool { + self.ended + } + + /// The slide that was due but not ready at the last tick (the current one is held for it). + pub fn waiting_for(&self) -> Option { + self.held + } + + /// The clock as playback sees it: stopped while paused. + fn clock(&self, now: f64) -> f64 { + let now = if now.is_finite() { now } else { self.started }; + self.paused_at.map_or(now, |p| p.min(now)) + } + + /// When the next slide is due. + pub fn due(&self) -> f64 { + self.started + self.settings.slide_duration + } + + /// Seconds until the next slide is due (as of `now`; frozen while paused). + pub fn remaining(&self, now: f64) -> f64 { + (self.due() - self.clock(now)).max(0.0) + } + + /// The next `n` slides in playing order (into the next pass when repeating; never the one up + /// now twice). + pub fn upcoming(&self, n: usize) -> Vec { + let mut out = Vec::new(); + let mut order = self.order.clone(); + let (mut pos, mut pass) = (self.pos, self.pass); + while out.len() < n { + pos += 1; + if pos >= order.len() { + if !self.settings.repeat { + break; + } + pass += 1; + order = self.order_after(pass, &order); + pos = 0; + } + match self.photo_at(&order, pos) { + Some(p) if p != self.current() && !out.contains(&p) => out.push(p), + _ => break, + } + } + out + } + + /// Move one slide on (`Advanced`), or end (`Ended`) past the last one without repeat. + fn advance(&mut self) -> Tick { + if self.pos + 1 < self.order.len() { + self.pos += 1; + } else if self.settings.repeat { + self.pass += 1; + self.order = self.order_after(self.pass, &self.order); + self.pos = 0; + } else { + self.ended = true; + return Tick::Ended; + } + Tick::Advanced(self.current()) + } + + /// Advance the timeline to `now`. `ready(photo)` says whether a slide can be shown: the + /// transition to the next slide waits until it is. + pub fn tick(&mut self, now: f64, ready: impl Fn(PhotoId) -> bool) -> Tick { + if self.ended { + return Tick::Ended; + } + let now = self.clock(now); + if self.from.is_some() && now >= self.started + self.settings.fade_duration { + self.from = None; + } + if self.paused_at.is_some() { + return Tick::Idle; + } + // a slide's time starts once it can be shown (the first one, one stepped to with ←/→) + if self.from.is_none() && !ready(self.current()) { + self.started = now; + return Tick::Idle; + } + if now < self.due() { + return Tick::Idle; + } + let Some(next) = self.upcoming(1).first().copied() else { + if self.settings.repeat { + // a single photo repeating: nothing to change to + self.started = now; + return Tick::Idle; + } + self.ended = true; + return Tick::Ended; + }; + if !ready(next) { + self.held = Some(next); + return Tick::Waiting(next); + } + self.held = None; + let old = self.current(); + let t = self.advance(); + if let Tick::Advanced(_) = t { + self.started = now; + self.from = (self.settings.fade_duration > 0.0).then_some(old); + } + t + } + + /// What to draw at `now`. + pub fn frame(&self, now: f64) -> Frame { + let now = self.clock(now); + let fade = self.settings.fade_duration; + let (from, progress) = match self.from { + Some(f) if fade > 0.0 && now < self.started + fade => (Some(f), ((now - self.started) / fade).clamp(0.0, 1.0) as f32), + _ => (None, 1.0), + }; + Frame { current: self.current(), from, progress, fade_color: self.settings.fade_color } + } + + /// Pause or resume at `now`; resuming keeps the time the slide had left. Returns whether it + /// is paused now. + pub fn toggle_pause(&mut self, now: f64) -> bool { + let now = if now.is_finite() { now } else { self.started }; + match self.paused_at.take() { + Some(p) => { + // the slide (and a transition in progress) carries on where it stopped + self.started += (now - p).max(0.0); + } + None => self.paused_at = Some(now), + } + self.paused_at.is_some() + } + + /// Step `delta` slides (←/→) at `now`: a cut, and the new slide gets its full time (paused + /// playback stays paused). Past the last slide without repeat playback ends; before the first + /// it stays there. + pub fn step(&mut self, delta: i32, now: f64) -> Tick { + if self.ended { + return Tick::Ended; + } + let now = if now.is_finite() { now } else { self.started }; + // (more steps than slides would only go round again) + let steps = delta.unsigned_abs().min(self.order.len().min(u32::MAX as usize) as u32); + let mut t = Tick::Idle; + if delta > 0 { + for _ in 0..steps { + t = self.advance(); + if t == Tick::Ended { + return t; + } + } + } else { + for _ in 0..steps { + if self.pos > 0 { + self.pos -= 1; + } else if self.settings.repeat { + self.pos = self.order.len().saturating_sub(1); + } + } + t = Tick::Advanced(self.current()); + } + self.from = None; + self.held = None; + self.started = now; + if self.paused_at.is_some() { + self.paused_at = Some(now); + } + t + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ids(n: u64) -> Vec { + (1..=n).map(PhotoId).collect() + } + + fn settings(slide: f64, fade: f64) -> PlaybackSettings { + PlaybackSettings { slide_duration: slide, fade_duration: fade, ..Default::default() } + } + + fn all_ready(_: PhotoId) -> bool { + true + } + + #[test] + fn timeline_durations_and_fade_progress() { + let mut p = Playback::new(ids(3), Some(PhotoId(2)), settings(4.0, 1.0), 1, 10.0).unwrap(); + assert_eq!(p.current(), PhotoId(2), "starts at the selected photo"); + assert_eq!(p.position(), (1, 3)); + assert_eq!(p.tick(13.9, all_ready), Tick::Idle); + assert_eq!(p.frame(13.9).from, None); + assert_eq!(p.tick(14.0, all_ready), Tick::Advanced(PhotoId(3))); + let f = p.frame(14.25); + assert_eq!((f.current, f.from), (PhotoId(3), Some(PhotoId(2)))); + assert!((f.progress - 0.25).abs() < 1e-6, "{f:?}"); + assert!((p.frame(14.5).progress - 0.5).abs() < 1e-6); + assert_eq!(p.tick(15.0, all_ready), Tick::Idle); + assert_eq!(p.frame(15.0).from, None, "the fade is over after fadeDuration"); + // the slide period counts from its transition: next at 18 + assert_eq!(p.tick(17.99, all_ready), Tick::Idle); + assert_eq!(p.tick(18.0, all_ready), Tick::Advanced(PhotoId(1)), "wraps to the photos before the start"); + assert_eq!(p.position(), (3, 3)); + } + + #[test] + fn fade_zero_is_a_cut_and_fade_color_is_reported() { + let mut p = Playback::new(ids(2), None, settings(2.0, 0.0), 1, 0.0).unwrap(); + assert_eq!(p.tick(2.0, all_ready), Tick::Advanced(PhotoId(2))); + assert_eq!(p.frame(2.0).from, None); + let s = PlaybackSettings { fade_color: Some([255, 0, 0]), ..settings(2.0, 1.0) }; + let mut p = Playback::new(ids(2), None, s, 1, 0.0).unwrap(); + p.tick(2.0, all_ready); + assert_eq!(p.frame(2.5).fade_color, Some([255, 0, 0])); + } + + #[test] + fn arrows_cut_immediately_and_restart_the_slide_time() { + let mut p = Playback::new(ids(4), None, settings(4.0, 2.0), 1, 0.0).unwrap(); + p.tick(4.0, all_ready); + assert!(p.frame(4.5).from.is_some(), "fading"); + assert_eq!(p.step(1, 4.5), Tick::Advanced(PhotoId(3))); + assert_eq!(p.frame(4.5), Frame { current: PhotoId(3), from: None, progress: 1.0, fade_color: None }, "a cut"); + assert_eq!(p.tick(8.4, all_ready), Tick::Idle, "full time from the step"); + assert_eq!(p.step(-1, 6.0), Tick::Advanced(PhotoId(2))); + assert_eq!(p.step(-1, 6.0), Tick::Advanced(PhotoId(1))); + // repeat on: before the first is the last + assert_eq!(p.step(-1, 6.0), Tick::Advanced(PhotoId(4))); + assert_eq!(p.remaining(6.0), 4.0); + } + + #[test] + fn random_order_is_deterministic_per_seed_and_covers_every_photo_once_per_pass() { + let photos = ids(20); + let run = |seed: u64| { + let s = PlaybackSettings { random_order: true, ..settings(1.0, 0.0) }; + let mut p = Playback::new(photos.clone(), Some(PhotoId(5)), s, seed, 0.0).unwrap(); + let mut seen = vec![p.current()]; + for k in 1..60 { + p.tick(k as f64, all_ready); + seen.push(p.current()); + } + seen + }; + let a = run(42); + assert_eq!(a, run(42), "same seed, same order"); + assert_ne!(a, run(43), "another seed, another order"); + assert_eq!(a[0], PhotoId(5), "starts at the selected photo"); + assert_ne!(a[..20].to_vec(), photos, "shuffled"); + for pass in a.chunks(20).take(2) { + let mut sorted = pass.to_vec(); + sorted.sort(); + assert_eq!(sorted, photos, "every photo once per pass"); + } + assert_ne!(a[..20], a[20..40], "a new order for the next pass"); + assert!(a.windows(2).all(|w| w[0] != w[1]), "never the same photo twice in a row"); + } + + #[test] + fn repeat_off_stops_after_the_last_slide() { + let s = PlaybackSettings { repeat: false, ..settings(1.0, 0.0) }; + let mut p = Playback::new(ids(3), Some(PhotoId(2)), s.clone(), 1, 0.0).unwrap(); + assert_eq!(p.tick(1.0, all_ready), Tick::Advanced(PhotoId(3))); + assert_eq!(p.tick(2.0, all_ready), Tick::Advanced(PhotoId(1))); + assert_eq!(p.upcoming(2), vec![], "nothing after the last"); + assert_eq!(p.tick(3.0, all_ready), Tick::Ended); + assert!(p.is_ended()); + // → past the last one ends it too + let mut p = Playback::new(ids(2), None, s, 1, 0.0).unwrap(); + assert_eq!(p.step(1, 0.5), Tick::Advanced(PhotoId(2))); + assert_eq!(p.step(1, 0.6), Tick::Ended); + // with repeat: it goes on + let mut p = Playback::new(ids(2), None, settings(1.0, 0.0), 1, 0.0).unwrap(); + for k in 1..10 { + assert!(matches!(p.tick(k as f64, all_ready), Tick::Advanced(_))); + } + } + + #[test] + fn pause_and_resume_keep_the_remaining_time() { + let mut p = Playback::new(ids(3), None, settings(4.0, 1.0), 1, 0.0).unwrap(); + assert!(p.toggle_pause(3.0)); + assert_eq!(p.tick(100.0, all_ready), Tick::Idle, "paused: nothing moves"); + assert_eq!(p.remaining(100.0), 1.0); + assert!(!p.toggle_pause(100.0)); + assert_eq!(p.tick(100.9, all_ready), Tick::Idle); + assert_eq!(p.tick(101.0, all_ready), Tick::Advanced(PhotoId(2)), "1 s left after resuming"); + // pausing mid-fade freezes the fade + p.toggle_pause(101.25); + assert!((p.frame(150.0).progress - 0.25).abs() < 1e-6); + p.toggle_pause(150.0); + assert!((p.frame(150.5).progress - 0.75).abs() < 1e-6); + } + + #[test] + fn a_transition_waits_for_the_next_slide_to_be_ready() { + let mut p = Playback::new(ids(3), None, settings(2.0, 1.0), 1, 0.0).unwrap(); + assert_eq!(p.upcoming(2), vec![PhotoId(2), PhotoId(3)], "what to prepare"); + let not_two = |id: PhotoId| id != PhotoId(2); + assert_eq!(p.tick(2.0, not_two), Tick::Waiting(PhotoId(2))); + assert_eq!(p.frame(3.0), Frame { current: PhotoId(1), from: None, progress: 1.0, fade_color: None }, "held, not flashed"); + assert_eq!(p.waiting_for(), Some(PhotoId(2))); + assert_eq!(p.tick(3.5, all_ready), Tick::Advanced(PhotoId(2))); + assert_eq!(p.frame(3.5).from, Some(PhotoId(1)), "the fade starts once it is ready"); + assert!((p.frame(4.0).progress - 0.5).abs() < 1e-6, "and runs its full length"); + } + + #[test] + fn a_slide_s_time_starts_once_it_is_ready() { + let mut p = Playback::new(ids(3), None, settings(2.0, 0.5), 1, 0.0).unwrap(); + let none = |_: PhotoId| false; + assert_eq!(p.tick(1.0, none), Tick::Idle); + assert_eq!(p.tick(5.0, none), Tick::Idle, "the first slide isn't rendered yet: its clock waits"); + assert_eq!(p.remaining(5.0), 2.0); + assert_eq!(p.tick(6.0, all_ready), Tick::Idle); + assert_eq!(p.tick(7.0, all_ready), Tick::Advanced(PhotoId(2)), "2 s from when it could be shown"); + } + + #[test] + fn hostile_settings_are_rejected_or_clamped() { + let d = PlaybackSettings::default(); + for bad in [ + json!({"slideDuration": "fast"}), + json!({"fadeDuration": null}), + json!({"repeat": 1}), + json!({"fadeColor": "#12345"}), + json!({"fadeColor": "#ééé"}), + json!({"fadeColor": [300, 0, 0]}), + json!({"fadeColor": [1, 2]}), + json!({"speed": 2}), + json!([1, 2]), + ] { + assert!(d.merged(&bad).is_err(), "{bad}"); + } + let s = d.merged(&json!({"slideDuration": 1e300, "fadeDuration": -5})).unwrap(); + assert_eq!((s.slide_duration, s.fade_duration), (SLIDE_MAX, 0.0)); + let s = d.merged(&json!({"slideDuration": 0.0, "fadeDuration": 9})).unwrap(); + assert_eq!((s.slide_duration, s.fade_duration), (SLIDE_MIN, SLIDE_MIN), "the fade fits in the slide"); + let s = d.merged(&json!({"fadeColor": "#FF8000"})).unwrap(); + assert_eq!(s.fade_color, Some([255, 128, 0])); + assert_eq!(s.to_json()["fadeColor"], "#ff8000"); + assert_eq!(d.merged(&json!({"fadeColor": [1, 2, 3]})).unwrap().fade_color, Some([1, 2, 3])); + // NaN / inf (from a hand-edited file or a caller in-process) take the defaults + let s = PlaybackSettings { slide_duration: f64::NAN, fade_duration: f64::INFINITY, ..d.clone() }.sanitized(); + assert_eq!((s.slide_duration, s.fade_duration), (d.slide_duration, d.fade_duration)); + // and the timeline never panics on hostile times + let mut p = Playback::new(ids(2), None, s, 0, f64::NAN).unwrap(); + p.tick(f64::NAN, all_ready); + p.tick(f64::INFINITY, all_ready); + p.toggle_pause(f64::NEG_INFINITY); + p.step(i32::MIN, f64::NAN); + let _ = p.frame(f64::NAN); + assert!(Playback::new(vec![], None, d, 0, 0.0).is_none()); + } + + #[test] + fn a_single_photo_repeats_without_changing() { + let mut p = Playback::new(ids(1), None, settings(1.0, 0.5), 1, 0.0).unwrap(); + assert_eq!(p.upcoming(2), vec![]); + assert_eq!(p.tick(5.0, all_ready), Tick::Idle); + assert_eq!(p.frame(5.0).from, None); + } +} diff --git a/crates/engine/src/tests_prefs.rs b/crates/engine/src/tests_prefs.rs index 3534064f2..cc9f5f74d 100644 --- a/crates/engine/src/tests_prefs.rs +++ b/crates/engine/src/tests_prefs.rs @@ -317,3 +317,43 @@ fn reopening_a_library_retires_the_old_cache_so_its_jobs_cannot_write_after_a_cl drop(s); let _ = std::fs::remove_dir_all(&lib); } + +#[test] +fn slideshow_playback_settings_persist_with_the_library() { + let lib = temp_dir("slideshow"); + { + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + let r = s.execute("slideshow.playback", &json!({})).unwrap(); + assert_eq!(r["slideDuration"], 4.0, "defaults: {r}"); + let r = s + .execute( + "slideshow.playback", + &json!({"slideDuration": 6, "fadeDuration": 2.5, "fadeColor": "#000000", "randomOrder": true, "repeat": false}), + ) + .unwrap(); + assert_eq!(r["fadeColor"], "#000000"); + // a hostile value changes nothing + assert!(s.execute("slideshow.playback", &json!({"slideDuration": "soon", "repeat": true})).is_err()); + assert!(!s.slideshow.repeat); + drop(s); + } + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + let r = s.execute("slideshow.playback", &json!({})).unwrap(); + assert_eq!( + r, + json!({"slideDuration": 6.0, "fadeDuration": 2.5, "fadeColor": "#000000", "randomOrder": true, "repeat": false, "preparePreviews": true}) + ); + drop(s); + // out-of-range values written by hand are clamped when the library opens + std::fs::write(lib.join("prefs.json"), r#"{"slideshow": {"slideDuration": 1e9, "fadeDuration": -3}}"#).unwrap(); + let s = { + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + s + }; + assert_eq!((s.slideshow.slide_duration, s.slideshow.fade_duration), (120.0, 0.0)); + drop(s); + let _ = std::fs::remove_dir_all(&lib); +} diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index d0104f7de..0d4e59e78 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -269,6 +269,11 @@ impl Headless { self.frames } + /// The simulated clock (seconds) the next frame runs at. + pub fn time(&self) -> f64 { + self.time + } + /// Every piece of text the last frame painted ([`HeadlessView::painted_text`]). pub fn painted_text(&self) -> Vec { self.view.painted_text() diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 9a4718895..9f7622ef7 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -83,6 +83,10 @@ pub enum Icon { Chat, /// Face boxes on/off: four corner brackets round a small face. FaceBox, + /// Play (a triangle pointing right). + Play, + /// Pause (two bars). + Pause, } struct Pen<'a> { @@ -423,6 +427,11 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, color: Color32) { pen.arc(10.0, 18.0, 6.5, 200.0, 340.0); pen.rect(2.5, 2.5, 17.5, 17.5, 2.0); } + Play => pen.fill(&[(6.0, 4.0), (6.0, 16.0), (16.0, 10.0)], color), + Pause => { + pen.rect_fill(5.0, 4.0, 8.5, 16.0, 0.5); + pen.rect_fill(11.5, 4.0, 15.0, 16.0, 0.5); + } FaceBox => { pen.line(&[(2.5, 7.0), (2.5, 2.5), (7.0, 2.5)]); pen.line(&[(13.0, 2.5), (17.5, 2.5), (17.5, 7.0)]); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6b5033e0c..1a4bc9d83 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -81,6 +81,8 @@ mod tests_quit_unsaved; #[cfg(test)] mod tests_scroll; #[cfg(test)] +mod tests_slideshow; +#[cfg(test)] mod tests_switch_library; #[cfg(test)] mod tests_sync; @@ -453,28 +455,9 @@ impl LightcraftApp { } /// Show a transient toast at the bottom of the canvas (like the reference app's HUD). - /// Advance a running slideshow (wrapping around at the end). + /// Advance a running slideshow. fn slideshow_tick(&mut self, ctx: &egui::Context) { - let Some((interval, due, paused)) = self.ui.slideshow else { return }; - if !self.ui.fullscreen { - self.ui.slideshow = None; - return; - } - if paused { - return; - } - let now = ctx.input(|i| i.time); - if now >= due { - let vis = self.session.visible_cloned(); - if let Some(cur) = self.session.active() - && !vis.is_empty() - { - let i = vis.iter().position(|x| *x == cur).map_or(0, |i| (i + 1) % vis.len()); - let _ = self.run("library.select", serde_json::json!({"ids": [vis[i].0]})); - } - self.ui.slideshow = Some((interval, now + interval, false)); - } - ctx.request_repaint_after(std::time::Duration::from_secs_f64((due - now).clamp(0.05, interval))); + panels::slideshow::tick(self, ctx); } /// Announce the end of a Build Previews run. @@ -1022,7 +1005,9 @@ impl LightcraftApp { self.ai_mask_detail(&ctx); if self.ui.fullscreen { // full-screen preview: the photo alone on black - egui::CentralPanel::default().frame(egui::Frame::NONE.fill(egui::Color32::BLACK)).show(ui, |ui| panels::detail::show(self, ui)); + egui::CentralPanel::default() + .frame(egui::Frame::NONE.fill(egui::Color32::BLACK)) + .show(ui, |ui| if self.ui.slideshow.is_some() { panels::slideshow::show(self, ui) } else { panels::detail::show(self, ui) }); panels::second::show(self, &ctx); panels::notices::show(self, &ctx); panels::dialogs::show(self, &ctx); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index c9b397f60..21029efea 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -138,6 +138,12 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("dialog.smartAlbum", "New Smart Album…", None, "File"), ("view.photoCounts", "Show Photo Counts", None, "View"), ("view.slideshow", "Slideshow", Some("Cmd+Alt+Enter"), "View"), + ("view.slideshowOptions", "Slideshow Options…", None, "View"), + // the slideshow's playback controls (Space, ←/→ and Esc while it plays) + ("slideshow.togglePause", "Pause / Resume Slideshow", None, ""), + ("slideshow.next", "Next Slide", None, ""), + ("slideshow.previous", "Previous Slide", None, ""), + ("slideshow.end", "End Slideshow", None, ""), ("view.secondWindow", "Second Window", Some("Cmd+F11"), "Window"), ("tool.keywordPainter", "Keyword Painter", None, ""), ("view.gridInfo", "Grid Info", None, ""), @@ -418,25 +424,21 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - // {interval?: seconds (4)}: the photos in view, full screen, one after another - let interval = p.get("interval").and_then(Value::as_f64).unwrap_or(4.0).clamp(0.5, 120.0); - if app.session.active().is_none() { - let first = app.session.visible_cloned().first().copied(); - match first { - Some(f) => { - let _ = app.run("library.select", json!({"ids": [f.0]})); - } - None => return Some(Err("no photos to show".into())), - } - } + // the photos in view, full screen, with the playback settings (see panels::slideshow) let now = ctx.input(|i| i.time); - app.ui.slideshow = Some((interval, now + interval, false)); - app.ui.fullscreen = true; - app.ui.zoom = Zoom::Fit; - app.ui.tool.clear(); - let _ = app.session.end_interaction(); - app.toast(&ctx, crate::i18n::tr("Slideshow · Space pauses · Esc ends")); - Ok(json!({"interval": interval})) + let r = crate::panels::slideshow::start(app, p, now); + if r.is_ok() { + app.toast(&ctx, crate::i18n::tr("Slideshow · Space pauses · Esc ends")); + } + r + } + "slideshow.togglePause" | "slideshow.next" | "slideshow.previous" | "slideshow.end" => { + let now = ctx.input(|i| i.time); + crate::panels::slideshow::command(app, id, now) + } + "view.slideshowOptions" => { + app.ui.dialog = Some(Dialog::Settings { tab: "slideshow".into() }); + Ok(Value::Null) } "view.fullScreenPreview" => { app.ui.fullscreen = !app.ui.fullscreen; @@ -1615,6 +1617,7 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool { "app.openLogFolder" => app.services.reveal.is_some() && app.services.log_file.is_some(), "file.restoreLibrary" => app.services.restore_library.is_some(), "compare.swap" | "compare.makeSelect" => app.ui.view == ViewMode::Compare, + "slideshow.togglePause" | "slideshow.next" | "slideshow.previous" | "slideshow.end" => app.ui.slideshow.is_some(), s if s.starts_with("dialog.merge") || (s.starts_with("merge.") && s.ends_with("Last")) => { app.session.targets(&serde_json::json!({})).len() >= 2 && app.merge.final_task.is_none() } diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index a8d99aed2..0652414c1 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -27,6 +27,7 @@ pub mod right; pub mod rules_editor; pub mod second; pub mod settings; +pub mod slideshow; pub mod strip; pub mod topbar; pub mod unnamed; diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 7036e10f1..7569f4a3c 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -1,12 +1,14 @@ -//! The Settings dialog (⌘,): General, Import, Performance, Display, Interface, Faces, AI Denoise. +//! The Settings dialog (⌘,): General, Import, Performance, Display, Interface, Slideshow, Faces, +//! AI Denoise. //! //! Changes apply immediately (no OK/Cancel). Where they are stored: //! - **app settings** ([`crate::state::AppSettings`]: startup view, delete confirmation, GPU, //! preview size, filmstrip/grid badges, last library, monitor profile) and Auto Advance live in //! the UI state, //! saved by the host in its config folder (`ui.json`); -//! - **library settings** (import defaults, XMP sidecars, thumbnail cache size) go through the -//! `library.preferences` / `library.xmpPreferences` commands into the library's `prefs.json`, +//! - **library settings** (import defaults, XMP sidecars, thumbnail cache size, slideshow +//! playback) go through the `library.preferences` / `library.xmpPreferences` / +//! `slideshow.playback` commands into the library's `prefs.json`, //! so they travel with the library. use egui::RichText; @@ -24,6 +26,7 @@ pub const TABS: &[(&str, &str)] = &[ ("performance", "Performance"), ("display", "Display"), ("interface", "Interface"), + ("slideshow", "Slideshow"), ("faces", "Faces"), ("denoise", "AI Denoise"), ]; @@ -53,6 +56,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, tab: &mut String) { "performance" => performance_tab(app, ui, &t), "display" => display_tab(app, ui, &t), "interface" => interface_tab(app, ui, &t), + "slideshow" => slideshow_tab(app, ui, &t), "faces" => super::faces::settings_tab(app, ui, &t), "denoise" => super::denoise::settings_tab(app, ui, &t), _ => general_tab(app, ui, &t), @@ -620,6 +624,95 @@ fn interface_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { }); } +// ----------------------------------------------------------------------------------- Slideshow + +/// A playback duration slider (`slider:{id}`). While it is dragged the value changes in memory +/// (the slideshow reads it); letting go saves it through `slideshow.playback`. +fn duration_slider(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, key: &str, value: f64, range: std::ops::RangeInclusive) { + let mut v = value; + let r = ui.add(egui::Slider::new(&mut v, range).logarithmic(key == "slideDuration").suffix(" s").max_decimals(1)); + register(ui.ctx(), format!("slider:{id}"), r.rect); + if r.changed() + && let Ok(next) = app.session.slideshow.merged(&json!({ key: v })) + { + app.session.slideshow = next; + } + if r.drag_stopped() || (r.changed() && !r.dragged()) { + let _ = app.run("slideshow.playback", json!({ key: v })); + } +} + +fn slideshow_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + use lightcraft_engine::slideshow::{FADE_MAX, SLIDE_MAX, SLIDE_MIN}; + heading(ui, t, crate::i18n::tr("Playback")); + let s = app.session.slideshow.clone(); + row(ui, t, crate::i18n::tr("Slide duration"), |ui| { + duration_slider(app, ui, "slideshowSlide", "slideDuration", s.slide_duration, SLIDE_MIN..=SLIDE_MAX) + }); + row(ui, t, crate::i18n::tr("Fade duration"), |ui| { + duration_slider(app, ui, "slideshowFade", "fadeDuration", s.fade_duration, 0.0..=FADE_MAX.min(s.slide_duration)) + }); + hint(ui, t, crate::i18n::tr("A slide stays up for its duration, its fade in included. A fade of 0 cuts.")); + row(ui, t, crate::i18n::tr("Fade"), |ui| { + #[derive(Clone, Copy, PartialEq)] + enum Fade { + Cross, + Black, + White, + Color, + } + let mut f = match s.fade_color { + None => Fade::Cross, + Some([0, 0, 0]) => Fade::Black, + Some([255, 255, 255]) => Fade::White, + Some(_) => Fade::Color, + }; + let opts = [(Fade::Cross, "Crossfade"), (Fade::Black, "Through black"), (Fade::White, "Through white"), (Fade::Color, "Through a colour")]; + if choices(ui, "settingsSlideshowFade", &opts, &mut f) { + let c = match f { + Fade::Cross => Value::Null, + Fade::Black => json!("#000000"), + Fade::White => json!("#ffffff"), + Fade::Color => json!([64, 64, 64]), + }; + let _ = app.run("slideshow.playback", json!({"fadeColor": c})); + } + if f == Fade::Color + && let Some(mut c) = s.fade_color + { + let r = egui::color_picker::color_edit_button_srgb(ui, &mut c); + register(ui.ctx(), "button:slideshowFadeColor", r.rect); + if r.changed() { + let _ = app.run("slideshow.playback", json!({"fadeColor": c})); + } + } + }); + let mut v = s.random_order; + if check(ui, "settings.slideshowRandom", &mut v, "Random order") { + let _ = app.run("slideshow.playback", json!({"randomOrder": v})); + } + let mut v = s.repeat; + if check(ui, "settings.slideshowRepeat", &mut v, "Repeat (off: the slideshow ends after the last photo)") { + let _ = app.run("slideshow.playback", json!({"repeat": v})); + } + let mut v = s.prepare_previews; + if check(ui, "settings.slideshowPrepare", &mut v, "Prepare previews in advance") { + let _ = app.run("slideshow.playback", json!({"preparePreviews": v})); + } + hint( + ui, + t, + crate::i18n::tr( + "Renders the next slides while the current one is up, so fades never wait. Off: each slide renders when it is due (less memory and work).", + ), + ); + hint( + ui, + t, + crate::i18n::tr("Slideshow in the View menu plays the photos in view from the selected one. These settings are saved with the library."), + ); +} + // ------------------------------------------------------------------------------- Open Library… /// `app.openLibrary {path?}`: close the current library and open (or create) the one at `path`, diff --git a/crates/ui-egui/src/panels/slideshow.rs b/crates/ui-egui/src/panels/slideshow.rs new file mode 100644 index 000000000..9e1416a67 --- /dev/null +++ b/crates/ui-egui/src/panels/slideshow.rs @@ -0,0 +1,389 @@ +//! The slideshow (View ▸ Slideshow, ⌥⌘↩): the photos in view, full screen, played with the +//! library's Playback settings (`slideshow.playback`: slide and fade durations, fade colour, +//! random order, repeat, preparing previews in advance). +//! +//! The timeline is [`lightcraft_engine::slideshow::Playback`] (no egui, tested on its own); this +//! module renders the slides, draws the frame it asks for and the playback controls. +//! +//! Smooth transitions: every slide is rendered into its own texture ([`Slot::Slide`]) off the UI +//! thread. With "prepare previews" on, the next [`AHEAD`] slides are requested as soon as a slide +//! comes up, so they are ready long before they are due; a fade then only draws two textures +//! that already exist (no render, no upload inside the fade). The timeline does not start a +//! transition until the next slide's texture exists ([`ready`]): the current slide is held +//! instead of flashing a stand-in. With it off, a slide is rendered when it is due. +//! +//! Which picture a slide shows comes from one place, [`slide_job`], so a later slide composer +//! (layout, overlays, backdrop) replaces that function and the playback stays as it is. + +use egui::{Align2, Color32, Pos2, Rect, pos2, vec2}; +use lightcraft_catalog::PhotoId; +use lightcraft_engine::slideshow::{Playback, Tick}; +use lightcraft_pipeline::geometry::Frame; +use serde_json::{Value, json}; + +use crate::LightcraftApp; +use crate::icons::Icon; +use crate::render::Slot; +use crate::widgets::{icon_button, register}; + +/// Slides prepared ahead of the one up. +pub const AHEAD: usize = 2; +/// Seconds the playback controls stay after the pointer last moved. +pub const CONTROLS_SECS: f64 = 2.5; + +/// A running slideshow. +#[derive(Clone, Debug)] +pub struct Run { + pub play: Playback, + /// The playback controls show until this time (egui time). + pub controls_until: f64, + /// Where the pointer was last frame (the controls appear when it moves). + pub pointer: Option, + /// Slides no render can be made for (gone from the catalog, nothing to read): shown with + /// whatever stand-in there is, never waited for. + pub unrenderable: Vec, +} + +/// `view.slideshow {interval?, seed?, slideDuration?, fadeDuration?, fadeColor?, randomOrder?, +/// repeat?, preparePreviews?}`: play the photos in view from the selected one with the library's +/// playback settings; the keys given change them for this run only (`interval` = slideDuration, +/// `seed` fixes the random order). +pub fn start(app: &mut LightcraftApp, p: &Value, now: f64) -> Result { + let mut overrides = p.as_object().cloned().unwrap_or_default(); + if let Some(i) = overrides.remove("interval") { + overrides.insert("slideDuration".into(), i); + } + let seed = match overrides.remove("seed") { + None | Some(Value::Null) => (now.abs() * 1000.0) as u64 ^ 0x5eed_5eed, + Some(v) => v.as_u64().ok_or_else(|| format!("view.slideshow: `seed` must be a whole number, not {v}"))?, + }; + let settings = app.session.slideshow.merged(&Value::Object(overrides)).map_err(|e| format!("view.slideshow: {e}"))?; + let photos = app.session.visible_cloned(); + let Some(play) = Playback::new(photos, app.session.active(), settings, seed, now) else { + return Err("no photos to show".into()); + }; + let first = play.current(); + if app.session.active() != Some(first) { + let _ = app.run("library.select", json!({"ids": [first.0]})); + } + let (_, slides) = play.position(); + let out = json!({"slides": slides, "seed": seed, "settings": play.settings().to_json()}); + app.ui.slideshow = Some(Box::new(Run { play, controls_until: 0.0, pointer: None, unrenderable: Vec::new() })); + app.ui.fullscreen = true; + app.ui.zoom = crate::state::Zoom::Fit; + app.ui.tool.clear(); + let _ = app.session.end_interaction(); + Ok(out) +} + +/// End the slideshow (back to the view it started from, on the last photo shown). +pub fn end(app: &mut LightcraftApp) { + app.ui.slideshow = None; + app.ui.fullscreen = false; + app.renderer.release_slides(&[]); +} + +/// The slideshow's own commands (the playback controls and their keys). +pub fn command(app: &mut LightcraftApp, id: &str, now: f64) -> Result { + let Some(run) = app.ui.slideshow.as_mut() else { return Err("no slideshow is playing".into()) }; + let t = match id { + "slideshow.togglePause" => { + run.play.toggle_pause(now); + return Ok(state(&run.play, now)); + } + "slideshow.next" => run.play.step(1, now), + "slideshow.previous" => run.play.step(-1, now), + _ => { + end(app); + return Ok(Value::Null); + } + }; + after(app, t); + Ok(app.ui.slideshow.as_ref().map(|r| state(&r.play, now)).unwrap_or(json!({"ended": true}))) +} + +/// What the slideshow is doing (for `slideshow.*` replies and tests). +pub fn state(play: &Playback, now: f64) -> Value { + let (n, of) = play.position(); + let f = play.frame(now); + json!({ + "current": play.current().0, + "slide": n, + "of": of, + "paused": play.is_paused(), + "fading": f.from.is_some(), + "progress": f.progress, + "waitingFor": play.waiting_for().map(|p| p.0), + "remaining": play.remaining(now), + }) +} + +/// Act on what the timeline did: follow the slide with the selection, or end. +fn after(app: &mut LightcraftApp, t: Tick) { + match t { + Tick::Advanced(id) if app.session.active() != Some(id) => { + let _ = app.run("library.select", json!({"ids": [id.0]})); + } + Tick::Ended => end(app), + _ => {} + } +} + +/// Can `id` be shown as a slide now: its texture exists (or it can't be rendered at all, and its +/// stand-in is all there will be: playback goes on rather than waiting forever). +pub fn ready(app: &LightcraftApp, id: PhotoId) -> bool { + app.renderer.textures.get(&Slot::Slide(id)).is_some_and(|t| t.photo == id) + || app.renderer.failure(Slot::Slide(id)).is_some() + || app.session.catalog.photo(id).is_none() + || app.ui.slideshow.as_ref().is_some_and(|r| r.unrenderable.contains(&id)) +} + +/// Advance a running slideshow (every frame, before drawing). +pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) { + let Some(run) = app.ui.slideshow.as_ref() else { + app.renderer.release_slides(&[]); + return; + }; + if !app.ui.fullscreen { + end(app); + return; + } + let now = ctx.input(|i| i.time); + let mut play = run.play.clone(); + let t = play.tick(now, |id| ready(app, id)); + if let Some(run) = app.ui.slideshow.as_mut() { + run.play = play; + } + after(app, t); + let Some(run) = app.ui.slideshow.as_ref() else { return }; + let f = run.play.frame(now); + if f.from.is_some() { + ctx.request_repaint(); // a fade draws every frame + } else if !run.play.is_paused() { + ctx.request_repaint_after(std::time::Duration::from_secs_f64(run.play.remaining(now).clamp(0.016, 120.0))); + } + if run.controls_until > now { + ctx.request_repaint_after(std::time::Duration::from_secs_f64((run.controls_until - now).clamp(0.016, CONTROLS_SECS))); + } +} + +/// The render that shows slide `id` in `area` (logical points at `ppp`): the photo as developed, +/// fitted to the screen. The one place that decides what a slide's picture is. +fn slide_job(app: &mut LightcraftApp, id: PhotoId, area: Rect, ppp: f32, texture_side: usize) -> Option { + let photo = app.session.catalog.photo(id)?.clone(); + let frame = Frame::with_lens(photo.width.max(1) as usize, photo.height.max(1) as usize, &photo.develop, true, photo.embedded_lens.as_ref()); + let aspect = frame.aspect() as f32; + let aspect = if aspect.is_finite() && aspect > 0.0 { aspect } else { 1.5 }; + let fit = (area.width() / aspect).min(area.height()); + let drawn_long = fit.max(fit * aspect) * ppp; + let native = super::detail::output_px(&frame); + let edge = app.ui.settings.loupe_edge(drawn_long, native[0].max(native[1]), texture_side); + let (w, h) = if aspect >= 1.0 { (edge, (edge as f32 / aspect) as usize) } else { ((edge as f32 * aspect) as usize, edge) }; + app.session.loupe_job(id, w.max(8), h.max(8), true) +} + +/// The slides to keep rendered now, most wanted first, with their render priority. +fn wanted(play: &Playback, now: f64) -> Vec<(PhotoId, u32)> { + let f = play.frame(now); + let mut v = vec![(f.current, 100)]; + if let Some(from) = f.from { + v.push((from, 99)); + } + if let Some(next) = play.waiting_for() { + v.push((next, 98)); + } + if play.settings().prepare_previews { + for (k, id) in play.upcoming(AHEAD).into_iter().enumerate() { + v.push((id, 97u32.saturating_sub(k as u32))); + } + } + let mut seen = Vec::new(); + v.retain(|(id, _)| { + let fresh = !seen.contains(id); + seen.push(*id); + fresh + }); + v +} + +/// The texture to draw for slide `id`: its own render, else the loupe's or a thumbnail (a slide +/// stepped to with ←/→ shows at once, sharp when its render lands). +fn texture(app: &LightcraftApp, id: PhotoId) -> Option<&crate::render::Tex> { + app.renderer + .textures + .get(&Slot::Slide(id)) + .filter(|t| t.photo == id) + .or_else(|| app.renderer.textures.get(&Slot::Main).filter(|t| t.photo == id)) + .or_else(|| app.renderer.thumb(id)) +} + +fn full_uv() -> Rect { + Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)) +} + +/// The parts of `outer` outside `inner` (up to four bands). +fn outside(outer: Rect, inner: Rect) -> Vec { + let i = inner.intersect(outer); + if !i.is_positive() { + return vec![outer]; + } + let mut v = Vec::new(); + if i.top() > outer.top() { + v.push(Rect::from_min_max(outer.min, pos2(outer.right(), i.top()))); + } + if i.bottom() < outer.bottom() { + v.push(Rect::from_min_max(pos2(outer.left(), i.bottom()), outer.max)); + } + if i.left() > outer.left() { + v.push(Rect::from_min_max(pos2(outer.left(), i.top()), pos2(i.left(), i.bottom()))); + } + if i.right() < outer.right() { + v.push(Rect::from_min_max(pos2(i.right(), i.top()), pos2(outer.right(), i.bottom()))); + } + v +} + +/// Draw the slideshow (full screen) and its controls. +pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { + let canvas = ui.max_rect(); + app.canvas_rect = Some(canvas); + // (`ui.pointer` positions are relative to it) + app.image_rect = Some(canvas); + let Some(run) = app.ui.slideshow.as_ref() else { return }; + let now = ui.input(|i| i.time); + let ppp = ui.ctx().pixels_per_point(); + let side = super::detail::texture_side(ui.ctx()); + let want = wanted(&run.play, now); + let frame = run.play.frame(now); + let mut unrenderable = Vec::new(); + for (id, priority) in &want { + match slide_job(app, *id, canvas, ppp, side) { + Some(job) => app.renderer.request(Slot::Slide(*id), job, *priority), + None => unrenderable.push(*id), + } + } + if let Some(run) = app.ui.slideshow.as_mut() { + for id in unrenderable { + if !run.unrenderable.contains(&id) { + run.unrenderable.push(id); + } + } + } + let keep: Vec = want.iter().map(|(id, _)| *id).collect(); + app.renderer.release_slides(&keep); + let p = ui.painter_at(canvas); + p.rect_filled(canvas, 0.0, Color32::BLACK); + let rect_of = |t: &crate::render::Tex| super::detail::fit_texture_rect(canvas, t.size); + let draw = |id: PhotoId, alpha: f32| -> Option { + let t = texture(app, id)?; + let r = rect_of(t); + p.image(t.tex.id(), r, full_uv(), Color32::WHITE.gamma_multiply(alpha.clamp(0.0, 1.0))); + Some(r) + }; + let k = frame.progress.clamp(0.0, 1.0); + match (frame.from, frame.fade_color) { + // fade through a colour: out to it, then in from it + (Some(from), Some(c)) => { + let veil = if k < 0.5 { + draw(from, 1.0); + k * 2.0 + } else { + draw(frame.current, 1.0); + 2.0 - k * 2.0 + }; + p.rect_filled(canvas, 0.0, Color32::from_rgb(c[0], c[1], c[2]).gamma_multiply(veil.clamp(0.0, 1.0))); + } + // crossfade: where the slides overlap the new one goes over the old at `k`; where only + // the old one is, it fades to the backdrop + (Some(from), None) => { + let new_rect = texture(app, frame.current).map(rect_of); + if let Some(t) = texture(app, from) { + let r = rect_of(t); + match new_rect { + Some(n) => { + p.with_clip_rect(n.intersect(canvas)).image(t.tex.id(), r, full_uv(), Color32::WHITE); + for band in outside(r, n) { + p.with_clip_rect(band).image(t.tex.id(), r, full_uv(), Color32::WHITE.gamma_multiply(1.0 - k)); + } + } + None => { + p.image(t.tex.id(), r, full_uv(), Color32::WHITE.gamma_multiply(1.0 - k)); + } + } + } + draw(frame.current, k); + } + _ => { + draw(frame.current, 1.0); + } + } + register(ui.ctx(), "slideshow:canvas", canvas); + controls(app, ui, canvas, now); +} + +/// The playback controls: previous, play / pause, next, slide n of N, end. They appear when the +/// pointer moves and fade out [`CONTROLS_SECS`] after it stops (or stay while it is over them). +fn controls(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect, now: f64) { + let t = crate::theme::Tokens::get(ui.ctx()); + let pos = ui.input(|i| i.pointer.latest_pos()); + let pressed = ui.input(|i| i.pointer.any_pressed()); + let bar = Rect::from_center_size(pos2(canvas.center().x, canvas.bottom() - 56.0), vec2(300.0, 44.0)); + let Some(run) = app.ui.slideshow.as_mut() else { return }; + let moved = pos.is_some() && run.pointer.is_some() && pos != run.pointer; + let over = pos.is_some_and(|p| bar.expand(8.0).contains(p)); + if moved || pressed || (over && run.controls_until > now) { + run.controls_until = now + CONTROLS_SECS; + } + run.pointer = pos.or(run.pointer); + let left = run.controls_until - now; + if left <= 0.0 { + return; + } + let paused = run.play.is_paused(); + let (n, of) = run.play.position(); + // fade out over the last half second + let alpha = (left / 0.5).clamp(0.0, 1.0) as f32; + let p = ui.painter_at(canvas); + p.rect_filled(bar, 10.0, Color32::from_black_alpha((200.0 * alpha) as u8)); + let mut child = + ui.new_child(egui::UiBuilder::new().max_rect(bar.shrink2(vec2(10.0, 4.0))).layout(egui::Layout::left_to_right(egui::Align::Center))); + child.set_opacity(alpha); + child.spacing_mut().item_spacing.x = 4.0; + let size = vec2(36.0, 36.0); + let mut fire = None; + if icon_button(&mut child, "slideshowPrevious", Icon::ChevronLeft, size, false, true, "Previous slide (←)").clicked() { + fire = Some("slideshow.previous"); + } + let (icon, tip) = if paused { (Icon::Play, "Play (Space)") } else { (Icon::Pause, "Pause (Space)") }; + if icon_button(&mut child, "slideshowPlayPause", icon, size, false, true, tip).clicked() { + fire = Some("slideshow.togglePause"); + } + if icon_button(&mut child, "slideshowNext", Icon::ChevronRight, size, false, true, "Next slide (→)").clicked() { + fire = Some("slideshow.next"); + } + let (r, _) = child.allocate_exact_size(vec2(118.0, 36.0), egui::Sense::hover()); + let label = format!("{n} / {of}"); + child.painter().text(r.center(), Align2::CENTER_CENTER, label, t.font(13.0), Color32::from_gray(230).gamma_multiply(alpha)); + if icon_button(&mut child, "slideshowEnd", Icon::Close, size, false, true, "End slideshow (Esc)").clicked() { + fire = Some("slideshow.end"); + } + if let Some(id) = fire { + let _ = app.run(id, json!({})); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn crossfade_bands_cover_the_old_slide_outside_the_new_one() { + let outer = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 60.0)); + let inner = Rect::from_min_max(pos2(30.0, 0.0), pos2(70.0, 60.0)); + let bands = outside(outer, inner); + let area: f32 = bands.iter().map(|b| b.area()).sum(); + assert_eq!(area, 100.0 * 60.0 - 40.0 * 60.0); + assert_eq!(outside(outer, Rect::from_min_max(pos2(200.0, 0.0), pos2(300.0, 10.0))), vec![outer], "no overlap: all of it"); + assert!(outside(outer, outer.expand(5.0)).is_empty(), "covered"); + } +} diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index 6b2293f65..8346650c7 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -52,6 +52,9 @@ pub enum Slot { Import(u32), /// The second window's view of the active photo. Second, + /// A slideshow slide (the one up, the one fading out, the next ones prepared in advance; see + /// [`crate::panels::slideshow`]). + Slide(PhotoId), } pub struct Tex { @@ -206,6 +209,8 @@ pub struct Renderer { variant_want: usize, /// Frames polled so far. frame: u64, + /// Slideshow slides may be held ([`Self::release_slides`] has something to do). + slides_live: bool, /// Stage caches cleared to stay in budget (see [`stage_trim_order`]). stages_trimmed: u64, /// A budget other than the memory budget's share (tests). @@ -267,6 +272,7 @@ impl Renderer { variant_used: HashMap::new(), variant_want: 0, frame: 0, + slides_live: false, stages_trimmed: 0, #[cfg(test)] stage_budget_override: None, @@ -310,7 +316,7 @@ impl Renderer { /// Views that render straight into the display profile's primaries (wide gamut); everything /// else is rendered for sRGB and converted ([`lightcraft_engine::display::present`]). fn renders_for_display(slot: Slot) -> bool { - matches!(slot, Slot::Main | Slot::Region | Slot::Before | Slot::RegionBefore | Slot::Hover | Slot::Compare(_) | Slot::Second) + matches!(slot, Slot::Main | Slot::Region | Slot::Before | Slot::RegionBefore | Slot::Hover | Slot::Compare(_) | Slot::Second | Slot::Slide(_)) } /// `job` as [`Self::request`] would run it for `slot` (its key included): views render for @@ -384,6 +390,9 @@ impl Renderer { /// Request a render for `slot` (no-op if already current or pending at the same priority). pub fn request(&mut self, slot: Slot, job: RenderJob, priority: u32) { + if matches!(slot, Slot::Slide(_)) { + self.slides_live = true; + } let mut job = self.job_for(slot, job); if !self.request_needed(slot, job.key, priority) { return; @@ -442,6 +451,23 @@ impl Renderer { self.pool.submit(slot, key, priority, Box::new(move || lightcraft_engine::memory::in_background(|| job.run()))); } + /// Drop the slideshow slides other than `keep`: their textures and their requests that have + /// not started (a slide that is no longer near is not worth rendering). + pub fn release_slides(&mut self, keep: &[PhotoId]) { + if !self.slides_live { + return; + } + let gone = |s: &Slot| matches!(s, Slot::Slide(id) if !keep.contains(id)); + self.textures.retain(|s, _| !gone(s)); + let _ = self.pool.reprioritize(|s, p| if gone(s) { None } else { Some(p) }); + self.queue.retain(|q| !gone(&q.slot)); + // (a render already running finishes unclaimed: its result is dropped) + self.pending.retain(|s, _| !gone(s)); + self.request_ids.retain(|s, _| !gone(s)); + self.failed.retain(|s, _| !gone(s)); + self.slides_live = !keep.is_empty(); + } + /// Is a request for `slot` queued or running? /// Drop every texture and cached stage (another library was opened: photo ids changed meaning). pub fn forget_all(&mut self) { diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index e3af3c66a..3ebc9d2ab 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -366,13 +366,11 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { } }; for (id, params) in aliased { - // Space pauses / resumes a slideshow - if id == "view.zoomToggle" - && let Some((interval, _, paused)) = app.ui.slideshow - { - let now = ctx.input(|i| i.time); - app.ui.slideshow = Some((interval, now + interval, !paused)); - app.toast(ctx, if paused { "Slideshow resumed" } else { "Slideshow paused" }); + // a slideshow: Space pauses / resumes + if id == "view.zoomToggle" && app.ui.slideshow.is_some() { + if let Ok(v) = app.run("slideshow.togglePause", json!({})) { + app.toast(ctx, if v["paused"] == true { "Slideshow paused" } else { "Slideshow resumed" }); + } continue; } // flag/rate aliases (Shift+X…) go through the culling path: active photo in Compare/Survey, @@ -392,6 +390,11 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { } } for f in fire { + // a slideshow: ←/→ step (a cut) + if app.ui.slideshow.is_some() && matches!(f.as_str(), "library.next" | "library.previous") { + let _ = app.run(if f == "library.next" { "slideshow.next" } else { "slideshow.previous" }, json!({})); + continue; + } if let Some(rest) = f.strip_prefix("rate:") { let (n, adv) = rest.split_once(':').unwrap_or(("0", "0")); cull(app, "photo.rate", json!({"rating": n.parse::().unwrap_or(0)}), adv == "1"); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index b178a167c..002d6386a 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -476,10 +476,9 @@ pub struct UiState { /// A keyword being dragged in the Keyword List (onto another to nest it). #[serde(skip)] pub dragging_keyword: Option, - /// A running slideshow (full screen): seconds per photo, when the next one is due (egui - /// time), paused. + /// A running slideshow (full screen; see [`crate::panels::slideshow`]). #[serde(skip)] - pub slideshow: Option<(f64, f64, bool)>, + pub slideshow: Option>, /// Info overlay on the loupe. pub info_overlay: InfoOverlay, /// Navigator mini map in the loupe while zoomed in. diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index c748ecf14..d075dda3a 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -812,38 +812,6 @@ fn local_folder_tree_expands_and_browses() { h.settle(SETTLE); } -#[test] -fn slideshow_advances_pauses_and_ends() { - let mut h = detail("panel.edit"); - let first = h.app.session.active(); - exec(&mut h, "view.slideshow", json!({"interval": 0.5})); - assert!(h.app.ui.fullscreen && h.app.ui.slideshow.is_some()); - // simulated time runs with the frames - let mut moved = false; - for _ in 0..200 { - h.step(); - if h.app.session.active() != first { - moved = true; - break; - } - } - assert!(moved, "the next photo comes up"); - // Space pauses: nothing moves - let r = h.request("ui.key", json!({"key": "space"}), T); - assert_eq!(r["ok"], true, "{r}"); - assert!(h.app.ui.slideshow.is_some_and(|s| s.2), "paused"); - let held = h.app.session.active(); - for _ in 0..120 { - h.step(); - } - assert_eq!(h.app.session.active(), held); - // Esc ends it - let r = h.request("ui.key", json!({"key": "escape"}), T); - assert_eq!(r["ok"], true, "{r}"); - assert!(!h.app.ui.fullscreen && h.app.ui.slideshow.is_none()); - h.settle(SETTLE); -} - #[test] fn geometry_slider_drag_marks_the_grid() { let mut h = detail("panel.crop"); diff --git a/crates/ui-egui/src/tests_slideshow.rs b/crates/ui-egui/src/tests_slideshow.rs new file mode 100644 index 000000000..06e402bef --- /dev/null +++ b/crates/ui-egui/src/tests_slideshow.rs @@ -0,0 +1,242 @@ +//! The slideshow in the app (View ▸ Slideshow): playback with the library's settings, slides +//! prepared ahead off the UI thread, transitions only between ready slides, keys and controls. +//! Simulated time: every headless frame is 1/60 s, so nothing here sleeps. + +use std::time::Duration; + +use lightcraft_catalog::PhotoId; +use serde_json::json; + +use crate::headless::Headless; +use crate::render::Slot; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(120); + +fn grid() -> Headless { + let services = Services { png: None, ..Default::default() }; + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + let mut h = Headless::new(app, [1000.0, 700.0], 1.0); + h.step(); + h +} + +fn run(h: &mut Headless, command: &str, params: serde_json::Value) -> serde_json::Value { + let r = h.request("engine.execute", json!({"command": command, "params": params}), T); + assert_eq!(r["ok"], true, "{command}: {r}"); + r["result"].clone() +} + +fn play(h: &Headless) -> &lightcraft_engine::slideshow::Playback { + &h.app.ui.slideshow.as_ref().expect("a slideshow is playing").play +} + +fn has_slide(h: &Headless, id: PhotoId) -> bool { + h.app.renderer.textures.get(&Slot::Slide(id)).is_some_and(|t| t.photo == id) +} + +fn slides_held(h: &Headless) -> usize { + h.app.renderer.textures.keys().filter(|s| matches!(s, Slot::Slide(_))).count() +} + +#[test] +fn slideshow_advances_pauses_and_ends() { + let mut h = grid(); + let first = h.app.session.visible_cloned()[0]; + run(&mut h, "view.slideshow", json!({"interval": 0.5, "fadeDuration": 0})); + assert!(h.app.ui.fullscreen && h.app.ui.slideshow.is_some()); + assert_eq!(h.app.session.active(), Some(first), "starts at the first photo in view"); + assert!(h.step_until(SETTLE, |h| h.app.session.active() != Some(first)), "the next photo comes up and is selected"); + // Space pauses: nothing moves + let r = h.request("ui.key", json!({"key": "space"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(play(&h).is_paused(), "paused"); + let held = h.app.session.active(); + for _ in 0..120 { + h.step(); + } + assert_eq!(h.app.session.active(), held); + // Esc ends it, and the slides are let go + let r = h.request("ui.key", json!({"key": "escape"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(!h.app.ui.fullscreen && h.app.ui.slideshow.is_none()); + assert_eq!(slides_held(&h), 0, "slide textures freed"); + h.settle(SETTLE); +} + +#[test] +fn next_slides_are_prepared_before_they_are_due() { + let mut h = grid(); + // a long slide: anything rendered for the next ones is rendered ahead of time + run(&mut h, "view.slideshow", json!({"interval": 60, "fadeDuration": 1, "preparePreviews": true, "seed": 7})); + let ahead = play(&h).upcoming(crate::panels::slideshow::AHEAD); + assert_eq!(ahead.len(), 2); + let start = h.time(); + assert!(h.step_until(SETTLE, |h| ahead.iter().all(|id| has_slide(h, *id))), "the next two slides are rendered"); + let current = play(&h).current(); + assert!(has_slide(&h, current)); + assert!(h.time() - start < 60.0, "before the first is due"); + assert_eq!(play(&h).current(), current, "still on the first slide"); + h.settle(SETTLE); + + // off: only the slide up is rendered until the next one is due + let mut h = grid(); + run(&mut h, "view.slideshow", json!({"interval": 60, "preparePreviews": false})); + let next = play(&h).upcoming(1)[0]; + let current = play(&h).current(); + assert!(h.step_until(SETTLE, |h| has_slide(h, current))); + for _ in 0..30 { + h.step(); + } + assert!(!has_slide(&h, next) && !h.app.renderer.is_pending(Slot::Slide(next)), "not prepared"); + h.settle(SETTLE); +} + +#[test] +fn a_fade_only_runs_between_rendered_slides() { + let mut h = grid(); + // without previews prepared, each slide renders when it is due: the transition must wait + run(&mut h, "view.slideshow", json!({"interval": 0.5, "fadeDuration": 0.4, "preparePreviews": false})); + let mut fades = 0; + let t0 = std::time::Instant::now(); + while fades < 20 && t0.elapsed() < SETTLE { + h.step(); + let f = play(&h).frame(h.time()); + if let Some(from) = f.from { + fades += 1; + assert!(has_slide(&h, f.current), "fading in a slide that isn't rendered"); + assert!(has_slide(&h, from), "fading out a slide that was let go"); + } + } + assert!(fades >= 20, "fades ran ({fades})"); + h.settle(SETTLE); +} + +#[test] +fn arrows_cut_and_repeat_off_ends_after_the_last_slide() { + let mut h = grid(); + let photos = h.app.session.visible_cloned(); + run(&mut h, "view.slideshow", json!({"interval": 60, "fadeDuration": 2, "repeat": false})); + let r = h.request("ui.key", json!({"key": "right"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(play(&h).current(), photos[1]); + assert_eq!(h.app.session.active(), Some(photos[1]), "the selection follows"); + assert!(play(&h).frame(h.time()).from.is_none(), "a cut, not a fade"); + let r = h.request("ui.key", json!({"key": "left"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(play(&h).current(), photos[0]); + for _ in 1..photos.len() { + run(&mut h, "slideshow.next", json!({})); + } + assert_eq!(play(&h).position(), (photos.len(), photos.len())); + run(&mut h, "slideshow.next", json!({})); + assert!(h.app.ui.slideshow.is_none() && !h.app.ui.fullscreen, "no repeat: past the last one it ends"); + h.settle(SETTLE); +} + +#[test] +fn random_order_starts_at_the_selection_and_is_fixed_by_the_seed() { + let order = |seed: u64| { + let mut h = grid(); + let photos = h.app.session.visible_cloned(); + run(&mut h, "library.select", json!({"ids": [photos[3].0]})); + run(&mut h, "view.slideshow", json!({"randomOrder": true, "seed": seed, "interval": 60})); + let mut seen = vec![play(&h).current()]; + for _ in 1..photos.len() { + run(&mut h, "slideshow.next", json!({})); + seen.push(play(&h).current()); + } + h.settle(SETTLE); + (photos, seen) + }; + let (photos, a) = order(11); + assert_eq!(a[0], photos[3]); + let in_view_order: Vec = photos.iter().copied().filter(|p| *p != photos[3]).collect(); + assert_ne!(a[1..].to_vec(), in_view_order, "shuffled"); + let mut sorted = a.clone(); + sorted.sort(); + let mut all = photos.clone(); + all.sort(); + assert_eq!(sorted, all, "every photo once"); + assert_eq!(order(11).1, a, "same seed, same order"); +} + +#[test] +fn playback_controls_show_on_pointer_movement_and_hide() { + let mut h = grid(); + run(&mut h, "view.slideshow", json!({"interval": 60})); + h.step(); + let shown = |h: &Headless| h.app.widgets.iter().any(|(w, _)| w == "icon:slideshowPlayPause"); + assert!(!shown(&h), "hidden until the pointer moves"); + for fx in [0.3, 0.6] { + let r = h.request("ui.hoverWidget", json!({"id": "slideshow:canvas", "fx": fx, "fy": 0.3}), T); + assert_eq!(r["ok"], true, "{r}"); + } + h.step(); + assert!(shown(&h), "the pointer moved: the controls show"); + assert!(h.painted_text().iter().any(|t| t.contains(&format!("1 / {}", h.app.session.visible_cloned().len()))), "slide n of N"); + let r = h.request("ui.clickWidget", json!({"id": "icon:slideshowPlayPause"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(play(&h).is_paused(), "the play / pause button pauses"); + // the pointer rests away from the bar: gone after a few seconds + let r = h.request("ui.hoverWidget", json!({"id": "slideshow:canvas", "fx": 0.5, "fy": 0.2}), T); + assert_eq!(r["ok"], true, "{r}"); + for _ in 0..(60.0 * (crate::panels::slideshow::CONTROLS_SECS + 0.5)) as usize { + h.step(); + } + assert!(!shown(&h), "hidden again"); + let r = h.request("ui.hoverWidget", json!({"id": "slideshow:canvas", "fx": 0.55, "fy": 0.25}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + let r = h.request("ui.clickWidget", json!({"id": "icon:slideshowEnd"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.app.ui.slideshow.is_none() && !h.app.ui.fullscreen, "the end button ends it"); + h.settle(SETTLE); +} + +#[test] +fn playback_settings_drive_the_slideshow_and_bad_ones_are_refused() { + let mut h = grid(); + let r = run(&mut h, "slideshow.playback", json!({"slideDuration": 9, "fadeDuration": 0.5, "repeat": false})); + assert_eq!(r["slideDuration"], 9.0); + let r = h.request("engine.execute", json!({"command": "view.slideshow", "params": {"slideDuration": "slow"}}), T); + assert_ne!(r["ok"], true, "{r}"); + assert!(h.app.ui.slideshow.is_none()); + let r = h.request("engine.execute", json!({"command": "view.slideshow", "params": {"seed": -1}}), T); + assert_ne!(r["ok"], true, "{r}"); + let r = run(&mut h, "view.slideshow", json!({})); + assert_eq!(r["settings"]["slideDuration"], 9.0); + assert_eq!(r["settings"]["repeat"], false); + // `interval` (the older parameter) is this run's slide duration; the saved one stays + run(&mut h, "slideshow.end", json!({})); + let r = run(&mut h, "view.slideshow", json!({"interval": 2})); + assert_eq!(r["settings"]["slideDuration"], 2.0); + assert_eq!(h.app.session.slideshow.slide_duration, 9.0); + // the options open in Settings + run(&mut h, "slideshow.end", json!({})); + run(&mut h, "view.slideshowOptions", json!({})); + h.step(); + assert!(h.app.widgets.iter().any(|(w, _)| w == "check:settings.slideshowRandom")); + let r = h.request("ui.clickWidget", json!({"id": "check:settings.slideshowRandom"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(h.app.session.slideshow.random_order, "the checkbox goes through slideshow.playback"); + h.settle(SETTLE); +} + +#[test] +fn a_photo_deleted_while_it_plays_does_not_stall_the_slideshow() { + let mut h = grid(); + let photos = h.app.session.visible_cloned(); + run(&mut h, "view.slideshow", json!({"interval": 0.5, "fadeDuration": 0, "preparePreviews": false})); + // the next slide goes away before it is due + let next = play(&h).upcoming(1)[0]; + run(&mut h, "photo.delete", json!({"ids": [next.0]})); + run(&mut h, "photo.deletePermanently", json!({"ids": [next.0]})); + assert!(h.app.session.catalog.photo(next).is_none()); + assert!(h.step_until(SETTLE, |h| h.app.ui.slideshow.as_ref().is_some_and(|r| r.play.position().0 >= 3)), "it plays on"); + assert!(photos.len() > 3); + h.settle(SETTLE); +} diff --git a/docs/parity.md b/docs/parity.md index d0bdc35c2..bbce64327 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -42,15 +42,15 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | S. Export (EXP) | 15 | 2 | 1 | 0 | 7/7 (100%) | 7/7 (100%) | | T. Share (SHARE) | 0 | 0 | 0 | 4 | — | — | | U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) | -| V. Preferences (PREF) | 7 | 1 | 2 | 2 | 1/1 (100%) | 5/5 (100%) | +| V. Preferences (PREF) | 6 | 2 | 2 | 2 | 1/1 (100%) | 4/5 (80%) | | W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — | | X. Cross-cutting behaviours (BEHAV) | 18 | 6 | 1 | 1 | 8/8 (100%) | 6/9 (67%) | | Y. Menus | 82 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 75 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) | -| Lightroom Classic extras | 32 | 11 | 39 | 9 | — | 22/24 (92%) | -| **Total** | 393 | 39 | 84 | 36 | 192/200 (96%) | 142/152 (93%) | +| Lightroom Classic extras | 33 | 16 | 34 | 9 | — | 23/25 (92%) | +| **Total** | 397 | 50 | 72 | 36 | 192/200 (96%) | 143/155 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.9%** of 516 in-scope rows — P0 98.0% of 200 · P1 95.7% of 152 · P2 42.9% of 163. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.3%** of 519 in-scope rows — P0 98.0% of 200 · P1 95.2% of 155 · P2 47.5% of 163. ## Top gaps @@ -200,7 +200,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-VIEW-COMPARE | Compare two photos | P1 | ✅ | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `crates/ui-egui/src/panels/compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; no zoom-link toggle | | LR-VIEW-SURVEY | Survey view [Classic] | P2 | ✅ | `cmd:view.survey`, `crates/ui-egui/src/panels/compare.rs` | selection tiled (≤ 48), keys act on the active photo, hover × removes | | LR-VIEW-INFOOVERLAY | Info overlay on the photo | P1 | ✅ | `cmd:view.infoOverlay` | off / file + date + size / exposure + camera; ⌘I cycles (I in full screen; elsewhere I stays the Info panel) | -| LR-VIEW-SLIDESHOW | Slideshow | P2 | ✅ | `cmd:view.slideshow` | View ▸ Slideshow (⌥⌘↩): the photos in view full screen, every 4 s (`interval`), wrapping; Space pauses, ←/→ step, Esc ends | +| LR-VIEW-SLIDESHOW | Slideshow | P2 | ✅ | `cmd:view.slideshow` | View ▸ Slideshow (⌥⌘↩): the photos in view full screen from the selected one, played with the library's playback settings (see LRC-SS-PLAYBACK: fades, random order, repeat, previews prepared ahead; `interval` / any setting overrides one run); Space pauses, ←/→ step (a cut), Esc ends; playback controls on pointer movement | | LR-VIEW-SECONDWINDOW | Second display window [Classic] | P2 | ✅ | `cmd:view.secondWindow`, `crates/ui-egui/src/panels/second.rs` | Window ▸ Second Window (⌘F11): the active photo fitted in its own native window with its own render (a floating panel where there are no native windows); loupe view only (no grid / compare / survey there) | | LR-VIEW-CLIPPING | Clipping indicators | P0 | ✅ | `cmd:view.clipping` | | | LR-VIEW-HISTOGRAM | Histogram | P0 | ✅ | `cmd:view.histogram`, `crates/ui-egui/src/panels/edit.rs` | no drag-to-adjust on the histogram | @@ -800,7 +800,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-SS-BACKDROP | Slideshow backdrop | P2 | ⬜ | | | | LRC-SS-TITLES | Slideshow titles | P2 | ⬜ | | | | LRC-SS-MUSIC | Slideshow music | P2 | ⬜ | | | -| LRC-SS-PLAYBACK | Slideshow playback | P2 | ⬜ | | | +| LRC-SS-PLAYBACK | Slideshow playback | P2 | 🟡 | `cmd:slideshow.playback`, `cmd:view.slideshowOptions`, `cmd:slideshow.togglePause`, `cmd:slideshow.next`, `cmd:slideshow.previous`, `cmd:slideshow.end` | Playback settings (library preference, `slideshow.playback`; Settings ▸ Slideshow, View ▸ Slideshow Options…): slide duration 0.5–120 s, fade 0–10 s (≤ slide; 0 = cut) as a crossfade or through a colour, random order (seeded shuffle, every photo once per pass, a new order each pass), repeat (off: ends after the last slide), prepare previews in advance (the next 2 slides render off the UI thread; a transition never starts before its slide is rendered — the current one is held). Space / ←/→ (cut) / Esc and a control bar (previous, play/pause, next, n / N, end) that shows on pointer movement. Missing: the Slideshow module view and its "preview in the view" (phase 2 of the module plan), music and sync to music (LRC-SS-MUSIC), pan & zoom, manual-advance mode, choosing the screen to play on, slides composed with layout/overlays/backdrop (LRC-SS-LAYOUT…) | | LRC-SS-EXPORT | Slideshow export | P2 | ⬜ | | | | LRC-PRINT-LAYOUTSTYLE | Print layout styles | P2 | 🟡 | `cmd:dialog.contactSheet`, `cmd:export.contactSheet`, `crates/engine/src/contact_sheet.rs` | paginated contact sheets; single-image/custom package layouts missing | | LRC-PRINT-IMAGESETTINGS | Print image settings | P2 | ⬜ | | | diff --git a/docs/whats-new.md b/docs/whats-new.md index fb1fd3165..02dea32ac 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,16 @@ ## October 2026 +### Slideshow playback +- View ▸ Slideshow (⌥⌘↩) now plays with Lightroom Classic's Playback settings: how long each slide stays up, a fade + between slides (a crossfade, or through black, white or any colour; 0 cuts), random order (every photo once per + pass, a new order each time round), repeat (off: the slideshow ends after the last photo) and preparing previews in + advance (the next two slides render while the current one is up, so a fade never waits or flashes). +- Change them in Settings ▸ Slideshow (View ▸ Slideshow Options…); they are saved with the library. Agents use + `slideshow.playback`; `view.slideshow` takes any of them for one run (`interval` still sets the slide duration). +- While it plays, moving the pointer shows a small control bar: previous, play / pause, next, the slide number and + end. Space pauses, ← / → step (a cut), Esc ends. A slide's time starts once it is shown sharp. + ### Activity stack - Long-running tasks show in one place, top left under the top bar, as in Lightroom Classic (issue #345): imports and folder scans, exports (and the contact sheet PDF), Synchronize Folder, Build / Smart Previews, Lightroom catalog import, Photo Merge, model downloads, the face scan, From ae033aaf324802a1fb34b94eefdaba016e825a98 Mon Sep 17 00:00:00 2001 From: Jason Baldwin <33934381+bitshiftza@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:47:42 +0200 Subject: [PATCH 2/6] LRC-SS-LAYOUT: slideshow layout, options and backdrop: slides composed with margins, stroke, shadow and backdrop - New crate lightcraft-slideshow (L1, no UI): SlideSettings (margins linked or independent, guides, aspect preview, zoom to fill, stroke border, cast shadow, backdrop colour, colour wash, background image; lengths relative to the slide's shorter side; validated and clamped) and a CPU compose() used by playback, the Settings preview and slideshow.preview. - Engine: slideshow.layout (partial merge, saved in prefs.json) and slideshow.preview {id?, width?, height?, path?} (PNG to path or base64); RenderJob::with_slide composes on the render worker; the background photo is rendered once and shared. - Playback: with a non-default layout each slide is the composed screen-sized slide, prepared ahead like before; the default layout plays as before. - Settings > Slideshow: Layout, Options and Backdrop sections and a debounced live preview of the active photo's slide (with margin guides). - Tests: composer geometry/pixels/hostile input, command get/set/persist/preview, headless playback of a composed slide and of the default layout. --- Cargo.lock | 11 + Cargo.toml | 1 + apps/lightcraft-web/src/workers.rs | 10 + crates/engine/Cargo.toml | 1 + crates/engine/src/cmd/slideshow.rs | 142 +++++- crates/engine/src/import.rs | 1 + crates/engine/src/lib.rs | 10 + crates/engine/src/library.rs | 4 + crates/engine/src/media.rs | 31 +- crates/engine/src/slide.rs | 140 ++++++ crates/engine/src/tests_slide.rs | 139 ++++++ crates/slideshow/Cargo.toml | 14 + crates/slideshow/src/compose.rs | 513 ++++++++++++++++++++++ crates/slideshow/src/lib.rs | 23 + crates/slideshow/src/settings.rs | 401 +++++++++++++++++ crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/locales/de.json | 28 +- crates/ui-egui/locales/es.json | 28 +- crates/ui-egui/locales/fr.json | 28 +- crates/ui-egui/locales/ja.json | 28 +- crates/ui-egui/locales/pt-br.json | 28 +- crates/ui-egui/locales/ru.json | 28 +- crates/ui-egui/locales/uk.json | 28 +- crates/ui-egui/locales/zh-hans.json | 28 +- crates/ui-egui/locales/zh-hant.json | 28 +- crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/panels/settings.rs | 27 +- crates/ui-egui/src/panels/slide_layout.rs | 228 ++++++++++ crates/ui-egui/src/panels/slideshow.rs | 38 +- crates/ui-egui/src/render.rs | 3 + crates/ui-egui/src/tests_slideshow.rs | 104 +++++ docs/control-protocol.md | 2 + docs/mcp.md | 6 + docs/parity-checklist.md | 14 +- docs/whats-new.md | 12 + xtask/src/layers.rs | 1 + 36 files changed, 2094 insertions(+), 36 deletions(-) create mode 100644 crates/engine/src/slide.rs create mode 100644 crates/engine/src/tests_slide.rs create mode 100644 crates/slideshow/Cargo.toml create mode 100644 crates/slideshow/src/compose.rs create mode 100644 crates/slideshow/src/lib.rs create mode 100644 crates/slideshow/src/settings.rs create mode 100644 crates/ui-egui/src/panels/slide_layout.rs diff --git a/Cargo.lock b/Cargo.lock index 0ad4ab843..8f2dbb46b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3130,6 +3130,7 @@ dependencies = [ "lightcraft-raw", "lightcraft-scenes", "lightcraft-segment", + "lightcraft-slideshow", "lightcraft-tiff", "log", "miniz_oxide 0.8.9", @@ -3326,6 +3327,15 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "lightcraft-slideshow" +version = "0.5.0" +dependencies = [ + "lightcraft-raster", + "serde", + "serde_json", +] + [[package]] name = "lightcraft-sysmem" version = "0.5.0" @@ -3357,6 +3367,7 @@ dependencies = [ "lightcraft-pipeline", "lightcraft-preview", "lightcraft-raster", + "lightcraft-slideshow", "log", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 58e5383b7..4a796e757 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,7 @@ lightcraft-mcp = { path = "crates/mcp" } lightcraft-fetch = { path = "crates/fetch" } lightcraft-heif = { path = "crates/heif" } lightcraft-segment = { path = "crates/segment" } +lightcraft-slideshow = { path = "crates/slideshow" } # foundation thiserror = "2" diff --git a/apps/lightcraft-web/src/workers.rs b/apps/lightcraft-web/src/workers.rs index f321bdb9b..86d54fc66 100644 --- a/apps/lightcraft-web/src/workers.rs +++ b/apps/lightcraft-web/src/workers.rs @@ -165,6 +165,8 @@ struct Busy { level: SourceLevel, key: u64, cache: Option<(Arc, Hash128)>, + /// A slideshow slide: the worker renders the photo, it is composed here when it comes back. + slide: Option>, } struct W { @@ -369,6 +371,13 @@ impl Workers { }); } g.remote_done += 1; + let rendered = match (rendered, &b.slide) { + (Ok(mut r), Some(slide)) => { + r.image = slide.compose(&r.image); + Ok(r) + } + (r, _) => r, + }; g.done.push(( b.slot, RenderResult { @@ -490,6 +499,7 @@ impl RenderOffload for Workers { level: job.level, key: job.key, cache: job.cache, + slide: job.slide, }); None } diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 4036a5c94..a17e0885a 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -27,6 +27,7 @@ lightcraft-meta = { workspace = true } lightcraft-faces = { workspace = true } miniz_oxide = { workspace = true } lightcraft-merge = { workspace = true } +lightcraft-slideshow = { workspace = true } rayon = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/engine/src/cmd/slideshow.rs b/crates/engine/src/cmd/slideshow.rs index e6cd2d008..17bd861f1 100644 --- a/crates/engine/src/cmd/slideshow.rs +++ b/crates/engine/src/cmd/slideshow.rs @@ -1,12 +1,19 @@ //! `slideshow.playback`: the slideshow's Playback settings (slide and fade durations, fade colour, -//! random order, repeat, preparing previews in advance). A library preference, saved in the -//! library's `prefs.json`; the app's slideshow (View ▸ Slideshow) plays with them. +//! random order, repeat, preparing previews in advance). `slideshow.layout`: how a slide looks +//! (Layout, Options and Backdrop: margins, aspect preview, zoom to fill, stroke, shadow, backdrop +//! colour, colour wash, background image). Both are library preferences, saved in the library's +//! `prefs.json`; the app's slideshow (View ▸ Slideshow) plays with them. `slideshow.preview` +//! composes one slide as a PNG. -use serde_json::Value; +use lightcraft_catalog::PhotoId; +use serde_json::{Value, json}; -use super::{CommandSpec, always, bad, cmd}; +use super::{CommandSpec, always, bad, cmd, str_param}; use crate::{Result, Session}; +/// Largest side `slideshow.preview` renders. +pub const PREVIEW_MAX: usize = 4096; + fn playback(s: &mut Session, p: &Value) -> Result { let changes = p.as_object().is_some_and(|o| !o.is_empty()); if changes { @@ -19,14 +26,123 @@ fn playback(s: &mut Session, p: &Value) -> Result { Ok(s.slideshow.to_json()) } +fn layout(s: &mut Session, p: &Value) -> Result { + let changes = p.as_object().is_some_and(|o| !o.is_empty()); + if changes { + let next = s.slide_layout.merged(p).map_err(|e| bad("slideshow.layout", e))?; + if next != s.slide_layout { + s.slide_layout = next; + s.save_prefs()?; + } + } + Ok(s.slide_layout.to_json()) +} + +/// A slide size from the request: both sides given, one (the other from the aspect preview), or +/// neither (1280 wide). Each side 16–[`PREVIEW_MAX`]. +fn preview_size(s: &Session, p: &Value) -> Result<(usize, usize)> { + const C: &str = "slideshow.preview"; + let side = |k: &str| -> Result> { + match p.get(k) { + None | Some(Value::Null) => Ok(None), + Some(v) => match v.as_u64() { + Some(n) if (16..=PREVIEW_MAX as u64).contains(&n) => Ok(Some(n as usize)), + _ => Err(bad(C, format!("`{k}` must be a whole number of pixels from 16 to {PREVIEW_MAX}, not {v}"))), + }, + } + }; + let ratio = s.slide_layout.aspect.ratio(f64::NAN); + let clamp = |v: f64| (v.round() as usize).clamp(16, PREVIEW_MAX); + Ok(match (side("width")?, side("height")?) { + (Some(w), Some(h)) => (w, h), + (Some(w), None) => (w, clamp(w as f64 / ratio)), + (None, Some(h)) => (clamp(h as f64 * ratio), h), + (None, None) => (1280, clamp(1280.0 / ratio)), + }) +} + +fn preview(s: &mut Session, p: &Value) -> Result { + const C: &str = "slideshow.preview"; + let id = match p.get("id") { + None | Some(Value::Null) => s.active().ok_or_else(|| bad(C, "no photo selected (select one or pass `id`)"))?, + Some(v) => PhotoId(v.as_u64().ok_or_else(|| bad(C, format!("`id` must be a photo id, not {v}")))?), + }; + if s.catalog.photo(id).is_none() { + return Err(bad(C, format!("no photo {}", id.0))); + } + let (w, h) = preview_size(s, p)?; + let path = str_param(p, "path").map(str::trim).filter(|x| !x.is_empty()); + if let Some(path) = path { + s.check_write_target(path).map_err(|e| bad(C, e))?; + } + let settings = s.slide_layout.clone(); + let img = s.slide_now(&settings, id, w, h).map_err(crate::EngineError::Other)?; + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()) + .map_err(|e| crate::EngineError::Other(format!("PNG: {e}")))?; + let aspect = s.slide_photo_aspect(id).unwrap_or(1.5); + let l = lightcraft_slideshow::layout(&settings, aspect, img.width, img.height); + let rect = |r: lightcraft_slideshow::Rect| json!([r.x0, r.y0, r.x1, r.y1]); + let mut out = json!({ + "id": id.0, + "width": img.width, + "height": img.height, + "cell": rect(l.cell), + "photo": rect(l.visible), + }); + match path { + Some(path) => { + crate::export::write_file(path, &png).map_err(crate::EngineError::Other)?; + out["path"] = json!(path); + } + None => out["png"] = json!(base64(&png)), + } + Ok(out) +} + +/// Standard padded base64 (for an image returned in the reply). +fn base64(bytes: &[u8]) -> String { + const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); + for chunk in bytes.chunks(3) { + let b = [chunk.first().copied().unwrap_or(0), chunk.get(1).copied().unwrap_or(0), chunk.get(2).copied().unwrap_or(0)]; + let n = (b[0] as u32) << 16 | (b[1] as u32) << 8 | b[2] as u32; + let at = |shift: u32| A.get((n >> shift) as usize & 63).copied().unwrap_or(b'=') as char; + out.push(at(18)); + out.push(at(12)); + out.push(if chunk.len() > 1 { at(6) } else { '=' }); + out.push(if chunk.len() > 2 { at(0) } else { '=' }); + } + out +} + pub fn specs() -> Vec { - vec![cmd!( - "slideshow.playback", - "Slideshow Playback Settings", - [], - None, - "{slideDuration?: seconds (0.5–120), fadeDuration?: seconds (0 = cut; at most 10 and the slide duration), fadeColor?: \"#rrggbb\"|[r,g,b]|null (fade through a colour instead of crossfading), randomOrder?: bool, repeat?: bool, preparePreviews?: bool (render the next slides before they are due)} — omitted keys keep their value, {} reads them; saved with the library → {slideDuration, fadeDuration, fadeColor, randomOrder, repeat, preparePreviews}", - always, - playback - )] + vec![ + cmd!( + "slideshow.playback", + "Slideshow Playback Settings", + [], + None, + "{slideDuration?: seconds (0.5–120), fadeDuration?: seconds (0 = cut; at most 10 and the slide duration), fadeColor?: \"#rrggbb\"|[r,g,b]|null (fade through a colour instead of crossfading), randomOrder?: bool, repeat?: bool, preparePreviews?: bool (render the next slides before they are due)} — omitted keys keep their value, {} reads them; saved with the library → {slideDuration, fadeDuration, fadeColor, randomOrder, repeat, preparePreviews}", + always, + playback + ), + cmd!( + "slideshow.layout", + "Slideshow Layout Settings", + [], + None, + "{margins?: all four, marginLeft?, marginRight?, marginTop?, marginBottom?: fractions of the slide's shorter side (0–0.45; with linkMargins one sets all four), linkMargins?: bool, showGuides?: bool (margin guides in the slide editor), aspect?: \"screen\"|\"16:9\"|\"4:3\" (aspect preview), zoomToFill?: bool (crop the photo to fill its cell), strokeBorder?: bool, strokeColor?: \"#rrggbb\"|[r,g,b], strokeWidth?: fraction of the shorter side (0–0.05), castShadow?: bool, shadowOpacity?: 0–1, shadowOffset?: 0–0.1, shadowRadius?: 0–0.1 (fractions of the shorter side), shadowAngle?: degrees counter-clockwise from the right (−45 = down right), backdropColor?: colour, colorWash?: bool, washColor?: colour, washOpacity?: 0–1, washAngle?: degrees (the side the wash colour is full at), backgroundImage?: photo id|null, backgroundOpacity?: 0–1} — omitted keys keep their value, {} reads them; out-of-range numbers are clamped, wrong types refused; saved with the library → the settings", + always, + layout + ), + cmd!( + query "slideshow.preview", + "Slideshow Slide Preview", + [], + None, + "{id?: photo (default: the active one), width?, height?: pixels (16–4096; one given: the other from the aspect preview; neither: 1280 wide), path?: write the PNG here} — the photo's slide composed with the slideshow layout → {id, width, height, cell: [x0,y0,x1,y1], photo: [x0,y0,x1,y1] (the visible photo, slide pixels), path | png (base64 PNG)}", + always, + preview + ), + ] } diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index 29ea013e3..bf685c4d0 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -1386,6 +1386,7 @@ impl Session { stages: None, view_cache: None, display: None, + slide: None, }; let embedded = match (&self.media.preview_loader, c.kind) { (Some(l), MediaKind::Raw) => Some((c.path.clone(), l.clone(), edge)), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d10abd193..9d1bd5db8 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -55,6 +55,7 @@ pub mod presets; pub mod rename; pub mod segment; pub mod sidecar; +pub mod slide; pub mod slideshow; pub mod smart; pub mod sync; @@ -274,6 +275,11 @@ pub struct Session { pub cache_mb: u32, /// Slideshow playback settings (`slideshow.playback`; persisted in prefs.json). pub slideshow: slideshow::PlaybackSettings, + /// Slideshow slide layout: Layout, Options and Backdrop (`slideshow.layout`; persisted in + /// prefs.json). + pub slide_layout: slide::SlideSettings, + /// The background image the slides last rendered (shared by slide jobs: rendered once). + pub slide_backdrop: std::sync::Arc, /// Where smart previews are kept when not in the library folder (persisted in prefs.json). pub smart_previews_dir: Option, /// Untouched Local records of folders not browsed for this many days are forgotten when the @@ -357,6 +363,8 @@ impl Session { import_defaults: import::ImportDefaults::default(), cache_mb: 0, slideshow: slideshow::PlaybackSettings::default(), + slide_layout: slide::SlideSettings::default(), + slide_backdrop: Default::default(), smart_previews_dir: None, forget_local_days: lightcraft_catalog::DEFAULT_FORGET_DAYS, } @@ -910,6 +918,8 @@ mod tests_segment; #[cfg(test)] mod tests_settings_files; #[cfg(test)] +mod tests_slide; +#[cfg(test)] mod tests_spots; #[cfg(test)] mod tests_sync; diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 4e843054e..4f5695a0f 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -160,6 +160,8 @@ struct PrefsFile { forget_local_days: Option, /// Slideshow playback settings. slideshow: crate::slideshow::PlaybackSettings, + /// Slideshow slide layout (margins, options, backdrop). + slide_layout: crate::slide::SlideSettings, } fn presets_json(s: &Session) -> String { @@ -343,6 +345,7 @@ impl Session { self.import_defaults = prefs.import; self.cache_mb = prefs.cache_mb; self.slideshow = prefs.slideshow.sanitized(); + self.slide_layout = prefs.slide_layout.sanitized(); self.forget_local_days = prefs.forget_local_days.unwrap_or(lightcraft_catalog::DEFAULT_FORGET_DAYS); self.smart_previews_dir = prefs.smart_previews_dir.filter(|_| on_disk).map(PathBuf::from); if let Some(d) = &self.smart_previews_dir { @@ -580,6 +583,7 @@ impl Session { smart_previews_dir: self.smart_previews_dir.as_ref().map(|d| d.to_string_lossy().to_string()), forget_local_days: Some(self.forget_local_days), slideshow: self.slideshow.clone(), + slide_layout: self.slide_layout.clone(), }) .unwrap_or_default(); let Some(lib) = self.library.as_mut() else { return Ok(()) }; diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 6d5b548f1..8cafcf082 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -676,6 +676,8 @@ pub struct RenderJob { pub view_cache: Option<(Arc, Hash128)>, /// Render for this monitor profile ([`Self::with_display`]). pub display: Option>, + /// Compose the render into a slideshow slide ([`Self::with_slide`]). + pub slide: Option>, } pub struct RenderResult { @@ -744,7 +746,8 @@ impl RenderJob { /// Gets its own result key. Thumbnail jobs (rendered-thumbnail cache) are left as they are: /// [`crate::display::present`] converts their sRGB results. pub fn with_display(mut self, display: Option>) -> Self { - if self.cache.is_some() || self.request.depth != lightcraft_pipeline::OutputDepth::U8 { + // (a slide is composed in sRGB, backdrop colours included; the frontend presents it) + if self.cache.is_some() || self.slide.is_some() || self.request.depth != lightcraft_pipeline::OutputDepth::U8 { return self; } let k = |d: &Option>| d.as_ref().map_or(0, |d| d.id()).wrapping_mul(0xd6e8_feb8_6659_fd93); @@ -754,7 +757,30 @@ impl RenderJob { self } + /// Compose the render into a slideshow slide (layout, options, backdrop): the result is the + /// slide, not the photo. Gets its own result key; the photo's cached view preview stays the + /// photo. The slide stays sRGB ([`Self::with_display`] leaves it alone). + pub fn with_slide(mut self, slide: Arc) -> Self { + let k = |s: &Option>| s.as_ref().map_or(0, |s| s.key()).wrapping_mul(0xa24b_aed4_963e_e407); + self.key ^= k(&self.slide); + self.key ^= k(&Some(slide.clone())); + self.slide = Some(slide); + self + } + + /// [`Self::run_photo`], composed into its slide when this is a slide job. pub fn run(self) -> RenderResult { + let slide = self.slide.clone(); + let mut r = self.run_photo(); + if let (Some(slide), Ok(rendered)) = (slide, r.rendered.as_mut()) { + rendered.image = slide.compose(&rendered.image); + rendered.deep = None; + } + r + } + + /// The photo's render (without slide composition). + fn run_photo(self) -> RenderResult { if let Some((cache, key)) = &self.cache && let Some(img) = cache.get_at(self.cache_generation, *key) { @@ -1020,6 +1046,7 @@ impl crate::Session { stages: None, view_cache: None, display: None, + slide: None, }) } @@ -1071,6 +1098,7 @@ impl crate::Session { stages: None, view_cache: None, display: None, + slide: None, }) } @@ -1117,6 +1145,7 @@ impl crate::Session { stages: None, view_cache: None, display: None, + slide: None, }) } diff --git a/crates/engine/src/slide.rs b/crates/engine/src/slide.rs new file mode 100644 index 000000000..cd487d7d8 --- /dev/null +++ b/crates/engine/src/slide.rs @@ -0,0 +1,140 @@ +//! Slideshow slides in the engine: the library's slide layout ([`SlideSettings`], Lightroom +//! Classic's Layout, Options and Backdrop panels; the `slideshow.layout` command) and render jobs +//! that compose a photo into its slide ([`SlideCompose`], [`crate::media::RenderJob::with_slide`]). +//! +//! The composer itself is `lightcraft-slideshow` (no UI, no catalog): playback, the Settings +//! preview and `slideshow.preview` all compose through it, so they draw the same pixels. + +use std::sync::{Arc, Mutex, PoisonError}; + +use lightcraft_catalog::PhotoId; +use lightcraft_raster::Rgba8; +pub use lightcraft_slideshow::{Aspect, Layout, SlideSettings}; + +use crate::media::RenderJob; + +/// The last rendered background image: (its render key, the image). Shared by the slide jobs of +/// a session, so the backdrop photo is rendered once, not once per slide. +pub type BackdropCache = Mutex)>>; + +/// The background image of a slide: a library photo's render job and the shared cache it goes to. +#[derive(Clone)] +pub struct Backdrop { + pub photo: PhotoId, + job: Box, + cache: Arc, +} + +impl Backdrop { + /// The rendered background image (from the cache when it holds this render; otherwise + /// rendered now, on the calling thread). `None` when it can't be rendered (the slide then + /// shows the backdrop colour alone). + pub fn image(&self) -> Option> { + let key = self.job.key; + if let Some((k, img)) = self.cache.lock().unwrap_or_else(PoisonError::into_inner).as_ref() + && *k == key + { + return Some(img.clone()); + } + let img = Arc::new((*self.job).clone().run().rendered.ok()?.image); + *self.cache.lock().unwrap_or_else(PoisonError::into_inner) = Some((key, img.clone())); + Some(img) + } +} + +/// How to turn a photo's render into its slide: the layout, the slide's size in pixels and the +/// background image, if any. +#[derive(Clone)] +pub struct SlideCompose { + pub settings: SlideSettings, + pub width: usize, + pub height: usize, + pub backdrop: Option, +} + +impl SlideCompose { + /// What makes the slide's pixels differ (for render keys). + pub fn key(&self) -> u64 { + self.settings.pixel_key() + ^ (self.width as u64).wrapping_mul(0x9e37_79b9_7f4a_7c15) + ^ (self.height as u64).wrapping_mul(0xc2b2_ae3d_27d4_eb4f) + ^ self.backdrop.as_ref().map_or(0, |b| b.job.key.rotate_left(29)) + } + + /// The slide showing `photo` (rendered at [`lightcraft_slideshow::photo_size`], or any size). + pub fn compose(&self, photo: &Rgba8) -> Rgba8 { + let bg = self.backdrop.as_ref().and_then(Backdrop::image); + lightcraft_slideshow::compose(photo, bg.as_deref(), &self.settings, self.width, self.height) + } +} + +impl crate::Session { + /// The aspect (width / height) photo `id` shows at: cropped, straightened, lens-corrected. + pub fn slide_photo_aspect(&self, id: PhotoId) -> Option { + let p = self.catalog.photo(id)?; + let frame = lightcraft_pipeline::geometry::Frame::with_lens( + p.width.max(1) as usize, + p.height.max(1) as usize, + &p.develop, + true, + p.embedded_lens.as_ref(), + ); + let a = frame.aspect(); + Some(if a.is_finite() && a > 0.0 { a } else { 1.5 }) + } + + /// The composition of `settings` at `width × height`, with its background image's render job + /// (the photo `settings.background_image` names, when it is in the catalog). + pub fn slide_compose(&mut self, settings: &SlideSettings, width: usize, height: usize) -> Arc { + let settings = settings.clone().sanitized(); + let backdrop = settings.background_image.filter(|_| settings.background_opacity > 0.0).and_then(|bid| { + let id = PhotoId(bid); + let aspect = self.slide_photo_aspect(id)?; + let fill = + SlideSettings { zoom_to_fill: true, margin_left: 0.0, margin_right: 0.0, margin_top: 0.0, margin_bottom: 0.0, ..settings.clone() }; + let (w, h) = lightcraft_slideshow::photo_size(&fill, aspect, width, height); + let job = self.render_job(id, w, h, false, true)?; + Some(Backdrop { photo: id, job: Box::new(job), cache: self.slide_backdrop.clone() }) + }); + Arc::new(SlideCompose { settings, width, height, backdrop }) + } + + /// A render job whose result is photo `id`'s slide at `width × height` with the library's + /// slide layout: the photo is rendered at the size its cell needs (its long edge at most + /// `photo_edge`, when given), then composed on the worker. The photo's render is kept as its + /// view preview, like the loupe's. + pub fn slide_job(&mut self, id: PhotoId, width: usize, height: usize, photo_edge: Option) -> Option { + let settings = self.slide_layout.clone(); + self.slide_job_with(&settings, id, width, height, photo_edge, true) + } + + /// [`Self::slide_job`] with `settings` in place of the library's layout; `view` keeps the + /// photo's render as its view preview. + pub fn slide_job_with( + &mut self, + settings: &SlideSettings, + id: PhotoId, + width: usize, + height: usize, + photo_edge: Option, + view: bool, + ) -> Option { + let aspect = self.slide_photo_aspect(id)?; + let (mut w, mut h) = lightcraft_slideshow::photo_size(settings, aspect, width, height); + if let Some(edge) = photo_edge.filter(|e| *e > 0 && w.max(h) > *e) { + let k = edge as f64 / w.max(h) as f64; + (w, h) = (((w as f64 * k).round() as usize).max(1), ((h as f64 * k).round() as usize).max(1)); + } + let job = if view { self.loupe_job(id, w.max(8), h.max(8), true)? } else { self.render_job(id, w.max(8), h.max(8), false, true)? }; + let compose = self.slide_compose(settings, width, height); + Some(job.with_slide(compose)) + } + + /// Photo `id`'s slide at `width × height` with `settings`, rendered now on this thread. + pub fn slide_now(&mut self, settings: &SlideSettings, id: PhotoId, width: usize, height: usize) -> Result { + let job = self.slide_job_with(settings, id, width, height, None, false).ok_or("no such photo")?; + let r = job.run(); + self.accept(&r); + r.rendered.map(|r| r.image) + } +} diff --git a/crates/engine/src/tests_slide.rs b/crates/engine/src/tests_slide.rs new file mode 100644 index 000000000..84a8b5274 --- /dev/null +++ b/crates/engine/src/tests_slide.rs @@ -0,0 +1,139 @@ +//! Slideshow slides: `slideshow.layout` (validated, saved with the library) and +//! `slideshow.preview` (a slide composed with the layout, as a PNG). + +use std::path::PathBuf; + +use serde_json::json; + +use crate::Session; + +fn temp_dir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("lc-slide-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +fn png_size(bytes: &[u8]) -> (u32, u32) { + assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n", "a PNG"); + let be = |o: usize| u32::from_be_bytes([bytes[o], bytes[o + 1], bytes[o + 2], bytes[o + 3]]); + (be(16), be(20)) +} + +fn base64_decode(text: &str) -> Vec { + const A: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let (mut out, mut acc, mut bits) = (Vec::new(), 0u32, 0u32); + for c in text.bytes().take_while(|c| *c != b'=') { + acc = (acc << 6) | A.iter().position(|a| *a == c).unwrap() as u32; + bits += 6; + if bits >= 8 { + bits -= 8; + out.push((acc >> bits) as u8); + acc &= (1 << bits) - 1; + } + } + out +} + +#[test] +fn slide_layout_is_validated_and_persists_with_the_library() { + let lib = temp_dir("layout"); + { + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + let r = s.execute("slideshow.layout", &json!({})).unwrap(); + assert_eq!(r["marginLeft"], 0.0, "defaults: {r}"); + assert_eq!(r["backdropColor"], "#000000"); + let r = s + .execute( + "slideshow.layout", + &json!({"marginTop": 0.1, "strokeBorder": true, "strokeColor": [255, 255, 255], "castShadow": true, "backdropColor": "#203040", "aspect": "4:3"}), + ) + .unwrap(); + assert_eq!((r["marginLeft"].as_f64(), r["marginBottom"].as_f64()), (Some(0.1), Some(0.1)), "linked margins: {r}"); + assert_eq!(r["strokeColor"], "#ffffff"); + // a bad value changes nothing (the good keys beside it included) + for bad in [json!({"marginLeft": "wide", "zoomToFill": true}), json!({"backdropColor": "red"}), json!({"glitter": 1})] { + assert!(s.execute("slideshow.layout", &bad).is_err(), "{bad}"); + } + assert!(!s.slide_layout.zoom_to_fill); + // out of range: clamped + let r = s.execute("slideshow.layout", &json!({"linkMargins": false, "marginRight": 9, "shadowOpacity": -1})).unwrap(); + assert_eq!((r["marginRight"].as_f64(), r["marginLeft"].as_f64(), r["shadowOpacity"].as_f64()), (Some(0.45), Some(0.1), Some(0.0))); + drop(s); + } + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + let r = s.execute("slideshow.layout", &json!({})).unwrap(); + assert_eq!((r["marginRight"].as_f64(), r["aspect"].as_str(), r["backdropColor"].as_str()), (Some(0.45), Some("4:3"), Some("#203040"))); + assert_eq!(r["linkMargins"], false); + drop(s); + // hand-edited values out of range are clamped when the library opens + std::fs::write(lib.join("prefs.json"), r#"{"slideLayout": {"marginTop": 1e9, "shadowAngle": 720, "strokeWidth": -3}}"#).unwrap(); + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + assert_eq!((s.slide_layout.margin_top, s.slide_layout.shadow_angle, s.slide_layout.stroke_width), (0.45, 0.0, 0.0)); + // and the playback settings beside them are untouched by a layout change + let before = s.slideshow.clone(); + s.execute("slideshow.layout", &json!({"zoomToFill": true})).unwrap(); + assert_eq!(s.slideshow, before); + drop(s); + let _ = std::fs::remove_dir_all(&lib); +} + +#[test] +fn slide_preview_returns_the_composed_slide_at_the_asked_size() { + let mut s = Session::with_demo(); + let photos = s.visible_cloned(); + s.execute("library.select", &json!({"ids": [photos[0].0]})).unwrap(); + s.execute("slideshow.layout", &json!({"margins": 0.1, "backdropColor": "#ff0000"})).unwrap(); + let r = s.execute("slideshow.preview", &json!({"width": 320, "height": 200})).unwrap(); + assert_eq!((r["width"].as_u64(), r["height"].as_u64(), r["id"].as_u64()), (Some(320), Some(200), Some(photos[0].0))); + assert_eq!(r["cell"], json!([20.0, 20.0, 300.0, 180.0]), "10 % of the 200 px short side"); + let png = base64_decode(r["png"].as_str().unwrap()); + assert_eq!(png_size(&png), (320, 200)); + // written to a file instead + let dir = temp_dir("preview"); + let path = dir.join("slide.png"); + let r = s.execute("slideshow.preview", &json!({"id": photos[1].0, "width": 240, "path": path.to_string_lossy()})).unwrap(); + assert_eq!(r["path"], path.to_string_lossy().as_ref()); + assert!(r.get("png").is_none()); + assert_eq!(png_size(&std::fs::read(&path).unwrap()), (240, 150), "the height from the aspect preview (screen: 16:10)"); + s.execute("slideshow.layout", &json!({"aspect": "16:9"})).unwrap(); + let r = s.execute("slideshow.preview", &json!({"height": 90})).unwrap(); + assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(160), Some(90))); + // the pixels: red margins, the photo inside + let img = s.slide_now(&s.slide_layout.clone(), photos[0], 320, 200).unwrap(); + assert_eq!((img.width, img.height), (320, 200)); + assert_eq!(img.get(3, 3), [255, 0, 0, 255]); + assert_eq!(img.get(160, 196), [255, 0, 0, 255]); + assert_ne!(img.get(160, 100), [255, 0, 0, 255], "the photo in the cell"); + // refused: bad sizes, a photo that isn't there + for bad in [json!({"width": 4}), json!({"height": 100000}), json!({"width": "big"}), json!({"id": 999_999}), json!({"id": "one"})] { + assert!(s.execute("slideshow.preview", &bad).is_err(), "{bad}"); + } + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_background_image_and_the_slide_key() { + let mut s = Session::with_demo(); + let photos = s.visible_cloned(); + let plain = s.slide_layout.clone(); + let job = s.slide_job(photos[0], 320, 200, None).unwrap(); + let loupe = s.loupe_job(photos[0], 320, 200, true).unwrap(); + assert_ne!(job.key, loupe.key, "a slide is not the photo's render"); + // a background image fills the slide around the photo + let bg = + s.slide_layout.merged(&json!({"margins": 0.2, "backdropColor": "#ff0000", "backgroundImage": photos[2].0, "backgroundOpacity": 1})).unwrap(); + let img = s.slide_now(&bg, photos[0], 320, 200).unwrap(); + assert_ne!(img.get(3, 3), [255, 0, 0, 255], "the background photo, not the colour"); + // it is rendered once and reused + assert!(s.slide_backdrop.lock().unwrap().is_some()); + let other = s.slide_job_with(&bg, photos[1], 320, 200, None, false).unwrap(); + assert_ne!(other.key, s.slide_job_with(&plain, photos[1], 320, 200, None, false).unwrap().key); + // a background photo that isn't in the catalog: the colour alone + let gone = bg.merged(&json!({"backgroundImage": 987_654})).unwrap(); + let img = s.slide_now(&gone, photos[0], 320, 200).unwrap(); + assert_eq!(img.get(3, 3), [255, 0, 0, 255]); +} diff --git a/crates/slideshow/Cargo.toml b/crates/slideshow/Cargo.toml new file mode 100644 index 000000000..15cda43c1 --- /dev/null +++ b/crates/slideshow/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "lightcraft-slideshow" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "LightCraft slideshow slides: layout, options and backdrop settings and the CPU slide composer (L1)" + +[lints] +workspace = true + +[dependencies] +lightcraft-raster = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } diff --git a/crates/slideshow/src/compose.rs b/crates/slideshow/src/compose.rs new file mode 100644 index 000000000..998e591c9 --- /dev/null +++ b/crates/slideshow/src/compose.rs @@ -0,0 +1,513 @@ +//! The slide composer: [`layout`] (where the image cell, the photo, its frame and shadow go) and +//! [`compose`] (the slide's pixels, on the CPU). +//! +//! Drawing order: backdrop colour → background image (at its opacity, filling the slide) → colour +//! wash → cast shadow → stroke border → photo. The stroke is drawn around the outside of the +//! photo (the photo keeps its size); the shadow falls from the photo with its stroke. + +use lightcraft_raster::blur::gaussian; +use lightcraft_raster::resample::{Filter, resize}; +use lightcraft_raster::{Image, Plane, Rgba8, par_rows}; + +use crate::settings::{MAX_SIDE, SlideSettings}; + +/// A rectangle in slide pixels (`x0 ≤ x1`, `y0 ≤ y1`). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Rect { + pub x0: f64, + pub y0: f64, + pub x1: f64, + pub y1: f64, +} + +impl Rect { + pub fn width(&self) -> f64 { + self.x1 - self.x0 + } + pub fn height(&self) -> f64 { + self.y1 - self.y0 + } + fn intersect(&self, o: &Rect) -> Rect { + let (x0, y0) = (self.x0.max(o.x0), self.y0.max(o.y0)); + Rect { x0, y0, x1: self.x1.min(o.x1).max(x0), y1: self.y1.min(o.y1).max(y0) } + } + fn expand(&self, d: f64) -> Rect { + Rect { x0: self.x0 - d, y0: self.y0 - d, x1: self.x1 + d, y1: self.y1 + d } + } + fn shift(&self, dx: f64, dy: f64) -> Rect { + Rect { x0: self.x0 + dx, y0: self.y0 + dy, x1: self.x1 + dx, y1: self.y1 + dy } + } + /// Whole pixel bounds clipped to `w × h`: (x0, y0, x1, y1). + fn pixels(&self, w: usize, h: usize) -> (usize, usize, usize, usize) { + let c = |v: f64, max: usize| if v.is_finite() { v.round().clamp(0.0, max as f64) as usize } else { 0 }; + let (x0, y0) = (c(self.x0, w), c(self.y0, h)); + (x0, y0, c(self.x1, w).max(x0), c(self.y1, h).max(y0)) + } +} + +/// Where a slide's parts go, in slide pixels. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Layout { + /// The slide. + pub slide: Rect, + /// The image cell: the slide inside its margins. + pub cell: Rect, + /// The whole photo, scaled (with zoom to fill it extends past the cell). + pub photo: Rect, + /// The part of the photo that shows (the photo inside the cell). + pub visible: Rect, + /// The visible photo with its stroke border (the visible photo when there is none). + pub frame: Rect, + /// The rectangle that casts the shadow (the frame, offset), and the blur's sigma in pixels. + pub shadow: Option<(Rect, f64)>, +} + +/// A usable aspect: a non-finite, zero or absurd one takes `fallback`. +fn sane_aspect(a: f64, fallback: f64) -> f64 { + if a.is_finite() && a > 1e-3 && a < 1e3 { a } else { fallback } +} + +/// The slide size `out_w × out_h` limited to [`MAX_SIDE`] (proportionally) and at least 1 × 1. +fn slide_size(out_w: usize, out_h: usize) -> (usize, usize) { + let (w, h) = (out_w.max(1), out_h.max(1)); + let long = w.max(h); + if long <= MAX_SIDE { + return (w, h); + } + let k = MAX_SIDE as f64 / long as f64; + (((w as f64 * k).round() as usize).clamp(1, MAX_SIDE), ((h as f64 * k).round() as usize).clamp(1, MAX_SIDE)) +} + +/// The layout of a `out_w × out_h` slide showing a photo of aspect `photo_aspect` (width / +/// height; a bad one fills the cell). +pub fn layout(s: &SlideSettings, photo_aspect: f64, out_w: usize, out_h: usize) -> Layout { + let s = s.clone().sanitized(); + let (w, h) = slide_size(out_w, out_h); + let (wf, hf) = (w as f64, h as f64); + let short = wf.min(hf); + // margins in pixels; when they leave no room (a tiny slide), they shrink to leave one pixel + let fit = |a: f64, b: f64, room: f64| { + let (a, b) = ((a * short).round(), (b * short).round()); + if a + b <= room - 1.0 { + (a, b) + } else { + let k = ((room - 1.0).max(0.0)) / (a + b).max(1e-9); + ((a * k).floor(), (b * k).floor()) + } + }; + let (ml, mr) = fit(s.margin_left, s.margin_right, wf); + let (mt, mb) = fit(s.margin_top, s.margin_bottom, hf); + let slide = Rect { x0: 0.0, y0: 0.0, x1: wf, y1: hf }; + let cell = Rect { x0: ml, y0: mt, x1: wf - mr, y1: hf - mb }; + let (cw, ch) = (cell.width().max(1.0), cell.height().max(1.0)); + let a = sane_aspect(photo_aspect, cw / ch); + let wider = a > cw / ch; + // fit: the photo's longer side (relative to the cell) meets the cell; fill: the shorter + let (dw, dh) = if wider != s.zoom_to_fill { (cw, cw / a) } else { (ch * a, ch) }; + let (dw, dh) = (dw.round().max(1.0), dh.round().max(1.0)); + let x0 = (cell.x0 + (cw - dw) / 2.0).round(); + let y0 = (cell.y0 + (ch - dh) / 2.0).round(); + let photo = Rect { x0, y0, x1: x0 + dw, y1: y0 + dh }; + let visible = photo.intersect(&cell); + let stroke = if s.stroke_border && s.stroke_width > 0.0 { (s.stroke_width * short).round().max(1.0) } else { 0.0 }; + let frame = visible.expand(stroke).intersect(&slide); + let shadow = (s.cast_shadow && s.shadow_opacity > 0.0).then(|| { + let (d, t) = (s.shadow_offset * short, s.shadow_angle.to_radians()); + // counter-clockwise from the right, y down on screen + (frame.shift(d * t.cos(), -d * t.sin()), s.shadow_radius * short / 2.0) + }); + Layout { slide, cell, photo, visible, frame, shadow } +} + +/// The size to render a photo of aspect `photo_aspect` at for a `out_w × out_h` slide: the +/// photo's whole size in the slide (with zoom to fill, larger than its cell), at most 4× the +/// slide's long side. +pub fn photo_size(s: &SlideSettings, photo_aspect: f64, out_w: usize, out_h: usize) -> (usize, usize) { + let l = layout(s, photo_aspect, out_w, out_h); + let (w, h) = (l.photo.width().max(1.0), l.photo.height().max(1.0)); + let cap = 4.0 * l.slide.width().max(l.slide.height()); + let k = (cap / w.max(h)).min(1.0); + (((w * k).round() as usize).max(1), ((h * k).round() as usize).max(1)) +} + +fn mix(a: u8, b: u8, t: f32) -> u8 { + (a as f32 + (b as f32 - a as f32) * t).round().clamp(0.0, 255.0) as u8 +} + +fn mix_px(dst: &mut [u8; 4], src: [u8; 3], t: f32) { + for (d, s) in dst.iter_mut().zip(src) { + *d = mix(*d, s, t); + } + dst[3] = 255; +} + +/// Draw `src` scaled to `dest` (slide pixels), only where it meets `clip`, at `opacity`. +/// Only the part of `src` that shows is resampled (a zoomed-to-fill panorama stays cheap). +fn draw_image(out: &mut Rgba8, src: &Rgba8, dest: &Rect, clip: &Rect, opacity: f32) { + if src.width == 0 || src.height == 0 || src.data.len() != src.width * src.height || opacity <= 0.0 { + return; + } + let (w, h) = (out.width, out.height); + let (vx0, vy0, vx1, vy1) = dest.intersect(clip).pixels(w, h); + let (vw, vh) = (vx1 - vx0, vy1 - vy0); + if vw == 0 || vh == 0 || dest.width() <= 0.0 || dest.height() <= 0.0 { + return; + } + // the source pixels behind the visible part + let (sx, sy) = (src.width as f64 / dest.width(), src.height as f64 / dest.height()); + let to_src = |v: f64, origin: f64, k: f64, max: usize| ((v - origin) * k).clamp(0.0, max as f64); + let (s0x, s1x) = (to_src(vx0 as f64, dest.x0, sx, src.width).floor(), to_src(vx1 as f64, dest.x0, sx, src.width).ceil()); + let (s0y, s1y) = (to_src(vy0 as f64, dest.y0, sy, src.height).floor(), to_src(vy1 as f64, dest.y0, sy, src.height).ceil()); + let (cx, cy) = (s0x as usize, s0y as usize); + let (cw, ch) = (((s1x - s0x) as usize).max(1), ((s1y - s0y) as usize).max(1)); + let part = if (cx, cy, cw, ch) == (0, 0, src.width, src.height) { None } else { Some(src.crop(cx, cy, cw, ch)) }; + let part = part.as_ref().unwrap_or(src); + let scaled: Rgba8 = if (part.width, part.height) == (vw, vh) { + part.clone() + } else { + let f = part.map(|p| [p[0] as f32, p[1] as f32, p[2] as f32, p[3] as f32]); + let filter = if vw < part.width { Filter::Lanczos3 } else { Filter::Mitchell }; + resize(&f, vw, vh, filter).map(|p| [0, 1, 2, 3].map(|i| p[i].round().clamp(0.0, 255.0) as u8)) + }; + for (row, y) in (vy0..vy1).enumerate() { + let (Some(dst), Some(s)) = (out.data.get_mut(y * w + vx0..y * w + vx1), scaled.data.get(row * vw..(row + 1) * vw)) else { + continue; + }; + for (d, p) in dst.iter_mut().zip(s) { + let a = opacity * p[3] as f32 / 255.0; + mix_px(d, [p[0], p[1], p[2]], a); + } + } +} + +/// Fill `r` with `c`. +fn fill(out: &mut Rgba8, r: &Rect, c: [u8; 3]) { + let w = out.width; + let (x0, y0, x1, y1) = r.pixels(out.width, out.height); + for y in y0..y1 { + if let Some(row) = out.data.get_mut(y * w + x0..y * w + x1) { + row.fill([c[0], c[1], c[2], 255]); + } + } +} + +/// A 1-D blurred step: 1 inside `[a, b)`, blurred with `sigma` (pixels), over `0..n`. +fn blurred_step(n: usize, a: f64, b: f64, sigma: f64) -> Vec { + let inside = Plane::from_fn(n, 1, |x, _| { + let c = x as f64 + 0.5; + if c >= a && c < b { 1.0 } else { 0.0 } + }); + gaussian(&inside, sigma as f32).data +} + +/// The slide: `photo` (the developed photo, any size: it is scaled into its cell) and the +/// backdrop image (any size; it fills the slide) composed with `s` at `out_w × out_h` pixels +/// (limited to [`MAX_SIDE`] a side, at least 1 × 1). An empty `photo` draws the backdrop alone. +pub fn compose(photo: &Rgba8, backdrop_image: Option<&Rgba8>, s: &SlideSettings, out_w: usize, out_h: usize) -> Rgba8 { + let s = s.clone().sanitized(); + let (w, h) = slide_size(out_w, out_h); + let has_photo = photo.width > 0 && photo.height > 0 && photo.data.len() == photo.width * photo.height; + let aspect = if has_photo { photo.width as f64 / photo.height as f64 } else { f64::NAN }; + let l = layout(&s, aspect, w, h); + let c = s.backdrop_color; + let mut out = Image::filled(w, h, [c[0], c[1], c[2], 255]); + // background image: the photo filling the slide, at its opacity + if let Some(bg) = backdrop_image.filter(|_| s.background_image.is_some() && s.background_opacity > 0.0) + && bg.width > 0 + && bg.height > 0 + { + let a = bg.width as f64 / bg.height as f64; + let fill_all = SlideSettings { zoom_to_fill: true, margin_left: 0.0, margin_right: 0.0, margin_top: 0.0, margin_bottom: 0.0, ..s.clone() }; + let bl = layout(&fill_all, a, w, h); + draw_image(&mut out, bg, &bl.photo, &bl.slide, s.background_opacity as f32); + } + // colour wash: full wash colour at the side the angle points to, none at the opposite side + if s.color_wash && s.wash_opacity > 0.0 { + let t = s.wash_angle.to_radians(); + let (dx, dy) = (t.cos(), -t.sin()); + let (cx, cy) = (w as f64 / 2.0, h as f64 / 2.0); + let extent = (cx * dx.abs() + cy * dy.abs()).max(1e-9); + let (wc, op) = (s.wash_color, s.wash_opacity); + par_rows(&mut out.data, w, |y, row| { + let py = y as f64 + 0.5 - cy; + for (x, p) in row.iter_mut().enumerate() { + let proj = (x as f64 + 0.5 - cx) * dx + py * dy; + let k = ((proj / extent + 1.0) / 2.0).clamp(0.0, 1.0); + mix_px(p, wc, (k * op) as f32); + } + }); + } + // cast shadow: a blurred rectangle (separable: the product of two blurred steps) + if let Some((r, sigma)) = l.shadow { + let op = s.shadow_opacity as f32; + let fx = blurred_step(w, r.x0, r.x1, sigma); + let fy = blurred_step(h, r.y0, r.y1, sigma); + par_rows(&mut out.data, w, |y, row| { + let ky = fy.get(y).copied().unwrap_or(0.0); + if ky <= 1e-4 { + return; + } + for (p, kx) in row.iter_mut().zip(&fx) { + let a = op * ky * kx; + if a > 1e-4 { + mix_px(p, [0, 0, 0], a); + } + } + }); + } + if l.frame != l.visible { + fill(&mut out, &l.frame, s.stroke_color); + } + if has_photo { + draw_image(&mut out, photo, &l.photo, &l.cell, 1.0); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::settings::{MARGIN_MAX, SHADOW_OFFSET_MAX, SHADOW_RADIUS_MAX, STROKE_MAX}; + use serde_json::json; + + fn solid(w: usize, h: usize, c: [u8; 3]) -> Rgba8 { + Image::filled(w, h, [c[0], c[1], c[2], 255]) + } + + fn with(p: serde_json::Value) -> SlideSettings { + SlideSettings::default().merged(&p).unwrap() + } + + fn px(img: &Rgba8, x: usize, y: usize) -> [u8; 3] { + let p = img.get(x, y); + [p[0], p[1], p[2]] + } + + const RED: [u8; 3] = [255, 0, 0]; + const GREEN: [u8; 3] = [0, 200, 0]; + + #[test] + fn margins_make_the_cell_and_the_photo_fits_inside_it() { + // 1000 × 500 slide, 10 % margins (of the 500 px short side = 50 px) + let s = with(json!({"margins": 0.1})); + let l = layout(&s, 1.0, 1000, 500); + assert_eq!(l.cell, Rect { x0: 50.0, y0: 50.0, x1: 950.0, y1: 450.0 }); + // a square photo fits the 900 × 400 cell's height, centred + assert_eq!(l.photo, Rect { x0: 300.0, y0: 50.0, x1: 700.0, y1: 450.0 }); + assert_eq!(l.visible, l.photo); + // independent margins + let s = with(json!({"linkMargins": false, "marginLeft": 0.2, "marginTop": 0.0, "marginRight": 0.0, "marginBottom": 0.4})); + let l = layout(&s, 3.0, 1000, 500); + assert_eq!(l.cell, Rect { x0: 100.0, y0: 0.0, x1: 1000.0, y1: 300.0 }); + assert_eq!((l.photo.width(), l.photo.height()), (900.0, 300.0), "3:1 fits 900 × 300 exactly"); + + let img = compose(&solid(64, 64, GREEN), None, &with(json!({"margins": 0.1, "backdropColor": "#ff0000"})), 1000, 500); + assert_eq!((img.width, img.height), (1000, 500)); + assert_eq!(px(&img, 10, 10), RED, "margin: backdrop"); + assert_eq!(px(&img, 500, 250), GREEN, "cell centre: photo"); + assert_eq!(px(&img, 290, 250), RED, "beside a fitted square photo: backdrop"); + assert_eq!(px(&img, 310, 60), GREEN); + } + + #[test] + fn zoom_to_fill_covers_the_cell_and_crops() { + let s = with(json!({"margins": 0.1, "zoomToFill": true})); + let l = layout(&s, 1.0, 1000, 500); + assert_eq!((l.photo.width(), l.photo.height()), (900.0, 900.0), "the square photo covers the 900 × 400 cell"); + assert_eq!(l.visible, l.cell, "and shows only inside it"); + assert_eq!(photo_size(&s, 1.0, 1000, 500), (900, 900), "rendered whole at the covering size"); + // a gradient photo: zoomed to fill, its middle rows show, its top and bottom are cut + let photo = Rgba8::from_fn(100, 100, |_, y| [(y * 2) as u8, 0, 0, 255]); + let img = compose(&photo, None, &s, 1000, 500); + assert_eq!(px(&img, 500, 10), [0, 0, 0], "margin stays the backdrop"); + assert_eq!(px(&img, 60, 250), px(&img, 940, 250), "covers the cell edge to edge"); + let top = img.get(500, 51)[0]; + assert!(top > 40, "the photo's top rows are cropped away ({top})"); + // fit draws the whole photo (its first rows near black) + let img = compose(&photo, None, &with(json!({"margins": 0.1})), 1000, 500); + assert!(img.get(500, 51)[0] < 6); + } + + #[test] + fn aspect_preview_ratios() { + use crate::settings::Aspect; + let s = with(json!({"aspect": "4:3"})); + let r = s.aspect.ratio(2.0); + assert!((r - 4.0 / 3.0).abs() < 1e-12); + assert_eq!(with(json!({"aspect": "screen"})).aspect.ratio(2.0), 2.0); + assert_eq!(Aspect::parse("16:9"), Some(Aspect::Wide)); + // a 4:3 slide of a 3:2 photo letterboxes top and bottom + let l = layout(&s, 1.5, 800, 600); + assert_eq!((l.photo.width(), l.photo.height(), l.photo.y0), (800.0, 533.0, 34.0)); + } + + #[test] + fn stroke_surrounds_the_photo_and_shadow_falls_at_its_angle() { + let s = with(json!({"margins": 0.2, "strokeBorder": true, "strokeWidth": 0.02, "strokeColor": "#ffffff"})); + let l = layout(&s, 1.0, 1000, 1000); + assert_eq!(l.visible, Rect { x0: 200.0, y0: 200.0, x1: 800.0, y1: 800.0 }); + assert_eq!(l.frame, Rect { x0: 180.0, y0: 180.0, x1: 820.0, y1: 820.0 }, "20 px outside the photo"); + let img = compose(&solid(10, 10, GREEN), None, &s, 1000, 1000); + assert_eq!(px(&img, 190, 500), [255, 255, 255], "stroke left"); + assert_eq!(px(&img, 810, 810), [255, 255, 255], "stroke corner"); + assert_eq!(px(&img, 175, 500), [0, 0, 0], "outside the stroke"); + assert_eq!(px(&img, 205, 500), GREEN, "the photo keeps its size"); + + // shadow at −45° (down-right) by 5 %, hard edged (radius 0), opaque + let s = with( + json!({"margins": 0.2, "castShadow": true, "shadowOffset": 0.05, "shadowAngle": -45, "shadowRadius": 0, "shadowOpacity": 1, "backdropColor": "#ffffff"}), + ); + let l = layout(&s, 1.0, 1000, 1000); + let (r, _) = l.shadow.unwrap(); + let d = 50.0 / 2f64.sqrt(); + assert!((r.x0 - (200.0 + d)).abs() < 1e-9 && (r.y0 - (200.0 + d)).abs() < 1e-9, "{r:?}"); + let img = compose(&solid(10, 10, GREEN), None, &s, 1000, 1000); + assert_eq!(px(&img, 820, 820), [0, 0, 0], "shadow below right"); + assert_eq!(px(&img, 180, 180), [255, 255, 255], "no shadow above left"); + assert_eq!(px(&img, 850, 500), [255, 255, 255], "shadow ends at its offset"); + // at 90° the shadow goes straight up; a soft radius spreads it but fades out + let s = with( + json!({"margins": 0.2, "castShadow": true, "shadowOffset": 0.05, "shadowAngle": 90, "shadowRadius": 0.04, "shadowOpacity": 0.8, "backdropColor": "#ffffff"}), + ); + let img = compose(&solid(10, 10, GREEN), None, &s, 1000, 1000); + assert!(img.get(500, 170)[0] < 200, "above: in shadow"); + assert_eq!(px(&img, 500, 950), [255, 255, 255], "below: none"); + assert_eq!(px(&img, 500, 50), [255, 255, 255], "beyond the blur: none"); + let mid = img.get(500, 151)[0]; + assert!(mid > 60 && mid < 250, "a soft edge ({mid})"); + } + + #[test] + fn colour_wash_follows_its_angle() { + let wash = |angle: f64| { + compose( + &Rgba8::new(0, 0), + None, + &with(json!({"colorWash": true, "washColor": "#ffffff", "washOpacity": 1, "washAngle": angle})), + 200, + 100, + ) + }; + // 0°: white at the right, black at the left + let img = wash(0.0); + assert!(img.get(199, 50)[0] > 250 && img.get(0, 50)[0] < 5); + assert!((img.get(100, 50)[0] as i32 - 128).abs() < 3, "half way in the middle"); + assert_eq!(img.get(150, 0), img.get(150, 99), "constant along the other axis"); + // 90°: white at the top + let img = wash(90.0); + assert!(img.get(100, 0)[0] > 245 && img.get(100, 99)[0] < 10); + // 180°: white at the left + assert!(wash(180.0).get(0, 50)[0] > 250); + // −45°: bottom-right is the brightest corner + let img = wash(-45.0); + assert!(img.get(199, 99)[0] > 250 && img.get(0, 0)[0] < 5); + // opacity scales it + let img = + compose(&Rgba8::new(0, 0), None, &with(json!({"colorWash": true, "washColor": "#ffffff", "washOpacity": 0.5, "washAngle": 0})), 200, 100); + assert!((img.get(199, 50)[0] as i32 - 127).abs() < 3); + } + + #[test] + fn background_image_fills_the_slide_at_its_opacity() { + let bg = solid(30, 10, [200, 100, 0]); + let s = with(json!({"backgroundImage": 5, "backgroundOpacity": 0.5, "backdropColor": "#000000", "margins": 0.25})); + let img = compose(&solid(4, 4, GREEN), Some(&bg), &s, 400, 200); + assert_eq!(px(&img, 2, 2), [100, 50, 0], "half the image over black"); + assert_eq!(px(&img, 398, 198), [100, 50, 0], "fills the whole slide"); + assert_eq!(px(&img, 200, 100), GREEN, "the photo on top"); + // off, or no image given: the backdrop colour + let off = SlideSettings { background_image: None, ..s.clone() }; + assert_eq!(px(&compose(&solid(4, 4, GREEN), Some(&bg), &off, 400, 200), 2, 2), [0, 0, 0]); + assert_eq!(px(&compose(&solid(4, 4, GREEN), None, &s, 400, 200), 2, 2), [0, 0, 0]); + } + + #[test] + fn layouts_are_resolution_independent() { + let s = with( + json!({"linkMargins": false, "marginLeft": 0.1, "marginTop": 0.05, "marginRight": 0.2, "marginBottom": 0.08, "strokeBorder": true, "strokeWidth": 0.01, "castShadow": true, "shadowOffset": 0.02}), + ); + let a = layout(&s, 1.5, 800, 450); + let b = layout(&s, 1.5, 1600, 900); + for (ra, rb) in [(a.cell, b.cell), (a.photo, b.photo), (a.frame, b.frame), (a.shadow.unwrap().0, b.shadow.unwrap().0)] { + for (va, vb) in [(ra.x0, rb.x0), (ra.y0, rb.y0), (ra.x1, rb.x1), (ra.y1, rb.y1)] { + assert!((va * 2.0 - vb).abs() <= 2.0, "{ra:?} vs {rb:?}"); + } + } + assert!((a.shadow.unwrap().1 * 2.0 - b.shadow.unwrap().1).abs() < 1e-9); + // and the pixels agree: the big slide, halved, is the small one (away from edges) + let photo = Rgba8::from_fn(60, 40, |x, y| [(x * 4) as u8, (y * 6) as u8, 90, 255]); + let small = compose(&photo, None, &s, 800, 450); + let big = compose(&photo, None, &s, 1600, 900); + for (x, y) in [(10, 10), (400, 225), (300, 100), (700, 400)] { + let (p, q) = (small.get(x, y), big.get(x * 2, y * 2)); + assert!((0..3).all(|i| (p[i] as i32 - q[i] as i32).abs() < 24), "({x}, {y}): {p:?} vs {q:?}"); + } + } + + #[test] + fn the_default_slide_is_the_photo_fitted_on_black() { + let photo = Rgba8::from_fn(300, 200, |x, y| [x as u8, y as u8, 7, 255]); + let s = SlideSettings::default(); + assert!(s.is_plain()); + // a 3:2 photo on a 16:10 screen: the photo at its own pixels, black bars beside it + assert_eq!(photo_size(&s, 1.5, 320, 200), (300, 200)); + let img = compose(&photo, None, &s, 320, 200); + assert_eq!(px(&img, 5, 100), [0, 0, 0]); + assert_eq!(px(&img, 314, 100), [0, 0, 0]); + for (x, y) in [(0, 0), (150, 100), (299, 199)] { + assert_eq!(img.get(x + 10, y), photo.get(x, y), "unchanged pixels at ({x}, {y})"); + } + } + + #[test] + fn hostile_input_never_panics() { + let wild = SlideSettings { + margin_left: f64::NAN, + margin_right: 1e300, + margin_top: -5.0, + margin_bottom: f64::INFINITY, + stroke_border: true, + stroke_width: 1e9, + cast_shadow: true, + shadow_opacity: f64::NAN, + shadow_offset: -1.0, + shadow_radius: f64::INFINITY, + shadow_angle: f64::NAN, + color_wash: true, + wash_opacity: 9.0, + wash_angle: f64::INFINITY, + background_image: Some(u64::MAX), + background_opacity: f64::NEG_INFINITY, + zoom_to_fill: true, + ..SlideSettings::default() + }; + let photos = [Rgba8::new(0, 0), Rgba8::new(1, 1), Rgba8::new(5000, 1), Rgba8::new(1, 3000), Rgba8 { width: 4, height: 4, data: vec![] }]; + for photo in &photos { + for (w, h) in [(0, 0), (1, 1), (3, 2), (1, 400), (100_000, 2), (64, 48)] { + let img = compose(photo, Some(&Rgba8::new(0, 5)), &wild, w, h); + assert!(img.width >= 1 && img.height >= 1 && img.width <= MAX_SIDE && img.height <= MAX_SIDE); + assert_eq!(img.data.len(), img.width * img.height); + let (pw, ph) = photo_size(&wild, photo.width as f64 / photo.height.max(1) as f64, w, h); + assert!(pw >= 1 && ph >= 1 && pw <= 4 * MAX_SIDE && ph <= 4 * MAX_SIDE); + } + } + for a in [f64::NAN, 0.0, -1.0, f64::INFINITY, 1e-300, 1e300] { + let l = layout(&wild, a, 640, 480); + assert!(l.photo.width() >= 1.0 && l.photo.width().is_finite()); + } + // the clamps: biggest margins still leave a cell, biggest stroke and shadow stay bounded + let s = SlideSettings { + margin_left: MARGIN_MAX, + margin_right: MARGIN_MAX, + stroke_border: true, + stroke_width: STROKE_MAX, + cast_shadow: true, + shadow_offset: SHADOW_OFFSET_MAX, + shadow_radius: SHADOW_RADIUS_MAX, + ..SlideSettings::default() + }; + let l = layout(&s, 1.0, 100, 100); + assert!(l.cell.width() >= 1.0, "{l:?}"); + assert!(l.frame.x0 >= 0.0 && l.frame.x1 <= 100.0, "the stroke stays on the slide"); + } +} diff --git a/crates/slideshow/src/lib.rs b/crates/slideshow/src/lib.rs new file mode 100644 index 000000000..4d61716a5 --- /dev/null +++ b/crates/slideshow/src/lib.rs @@ -0,0 +1,23 @@ +//! Slideshow slides for LightCraft: the settings of Lightroom Classic's Slideshow Layout, Options +//! and Backdrop panels ([`SlideSettings`]) and the CPU composer that draws one slide from a +//! rendered photo ([`compose`]). +//! +//! No UI and no catalog here: playback, the settings preview, the `slideshow.preview` command and +//! (later) slideshow export all call [`compose`], so every one of them draws the same pixels. +//! +//! **Resolution independence:** every length is relative to the slide: margins, the stroke width +//! and the shadow's offset and radius are fractions of the slide's *shorter side* (so linked +//! margins are equal in pixels, and a slide composed at two sizes has the same proportions). +//! Angles are degrees counter-clockwise from the right (−45° points down and to the right). +//! +//! **Never crash:** settings are validated by [`SlideSettings::merged`] (a wrong type is an error) +//! and clamped by [`SlideSettings::sanitized`] (NaN, ±∞, negative and huge values land in range); +//! [`compose`] accepts any size and any photo (an empty one draws the backdrop alone). +#![forbid(unsafe_code)] +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +mod compose; +mod settings; + +pub use compose::{Layout, Rect, compose, layout, photo_size}; +pub use settings::{Aspect, MARGIN_MAX, MAX_SIDE, SHADOW_OFFSET_MAX, SHADOW_RADIUS_MAX, STROKE_MAX, SlideSettings, hex, parse_color}; diff --git a/crates/slideshow/src/settings.rs b/crates/slideshow/src/settings.rs new file mode 100644 index 000000000..6fb004bf6 --- /dev/null +++ b/crates/slideshow/src/settings.rs @@ -0,0 +1,401 @@ +//! [`SlideSettings`]: Layout (margins, guides, aspect preview), Options (zoom to fill, stroke +//! border, cast shadow) and Backdrop (colour, colour wash, background image). + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +/// Largest margin (fraction of the slide's shorter side). +pub const MARGIN_MAX: f64 = 0.45; +/// Widest stroke border (fraction of the slide's shorter side). +pub const STROKE_MAX: f64 = 0.05; +/// Longest shadow offset (fraction of the slide's shorter side). +pub const SHADOW_OFFSET_MAX: f64 = 0.1; +/// Largest shadow radius (fraction of the slide's shorter side). +pub const SHADOW_RADIUS_MAX: f64 = 0.1; +/// Largest slide side [`crate::compose`] draws (bigger requests are scaled down to it). +pub const MAX_SIDE: usize = 8192; + +/// The aspect the slide is previewed at (Lightroom Classic's Aspect Preview). Playback always +/// fills the screen; the settings preview and `slideshow.preview` use this. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum Aspect { + /// The screen's own aspect. + #[default] + #[serde(rename = "screen")] + Screen, + #[serde(rename = "16:9")] + Wide, + #[serde(rename = "4:3")] + Standard, +} + +impl Aspect { + pub const ALL: [Aspect; 3] = [Aspect::Screen, Aspect::Wide, Aspect::Standard]; + + pub fn id(self) -> &'static str { + match self { + Aspect::Screen => "screen", + Aspect::Wide => "16:9", + Aspect::Standard => "4:3", + } + } + + pub fn parse(s: &str) -> Option { + Aspect::ALL.into_iter().find(|a| a.id().eq_ignore_ascii_case(s.trim())) + } + + /// Width / height, given the screen's (used for [`Aspect::Screen`]; a bad one gives 16:10). + pub fn ratio(self, screen: f64) -> f64 { + match self { + Aspect::Screen if screen.is_finite() && screen > 0.05 && screen < 20.0 => screen, + Aspect::Screen => 1.6, + Aspect::Wide => 16.0 / 9.0, + Aspect::Standard => 4.0 / 3.0, + } + } +} + +/// How a slide looks (everything but the photo). Lengths are fractions of the slide's shorter +/// side, angles degrees counter-clockwise from the right, opacities 0–1. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct SlideSettings { + // Layout + pub margin_left: f64, + pub margin_right: f64, + pub margin_top: f64, + pub margin_bottom: f64, + /// Changing one margin changes all four. + pub link_margins: bool, + /// Show the margin guides in the slide editor (never in playback or export). + pub show_guides: bool, + pub aspect: Aspect, + // Options + /// Crop the photo to fill its cell (off: the whole photo fits inside it). + pub zoom_to_fill: bool, + pub stroke_border: bool, + pub stroke_color: [u8; 3], + pub stroke_width: f64, + pub cast_shadow: bool, + pub shadow_opacity: f64, + pub shadow_offset: f64, + pub shadow_radius: f64, + pub shadow_angle: f64, + // Backdrop + pub backdrop_color: [u8; 3], + /// A gradient from `wash_color` (full at the side `wash_angle` points to) to the backdrop. + pub color_wash: bool, + pub wash_color: [u8; 3], + pub wash_opacity: f64, + pub wash_angle: f64, + /// A library photo (its id) drawn over the backdrop colour, filling the slide. + pub background_image: Option, + pub background_opacity: f64, +} + +impl Default for SlideSettings { + fn default() -> Self { + SlideSettings { + margin_left: 0.0, + margin_right: 0.0, + margin_top: 0.0, + margin_bottom: 0.0, + link_margins: true, + show_guides: true, + aspect: Aspect::Screen, + zoom_to_fill: false, + stroke_border: false, + stroke_color: [255, 255, 255], + stroke_width: 0.004, + cast_shadow: false, + shadow_opacity: 0.4, + shadow_offset: 0.012, + shadow_radius: 0.02, + shadow_angle: -45.0, + backdrop_color: [0, 0, 0], + color_wash: false, + wash_color: [96, 96, 96], + wash_opacity: 0.6, + wash_angle: 90.0, + background_image: None, + background_opacity: 0.5, + } + } +} + +/// The keys [`SlideSettings::merged`] takes (for error messages and docs). +const KEYS: &str = "margins, marginLeft, marginRight, marginTop, marginBottom, linkMargins, showGuides, aspect, zoomToFill, strokeBorder, strokeColor, strokeWidth, castShadow, shadowOpacity, shadowOffset, shadowRadius, shadowAngle, backdropColor, colorWash, washColor, washOpacity, washAngle, backgroundImage, backgroundOpacity"; + +fn clamp_or(v: f64, lo: f64, hi: f64, fallback: f64) -> f64 { + if v.is_finite() { v.clamp(lo, hi) } else { fallback } +} + +/// An angle in degrees into (−180, 180]; non-finite → `fallback`. +fn wrap_angle(a: f64, fallback: f64) -> f64 { + if !a.is_finite() { + return fallback; + } + let w = (a + 180.0).rem_euclid(360.0) - 180.0; + if w <= -180.0 { 180.0 } else { w } +} + +impl SlideSettings { + /// The settings with every value in range: lengths and opacities clamped, angles wrapped, + /// non-finite numbers replaced by the defaults. + pub fn sanitized(mut self) -> Self { + let d = SlideSettings::default(); + for m in [&mut self.margin_left, &mut self.margin_right, &mut self.margin_top, &mut self.margin_bottom] { + *m = clamp_or(*m, 0.0, MARGIN_MAX, 0.0); + } + self.stroke_width = clamp_or(self.stroke_width, 0.0, STROKE_MAX, d.stroke_width); + self.shadow_opacity = clamp_or(self.shadow_opacity, 0.0, 1.0, d.shadow_opacity); + self.shadow_offset = clamp_or(self.shadow_offset, 0.0, SHADOW_OFFSET_MAX, d.shadow_offset); + self.shadow_radius = clamp_or(self.shadow_radius, 0.0, SHADOW_RADIUS_MAX, d.shadow_radius); + self.shadow_angle = wrap_angle(self.shadow_angle, d.shadow_angle); + self.wash_opacity = clamp_or(self.wash_opacity, 0.0, 1.0, d.wash_opacity); + self.wash_angle = wrap_angle(self.wash_angle, d.wash_angle); + self.background_opacity = clamp_or(self.background_opacity, 0.0, 1.0, d.background_opacity); + self + } + + /// Does a slide with these settings show nothing but the photo fitted on black (the plain + /// slideshow)? Settings that are switched on but draw nothing (a 0-wide stroke, a shadow at + /// 0 % opacity) count as plain. + pub fn is_plain(&self) -> bool { + let s = self.clone().sanitized(); + s.margin_left == 0.0 + && s.margin_right == 0.0 + && s.margin_top == 0.0 + && s.margin_bottom == 0.0 + && !s.zoom_to_fill + && !(s.stroke_border && s.stroke_width > 0.0) + && !(s.cast_shadow && s.shadow_opacity > 0.0) + && s.backdrop_color == [0, 0, 0] + && !(s.color_wash && s.wash_opacity > 0.0) + && !(s.background_image.is_some() && s.background_opacity > 0.0) + } + + /// A hash of what changes the slide's pixels (guides and the aspect preview don't), for + /// render keys. Stable within a run. + pub fn pixel_key(&self) -> u64 { + use std::hash::{Hash, Hasher}; + let mut s = self.clone().sanitized(); + s.show_guides = false; + s.aspect = Aspect::Screen; + s.link_margins = false; + let mut h = std::collections::hash_map::DefaultHasher::new(); + s.to_json().to_string().hash(&mut h); + h.finish() + } + + pub fn to_json(&self) -> Value { + json!({ + "marginLeft": self.margin_left, + "marginRight": self.margin_right, + "marginTop": self.margin_top, + "marginBottom": self.margin_bottom, + "linkMargins": self.link_margins, + "showGuides": self.show_guides, + "aspect": self.aspect.id(), + "zoomToFill": self.zoom_to_fill, + "strokeBorder": self.stroke_border, + "strokeColor": hex(self.stroke_color), + "strokeWidth": self.stroke_width, + "castShadow": self.cast_shadow, + "shadowOpacity": self.shadow_opacity, + "shadowOffset": self.shadow_offset, + "shadowRadius": self.shadow_radius, + "shadowAngle": self.shadow_angle, + "backdropColor": hex(self.backdrop_color), + "colorWash": self.color_wash, + "washColor": hex(self.wash_color), + "washOpacity": self.wash_opacity, + "washAngle": self.wash_angle, + "backgroundImage": self.background_image, + "backgroundOpacity": self.background_opacity, + }) + } + + /// These settings with the keys `p` names changed (the others kept), validated and clamped. + /// A wrong type, an unknown key or a non-finite number is an error, and nothing changes. + /// With `linkMargins` on (already, or set in `p`), a margin given sets all four; `margins` + /// sets all four in any case. + pub fn merged(&self, p: &Value) -> Result { + let Some(obj) = p.as_object() else { return Err("parameters must be an object".into()) }; + let mut s = self.clone(); + let mut margin: Option = None; + for (k, v) in obj { + match k.as_str() { + "margins" => { + let m = number(k, v)?; + (s.margin_left, s.margin_right, s.margin_top, s.margin_bottom) = (m, m, m, m); + } + "marginLeft" => s.margin_left = *margin.insert(number(k, v)?), + "marginRight" => s.margin_right = *margin.insert(number(k, v)?), + "marginTop" => s.margin_top = *margin.insert(number(k, v)?), + "marginBottom" => s.margin_bottom = *margin.insert(number(k, v)?), + "linkMargins" => s.link_margins = boolean(k, v)?, + "showGuides" => s.show_guides = boolean(k, v)?, + "aspect" => { + s.aspect = + v.as_str().and_then(Aspect::parse).ok_or_else(|| format!("`aspect` must be \"screen\", \"16:9\" or \"4:3\", not {v}"))? + } + "zoomToFill" => s.zoom_to_fill = boolean(k, v)?, + "strokeBorder" => s.stroke_border = boolean(k, v)?, + "strokeColor" => s.stroke_color = color(k, v)?, + "strokeWidth" => s.stroke_width = number(k, v)?, + "castShadow" => s.cast_shadow = boolean(k, v)?, + "shadowOpacity" => s.shadow_opacity = number(k, v)?, + "shadowOffset" => s.shadow_offset = number(k, v)?, + "shadowRadius" => s.shadow_radius = number(k, v)?, + "shadowAngle" => s.shadow_angle = number(k, v)?, + "backdropColor" => s.backdrop_color = color(k, v)?, + "colorWash" => s.color_wash = boolean(k, v)?, + "washColor" => s.wash_color = color(k, v)?, + "washOpacity" => s.wash_opacity = number(k, v)?, + "washAngle" => s.wash_angle = number(k, v)?, + "backgroundImage" => { + s.background_image = match v { + Value::Null => None, + _ => Some(v.as_u64().ok_or_else(|| format!("`backgroundImage` must be a photo id or null, not {v}"))?), + } + } + "backgroundOpacity" => s.background_opacity = number(k, v)?, + other => return Err(format!("unknown setting `{other}` ({KEYS})")), + } + } + if s.link_margins + && let Some(m) = margin + { + (s.margin_left, s.margin_right, s.margin_top, s.margin_bottom) = (m, m, m, m); + } + Ok(s.sanitized()) + } +} + +fn number(k: &str, v: &Value) -> Result { + match v.as_f64() { + Some(x) if x.is_finite() => Ok(x), + _ => Err(format!("`{k}` must be a finite number, not {v}")), + } +} + +fn boolean(k: &str, v: &Value) -> Result { + v.as_bool().ok_or_else(|| format!("`{k}` must be true or false, not {v}")) +} + +fn color(k: &str, v: &Value) -> Result<[u8; 3], String> { + parse_color(v).ok_or_else(|| format!("`{k}` must be \"#rrggbb\" or [r, g, b] (0–255), not {v}")) +} + +/// `"#rrggbb"` (the `#` optional) or `[r, g, b]` with 0–255 components. +pub fn parse_color(v: &Value) -> Option<[u8; 3]> { + match v { + Value::String(s) => { + let h = s.trim().strip_prefix('#').unwrap_or(s.trim()); + if h.len() != 6 || !h.is_ascii() { + return None; + } + let byte = |i: usize| h.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok()); + Some([byte(0)?, byte(2)?, byte(4)?]) + } + Value::Array(a) if a.len() == 3 => { + let mut c = [0u8; 3]; + for (o, x) in c.iter_mut().zip(a) { + *o = u8::try_from(x.as_u64()?).ok()?; + } + Some(c) + } + _ => None, + } +} + +/// `#rrggbb`. +pub fn hex(c: [u8; 3]) -> String { + format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn merge_validates_links_margins_and_round_trips() { + let d = SlideSettings::default(); + assert!(d.is_plain(), "the default slide is the plain slideshow"); + let s = d.merged(&json!({"marginLeft": 0.1})).unwrap(); + assert_eq!((s.margin_left, s.margin_right, s.margin_top, s.margin_bottom), (0.1, 0.1, 0.1, 0.1), "linked: all four"); + let s = d.merged(&json!({"linkMargins": false, "marginTop": 0.2})).unwrap(); + assert_eq!((s.margin_left, s.margin_top), (0.0, 0.2), "unlinked: one"); + let s = s.merged(&json!({"margins": 0.05})).unwrap(); + assert_eq!((s.margin_left, s.margin_bottom), (0.05, 0.05)); + let s = d + .merged(&json!({"strokeBorder": true, "strokeColor": "#FF8000", "washColor": [1, 2, 3], "aspect": "16:9", "backgroundImage": 7})) + .unwrap(); + assert_eq!(s.stroke_color, [255, 128, 0]); + assert_eq!(s.wash_color, [1, 2, 3]); + assert_eq!(s.aspect, Aspect::Wide); + assert_eq!(s.background_image, Some(7)); + assert!(!s.is_plain()); + // the JSON form reads back as the same settings + assert_eq!(d.merged(&s.to_json()).unwrap(), s); + // serde (prefs.json) round trip, and missing keys take the defaults + let back: SlideSettings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap(); + assert_eq!(back, s); + let partial: SlideSettings = serde_json::from_str(r#"{"marginTop": 0.3}"#).unwrap(); + assert_eq!(partial, SlideSettings { margin_top: 0.3, ..d.clone() }); + assert_eq!(d.merged(&json!({"backgroundImage": null})).unwrap().background_image, None); + } + + #[test] + fn hostile_settings_are_rejected_or_clamped() { + let d = SlideSettings::default(); + for bad in [ + json!({"marginLeft": "wide"}), + json!({"margins": null}), + json!({"zoomToFill": 1}), + json!({"strokeColor": "#12345"}), + json!({"strokeColor": "#ééééé"}), + json!({"washColor": [256, 0, 0]}), + json!({"washColor": [-1, 0, 0]}), + json!({"backdropColor": [1, 2]}), + json!({"aspect": "21:9"}), + json!({"backgroundImage": -3}), + json!({"backgroundImage": "photo"}), + json!({"sparkle": true}), + json!([1]), + ] { + assert!(d.merged(&bad).is_err(), "{bad}"); + } + let s = d + .merged(&json!({"margins": 1e300, "strokeWidth": -2, "shadowOpacity": 7, "shadowOffset": 1e9, "shadowRadius": -1, "shadowAngle": 540, "washAngle": -190, "washOpacity": -0.5})) + .unwrap(); + assert_eq!((s.margin_left, s.margin_top), (MARGIN_MAX, MARGIN_MAX)); + assert_eq!((s.stroke_width, s.shadow_opacity, s.shadow_offset, s.shadow_radius), (0.0, 1.0, SHADOW_OFFSET_MAX, 0.0)); + assert_eq!((s.shadow_angle, s.wash_angle, s.wash_opacity), (180.0, 170.0, 0.0)); + // NaN / ±∞ (a hand-edited prefs file, an in-process caller) take the defaults + let s = SlideSettings { + margin_left: f64::NAN, + stroke_width: f64::INFINITY, + shadow_angle: f64::NEG_INFINITY, + background_opacity: f64::NAN, + ..d.clone() + } + .sanitized(); + assert_eq!( + (s.margin_left, s.stroke_width, s.shadow_angle, s.background_opacity), + (0.0, d.stroke_width, d.shadow_angle, d.background_opacity) + ); + assert_eq!(Aspect::Screen.ratio(f64::NAN), 1.6); + assert_eq!(Aspect::Screen.ratio(1.5), 1.5); + assert!((Aspect::Wide.ratio(1.0) - 16.0 / 9.0).abs() < 1e-12); + } + + #[test] + fn pixel_key_ignores_what_does_not_change_pixels() { + let d = SlideSettings::default(); + let guides = SlideSettings { show_guides: false, aspect: Aspect::Standard, ..d.clone() }; + assert_eq!(d.pixel_key(), guides.pixel_key()); + assert_ne!(d.pixel_key(), SlideSettings { backdrop_color: [9, 9, 9], ..d.clone() }.pixel_key()); + } +} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index f7dbc65ab..f68aad329 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -18,6 +18,7 @@ lightcraft-pipeline = { workspace = true } lightcraft-catalog = { workspace = true } web-time = { workspace = true } lightcraft-engine = { workspace = true } +lightcraft-slideshow = { workspace = true } lightcraft-preview = { workspace = true } egui = { workspace = true } serde = { workspace = true } diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index aa24e0f10..fa4855fe3 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2031,5 +2031,31 @@ "Downloading model": "Modell wird heruntergeladen", "Finding faces": "Gesichter werden gesucht", "Synchronizing folder": "Ordner wird synchronisiert", - "Looking for changes": "Änderungen werden gesucht" + "Looking for changes": "Änderungen werden gesucht", + "Playback": "Wiedergabe", + "Layout": "Layout", + "Link all margins": "Alle Ränder verknüpfen", + "Margins": "Ränder", + "Left": "Links", + "Right": "Rechts", + "Top": "Oben", + "Bottom": "Unten", + "Margins are a share of the slide's shorter side, so slides look the same on any screen.": "Ränder sind ein Anteil der kürzeren Seite der Folie, damit Folien auf jedem Bildschirm gleich aussehen.", + "Show guides": "Hilfslinien anzeigen", + "Aspect preview": "Seitenverhältnis-Vorschau", + "Zoom to fill frame": "Zoomen, um Rahmen zu füllen", + "Stroke border": "Konturlinie", + "Colour": "Farbe", + "Cast shadow": "Schlagschatten", + "Offset": "Versatz", + "Backdrop": "Hintergrund", + "Background colour": "Hintergrundfarbe", + "Colour wash": "Farbverlauf", + "Background image": "Hintergrundbild", + "(not in the library)": "(nicht in der Bibliothek)", + "Use selected photo": "Ausgewähltes Foto verwenden", + "Select a photo, then turn this on to fill the slide behind every photo with it.": "Wählen Sie ein Foto aus und schalten Sie dies ein, um die Folie hinter jedem Foto damit zu füllen.", + "Reset layout": "Layout zurücksetzen", + "Select a photo to preview its slide": "Wählen Sie ein Foto, um seine Folie in der Vorschau zu sehen", + "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "Das ausgewählte Foto als Folie. Die Wiedergabe füllt den Bildschirm; die Seitenverhältnis-Vorschau ändert nur diese Vorschau." } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index c4f2e8357..4ab5578fe 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1876,5 +1876,31 @@ "Downloading model": "Descargando modelo", "Finding faces": "Buscando caras", "Synchronizing folder": "Sincronizando carpeta", - "Looking for changes": "Buscando cambios" + "Looking for changes": "Buscando cambios", + "Playback": "Reproducción", + "Layout": "Diseño", + "Link all margins": "Vincular todos los márgenes", + "Margins": "Márgenes", + "Left": "Izquierda", + "Right": "Derecha", + "Top": "Arriba", + "Bottom": "Abajo", + "Margins are a share of the slide's shorter side, so slides look the same on any screen.": "Los márgenes son una parte del lado más corto de la diapositiva, así que las diapositivas se ven igual en cualquier pantalla.", + "Show guides": "Mostrar guías", + "Aspect preview": "Vista previa de proporción", + "Zoom to fill frame": "Ampliar para llenar el marco", + "Stroke border": "Borde de trazo", + "Colour": "Color", + "Cast shadow": "Proyectar sombra", + "Offset": "Desplazamiento", + "Backdrop": "Fondo", + "Background colour": "Color de fondo", + "Colour wash": "Degradado de color", + "Background image": "Imagen de fondo", + "(not in the library)": "(no está en la biblioteca)", + "Use selected photo": "Usar la foto seleccionada", + "Select a photo, then turn this on to fill the slide behind every photo with it.": "Selecciona una foto y activa esta opción para llenar con ella el fondo detrás de cada foto.", + "Reset layout": "Restablecer diseño", + "Select a photo to preview its slide": "Selecciona una foto para previsualizar su diapositiva", + "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "La foto seleccionada como diapositiva. La reproducción llena la pantalla; la vista previa de proporción solo cambia esta vista previa." } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index f3422bc0a..e456530b5 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1876,5 +1876,31 @@ "Downloading model": "Téléchargement du modèle", "Finding faces": "Recherche des visages", "Synchronizing folder": "Synchronisation du dossier", - "Looking for changes": "Recherche des modifications" + "Looking for changes": "Recherche des modifications", + "Playback": "Lecture", + "Layout": "Disposition", + "Link all margins": "Lier toutes les marges", + "Margins": "Marges", + "Left": "Gauche", + "Right": "Droite", + "Top": "Haut", + "Bottom": "Bas", + "Margins are a share of the slide's shorter side, so slides look the same on any screen.": "Les marges sont une part du petit côté de la diapositive : les diapositives sont identiques sur tous les écrans.", + "Show guides": "Afficher les repères", + "Aspect preview": "Aperçu des proportions", + "Zoom to fill frame": "Zoom pour remplir le cadre", + "Stroke border": "Contour", + "Colour": "Couleur", + "Cast shadow": "Ombre portée", + "Offset": "Décalage", + "Backdrop": "Toile de fond", + "Background colour": "Couleur d’arrière-plan", + "Colour wash": "Dégradé de couleur", + "Background image": "Image d’arrière-plan", + "(not in the library)": "(absente de la bibliothèque)", + "Use selected photo": "Utiliser la photo sélectionnée", + "Select a photo, then turn this on to fill the slide behind every photo with it.": "Sélectionnez une photo, puis activez cette option pour en remplir le fond derrière chaque photo.", + "Reset layout": "Réinitialiser la disposition", + "Select a photo to preview its slide": "Sélectionnez une photo pour prévisualiser sa diapositive", + "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "La photo sélectionnée en diapositive. La lecture remplit l’écran ; l’aperçu des proportions ne change que cet aperçu." } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index a7a3a32ef..a8da442e4 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1878,5 +1878,31 @@ "Downloading model": "モデルをダウンロード中", "Finding faces": "顔を検索中", "Synchronizing folder": "フォルダを同期中", - "Looking for changes": "変更を確認中" + "Looking for changes": "変更を確認中", + "Playback": "再生", + "Layout": "レイアウト", + "Link all margins": "すべての余白をリンク", + "Margins": "余白", + "Left": "左", + "Right": "右", + "Top": "上", + "Bottom": "下", + "Margins are a share of the slide's shorter side, so slides look the same on any screen.": "余白はスライドの短辺に対する割合なので、どの画面でもスライドは同じように見えます。", + "Show guides": "ガイドを表示", + "Aspect preview": "縦横比プレビュー", + "Zoom to fill frame": "ズームして枠を埋める", + "Stroke border": "境界線", + "Colour": "カラー", + "Cast shadow": "ドロップシャドウ", + "Offset": "オフセット", + "Backdrop": "背景", + "Background colour": "背景色", + "Colour wash": "カラーウォッシュ", + "Background image": "背景画像", + "(not in the library)": "(ライブラリにありません)", + "Use selected photo": "選択した写真を使用", + "Select a photo, then turn this on to fill the slide behind every photo with it.": "写真を選択してからこれをオンにすると、各写真の背後をその写真で埋めます。", + "Reset layout": "レイアウトをリセット", + "Select a photo to preview its slide": "写真を選択するとスライドをプレビューできます", + "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "選択した写真をスライドとして表示します。再生では画面全体に表示され、縦横比プレビューはこのプレビューだけを変更します。" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index c3d9a8d78..e811a1be1 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1876,5 +1876,31 @@ "Downloading model": "Baixando modelo", "Finding faces": "Procurando rostos", "Synchronizing folder": "Sincronizando pasta", - "Looking for changes": "Procurando alterações" + "Looking for changes": "Procurando alterações", + "Playback": "Reprodução", + "Layout": "Layout", + "Link all margins": "Vincular todas as margens", + "Margins": "Margens", + "Left": "Esquerda", + "Right": "Direita", + "Top": "Superior", + "Bottom": "Inferior", + "Margins are a share of the slide's shorter side, so slides look the same on any screen.": "As margens são uma parte do lado menor do slide, então os slides ficam iguais em qualquer tela.", + "Show guides": "Mostrar guias", + "Aspect preview": "Visualização da proporção", + "Zoom to fill frame": "Zoom para preencher o quadro", + "Stroke border": "Borda", + "Colour": "Cor", + "Cast shadow": "Projetar sombra", + "Offset": "Deslocamento", + "Backdrop": "Fundo", + "Background colour": "Cor de fundo", + "Colour wash": "Degradê de cor", + "Background image": "Imagem de fundo", + "(not in the library)": "(não está na biblioteca)", + "Use selected photo": "Usar a foto selecionada", + "Select a photo, then turn this on to fill the slide behind every photo with it.": "Selecione uma foto e ative esta opção para preencher com ela o fundo atrás de cada foto.", + "Reset layout": "Redefinir layout", + "Select a photo to preview its slide": "Selecione uma foto para visualizar o slide dela", + "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "A foto selecionada como slide. A reprodução preenche a tela; a visualização da proporção muda só esta visualização." } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index bf9974740..2685f8609 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1590,5 +1590,31 @@ "Finding faces": "Поиск лиц", "Quit Anyway": "Всё равно выйти", "Synchronizing folder": "Синхронизация папки", - "Looking for changes": "Поиск изменений" + "Looking for changes": "Поиск изменений", + "Playback": "Воспроизведение", + "Layout": "Макет", + "Link all margins": "Связать все поля", + "Margins": "Поля", + "Left": "Слева", + "Right": "Справа", + "Top": "Сверху", + "Bottom": "Снизу", + "Margins are a share of the slide's shorter side, so slides look the same on any screen.": "Поля задаются долей короткой стороны слайда, поэтому слайды выглядят одинаково на любом экране.", + "Show guides": "Показывать направляющие", + "Aspect preview": "Предпросмотр пропорций", + "Zoom to fill frame": "Масштабировать по рамке", + "Stroke border": "Обводка", + "Colour": "Цвет", + "Cast shadow": "Отбрасывать тень", + "Offset": "Смещение", + "Backdrop": "Фон", + "Background colour": "Цвет фона", + "Colour wash": "Цветовой градиент", + "Background image": "Фоновое изображение", + "(not in the library)": "(нет в библиотеке)", + "Use selected photo": "Использовать выбранное фото", + "Select a photo, then turn this on to fill the slide behind every photo with it.": "Выберите фото и включите этот параметр, чтобы заполнить им фон за каждым фото.", + "Reset layout": "Сбросить макет", + "Select a photo to preview its slide": "Выберите фото, чтобы увидеть его слайд", + "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "Выбранное фото в виде слайда. При воспроизведении слайд заполняет экран; предпросмотр пропорций меняет только этот предпросмотр." } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index c73c05b93..e3ddc10ae 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2070,5 +2070,31 @@ "Downloading model": "Завантаження моделі", "Finding faces": "Пошук облич", "Synchronizing folder": "Синхронізація папки", - "Looking for changes": "Пошук змін" + "Looking for changes": "Пошук змін", + "Playback": "Відтворення", + "Layout": "Макет", + "Link all margins": "Зв’язати всі поля", + "Margins": "Поля", + "Left": "Ліворуч", + "Right": "Праворуч", + "Top": "Угорі", + "Bottom": "Унизу", + "Margins are a share of the slide's shorter side, so slides look the same on any screen.": "Поля задаються часткою короткої сторони слайда, тож слайди виглядають однаково на будь-якому екрані.", + "Show guides": "Показувати напрямні", + "Aspect preview": "Попередній перегляд пропорцій", + "Zoom to fill frame": "Масштабувати за рамкою", + "Stroke border": "Обведення", + "Colour": "Колір", + "Cast shadow": "Відкидати тінь", + "Offset": "Зсув", + "Backdrop": "Тло", + "Background colour": "Колір тла", + "Colour wash": "Кольоровий градієнт", + "Background image": "Фонове зображення", + "(not in the library)": "(немає в бібліотеці)", + "Use selected photo": "Використати вибране фото", + "Select a photo, then turn this on to fill the slide behind every photo with it.": "Виберіть фото й увімкніть цей параметр, щоб заповнити ним тло за кожним фото.", + "Reset layout": "Скинути макет", + "Select a photo to preview its slide": "Виберіть фото, щоб переглянути його слайд", + "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "Вибране фото як слайд. Під час відтворення слайд заповнює екран; попередній перегляд пропорцій змінює лише цей перегляд." } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 21fa4e4e9..065494810 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1876,5 +1876,31 @@ "Downloading model": "正在下载模型", "Finding faces": "正在查找人脸", "Synchronizing folder": "正在同步文件夹", - "Looking for changes": "正在查找更改" + "Looking for changes": "正在查找更改", + "Playback": "播放", + "Layout": "布局", + "Link all margins": "链接所有边距", + "Margins": "边距", + "Left": "左", + "Right": "右", + "Top": "上", + "Bottom": "下", + "Margins are a share of the slide's shorter side, so slides look the same on any screen.": "边距按幻灯片短边的比例设置,因此幻灯片在任何屏幕上看起来都一样。", + "Show guides": "显示参考线", + "Aspect preview": "长宽比预览", + "Zoom to fill frame": "缩放以填充框架", + "Stroke border": "描边", + "Colour": "颜色", + "Cast shadow": "投影", + "Offset": "偏移", + "Backdrop": "背景", + "Background colour": "背景颜色", + "Colour wash": "颜色渐变", + "Background image": "背景图像", + "(not in the library)": "(不在图库中)", + "Use selected photo": "使用所选照片", + "Select a photo, then turn this on to fill the slide behind every photo with it.": "选择一张照片,然后打开此项,用它填充每张照片后面的幻灯片背景。", + "Reset layout": "重置布局", + "Select a photo to preview its slide": "选择一张照片以预览其幻灯片", + "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "将所选照片显示为幻灯片。播放时会填满屏幕;长宽比预览只改变此预览。" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index d2e0a30c2..27802e68e 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1876,5 +1876,31 @@ "Downloading model": "正在下載模型", "Finding faces": "正在尋找臉孔", "Synchronizing folder": "正在同步資料夾", - "Looking for changes": "正在尋找變更" + "Looking for changes": "正在尋找變更", + "Playback": "播放", + "Layout": "版面", + "Link all margins": "連結所有邊界", + "Margins": "邊界", + "Left": "左", + "Right": "右", + "Top": "上", + "Bottom": "下", + "Margins are a share of the slide's shorter side, so slides look the same on any screen.": "邊界以投影片短邊的比例設定,因此投影片在任何螢幕上看起來都一樣。", + "Show guides": "顯示參考線", + "Aspect preview": "長寬比預覽", + "Zoom to fill frame": "縮放以填滿框架", + "Stroke border": "描邊", + "Colour": "顏色", + "Cast shadow": "投射陰影", + "Offset": "偏移", + "Backdrop": "背景", + "Background colour": "背景顏色", + "Colour wash": "顏色漸層", + "Background image": "背景影像", + "(not in the library)": "(不在圖庫中)", + "Use selected photo": "使用所選照片", + "Select a photo, then turn this on to fill the slide behind every photo with it.": "選取一張照片,然後開啟此項,以它填滿每張照片後方的投影片背景。", + "Reset layout": "重設版面", + "Select a photo to preview its slide": "選取一張照片以預覽其投影片", + "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "將所選照片顯示為投影片。播放時會填滿螢幕;長寬比預覽只會改變此預覽。" } diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 0652414c1..3d40f9cc1 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -27,6 +27,7 @@ pub mod right; pub mod rules_editor; pub mod second; pub mod settings; +pub mod slide_layout; pub mod slideshow; pub mod strip; pub mod topbar; diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 7569f4a3c..2be60a4a5 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -68,7 +68,7 @@ pub(super) fn heading(ui: &mut egui::Ui, t: &Tokens, text: &str) { ui.label(RichText::new(crate::i18n::tr(text)).font(t.semibold(12.5)).color(t.text)); } -fn row(ui: &mut egui::Ui, t: &Tokens, label: &str, add: impl FnOnce(&mut egui::Ui) -> R) -> R { +pub(super) fn row(ui: &mut egui::Ui, t: &Tokens, label: &str, add: impl FnOnce(&mut egui::Ui) -> R) -> R { ui.horizontal(|ui| { ui.allocate_ui_with_layout(egui::vec2(LABEL_W, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { ui.set_min_width(LABEL_W); @@ -642,7 +642,32 @@ fn duration_slider(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, key: &s } } +/// Width of the Slideshow tab's settings column (the slide preview is beside it). +const SLIDESHOW_COLUMN_W: f32 = 480.0; + fn slideshow_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + // the settings scroll on the left, the composed slide stays in view on the right + let top_down = egui::Layout::top_down(egui::Align::Min); + // as tall as the window allows (the dialog's title, tabs and Close button take ~260 pt) + let tall = (ui.ctx().content_rect().height() - 260.0).clamp(300.0, 760.0); + ui.horizontal_top(|ui| { + ui.allocate_ui_with_layout(egui::vec2(SLIDESHOW_COLUMN_W, tall), top_down, |ui| { + ui.set_width(SLIDESHOW_COLUMN_W); + egui::ScrollArea::vertical().id_salt("settingsSlideshowScroll").max_height(tall).auto_shrink([false, true]).show(ui, |ui| { + playback_section(app, ui, t); + ui.add_space(6.0); + super::slide_layout::sections(app, ui, t); + }); + }); + ui.add_space(12.0); + ui.allocate_ui_with_layout(egui::vec2(super::slide_layout::PREVIEW_W, tall), top_down, |ui| { + ui.set_width(super::slide_layout::PREVIEW_W); + super::slide_layout::preview(app, ui, t) + }); + }); +} + +fn playback_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { use lightcraft_engine::slideshow::{FADE_MAX, SLIDE_MAX, SLIDE_MIN}; heading(ui, t, crate::i18n::tr("Playback")); let s = app.session.slideshow.clone(); diff --git a/crates/ui-egui/src/panels/slide_layout.rs b/crates/ui-egui/src/panels/slide_layout.rs new file mode 100644 index 000000000..c0e03844c --- /dev/null +++ b/crates/ui-egui/src/panels/slide_layout.rs @@ -0,0 +1,228 @@ +//! Settings ▸ Slideshow: the slide's Layout, Options and Backdrop sections (Lightroom Classic's +//! Slideshow panels of the same names) and a small live preview of the composed slide. +//! +//! Every control goes through `slideshow.layout` (validated there, saved with the library). +//! Sliders change the layout in memory while dragged (the preview follows) and save it when let +//! go. The preview is the active photo's slide rendered off the UI thread ([`Slot::SlidePreview`]) +//! by the same composer playback uses; it is requested once the layout has been still for +//! [`DEBOUNCE`] seconds, so a drag doesn't queue a render per frame. + +use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2}; +use lightcraft_engine::slide::{Aspect, SlideSettings}; +use lightcraft_slideshow::{MARGIN_MAX, SHADOW_OFFSET_MAX, SHADOW_RADIUS_MAX, STROKE_MAX}; +use serde_json::{Value, json}; + +use super::settings::{check, choices, heading, hint, row}; +use crate::LightcraftApp; +use crate::render::Slot; +use crate::theme::Tokens; +use crate::widgets::register; + +/// Seconds the layout must stay unchanged before the preview is rendered again. +pub const DEBOUNCE: f64 = 0.15; +/// The preview's width in points. +pub const PREVIEW_W: f32 = 300.0; + +#[derive(Clone, Copy)] +enum Unit { + /// A fraction shown as a percentage (of the slide's shorter side, or an opacity). + Percent, + Degrees, +} + +fn set(app: &mut LightcraftApp, p: Value) { + let _ = app.run("slideshow.layout", p); +} + +/// A layout slider (`slider:{id}`): `key`'s value, 0–`max` (percent) or −180–180° (degrees). +fn slider(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, key: &str, value: f64, max: f64, unit: Unit) { + let (mut v, range, suffix) = match unit { + Unit::Percent => (value * 100.0, 0.0..=max * 100.0, " %"), + Unit::Degrees => (value, -180.0..=180.0, "°"), + }; + let r = ui.add(egui::Slider::new(&mut v, range).suffix(suffix).max_decimals(1)); + register(ui.ctx(), format!("slider:{id}"), r.rect); + let value = match unit { + Unit::Percent => v / 100.0, + Unit::Degrees => v, + }; + if r.changed() + && let Ok(next) = app.session.slide_layout.merged(&json!({ key: value })) + { + app.session.slide_layout = next; + } + if r.drag_stopped() || (r.changed() && !r.dragged()) { + set(app, json!({ key: value })); + } +} + +/// A colour button (`button:{id}`) for `key`. +fn colour(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, key: &str, value: [u8; 3]) { + let mut c = value; + let r = egui::color_picker::color_edit_button_srgb(ui, &mut c); + register(ui.ctx(), format!("button:{id}"), r.rect); + if r.changed() { + set(app, json!({ key: c })); + } +} + +/// A checkbox (`check:settings.{id}`) for `key`; true when it is on. +fn toggle(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, key: &str, value: bool, label: &str) -> bool { + let mut v = value; + if check(ui, &format!("settings.{id}"), &mut v, label) { + set(app, json!({ key: v })); + } + v +} + +/// The Layout, Options and Backdrop sections. +pub fn sections(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + let s = app.session.slide_layout.clone(); + + heading(ui, t, "Layout"); + let linked = toggle(app, ui, "slideshowLinkMargins", "linkMargins", s.link_margins, "Link all margins"); + if linked { + row(ui, t, "Margins", |ui| slider(app, ui, "slideshowMargins", "marginLeft", s.margin_left, MARGIN_MAX, Unit::Percent)); + } else { + for (id, key, label, v) in [ + ("slideshowMarginLeft", "marginLeft", "Left", s.margin_left), + ("slideshowMarginRight", "marginRight", "Right", s.margin_right), + ("slideshowMarginTop", "marginTop", "Top", s.margin_top), + ("slideshowMarginBottom", "marginBottom", "Bottom", s.margin_bottom), + ] { + row(ui, t, label, |ui| slider(app, ui, id, key, v, MARGIN_MAX, Unit::Percent)); + } + } + hint(ui, t, "Margins are a share of the slide's shorter side, so slides look the same on any screen."); + toggle(app, ui, "slideshowGuides", "showGuides", s.show_guides, "Show guides"); + row(ui, t, "Aspect preview", |ui| { + let mut a = s.aspect; + let opts = [(Aspect::Screen, "Screen"), (Aspect::Wide, "16:9"), (Aspect::Standard, "4:3")]; + if choices(ui, "settingsSlideshowAspect", &opts, &mut a) { + set(app, json!({"aspect": a.id()})); + } + }); + + ui.add_space(6.0); + heading(ui, t, "Options"); + toggle(app, ui, "slideshowZoomToFill", "zoomToFill", s.zoom_to_fill, "Zoom to fill frame"); + if toggle(app, ui, "slideshowStroke", "strokeBorder", s.stroke_border, "Stroke border") { + row(ui, t, "Colour", |ui| colour(app, ui, "slideshowStrokeColor", "strokeColor", s.stroke_color)); + row(ui, t, "Width", |ui| slider(app, ui, "slideshowStrokeWidth", "strokeWidth", s.stroke_width, STROKE_MAX, Unit::Percent)); + } + if toggle(app, ui, "slideshowShadow", "castShadow", s.cast_shadow, "Cast shadow") { + row(ui, t, "Opacity", |ui| slider(app, ui, "slideshowShadowOpacity", "shadowOpacity", s.shadow_opacity, 1.0, Unit::Percent)); + row(ui, t, "Offset", |ui| slider(app, ui, "slideshowShadowOffset", "shadowOffset", s.shadow_offset, SHADOW_OFFSET_MAX, Unit::Percent)); + row(ui, t, "Radius", |ui| slider(app, ui, "slideshowShadowRadius", "shadowRadius", s.shadow_radius, SHADOW_RADIUS_MAX, Unit::Percent)); + row(ui, t, "Angle", |ui| slider(app, ui, "slideshowShadowAngle", "shadowAngle", s.shadow_angle, 180.0, Unit::Degrees)); + } + + ui.add_space(6.0); + heading(ui, t, "Backdrop"); + row(ui, t, "Background colour", |ui| colour(app, ui, "slideshowBackdropColor", "backdropColor", s.backdrop_color)); + if toggle(app, ui, "slideshowWash", "colorWash", s.color_wash, "Colour wash") { + row(ui, t, "Colour", |ui| colour(app, ui, "slideshowWashColor", "washColor", s.wash_color)); + row(ui, t, "Opacity", |ui| slider(app, ui, "slideshowWashOpacity", "washOpacity", s.wash_opacity, 1.0, Unit::Percent)); + row(ui, t, "Angle", |ui| slider(app, ui, "slideshowWashAngle", "washAngle", s.wash_angle, 180.0, Unit::Degrees)); + } + let active = app.session.active(); + let mut on = s.background_image.is_some(); + let r = ui.add_enabled(on || active.is_some(), egui::Checkbox::new(&mut on, crate::i18n::tr("Background image"))); + register(ui.ctx(), "check:settings.slideshowBackgroundImage", r.rect); + if r.changed() { + set(app, json!({"backgroundImage": if on { active.map(|a| a.0) } else { None }})); + } + if let Some(bid) = s.background_image { + let name = app.session.catalog.photo(lightcraft_catalog::PhotoId(bid)).map(|p| p.file_name.clone()); + row(ui, t, "Photo", |ui| { + let text = name.unwrap_or_else(|| crate::i18n::tr("(not in the library)").to_string()); + ui.label(egui::RichText::new(text).color(t.text)); + if let Some(a) = active.filter(|a| a.0 != bid) + && crate::widgets::text_button(ui, "slideshowUseSelected", "Use selected photo", false).clicked() + { + set(app, json!({"backgroundImage": a.0})); + } + }); + row(ui, t, "Opacity", |ui| slider(app, ui, "slideshowBackgroundOpacity", "backgroundOpacity", s.background_opacity, 1.0, Unit::Percent)); + } else { + hint(ui, t, "Select a photo, then turn this on to fill the slide behind every photo with it."); + } + ui.add_space(4.0); + if crate::widgets::text_button(ui, "slideshowResetLayout", "Reset layout", false).clicked() { + reset(app); + } +} + +/// The aspect of the screen a slideshow plays on (the monitor, else the window). +fn screen_aspect(ctx: &egui::Context) -> f64 { + let monitor = ctx.input(|i| i.viewport().monitor_size).filter(|m| m.x > 0.0 && m.y > 0.0); + let r = monitor.unwrap_or_else(|| ctx.content_rect().size()); + if r.y > 0.0 { (r.x / r.y) as f64 } else { 1.6 } +} + +/// The small composed slide of the active photo, with the margin guides when they are on. +pub fn preview(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + heading(ui, t, "Preview"); + let s = app.session.slide_layout.clone(); + let ratio = s.aspect.ratio(screen_aspect(ui.ctx())) as f32; + let h = (PREVIEW_W / ratio).clamp(80.0, 320.0); + let (rect, _) = ui.allocate_exact_size(vec2(PREVIEW_W, h), Sense::hover()); + register(ui.ctx(), "slideshow:preview", rect); + let p = ui.painter_at(rect.expand(1.0)); + p.rect_stroke(rect, 0.0, Stroke::new(1.0, t.text_dim.gamma_multiply(0.5)), egui::StrokeKind::Outside); + let Some(id) = app.session.active() else { + p.rect_filled(rect, 0.0, Color32::from_rgb(s.backdrop_color[0], s.backdrop_color[1], s.backdrop_color[2])); + p.text(rect.center(), Align2::CENTER_CENTER, crate::i18n::tr("Select a photo to preview its slide"), t.font(11.5), Color32::from_gray(200)); + return; + }; + let ppp = ui.ctx().pixels_per_point(); + let (pw, ph) = (((PREVIEW_W * ppp).round() as usize).max(16), ((h * ppp).round() as usize).max(16)); + let key = s.pixel_key() ^ id.0.rotate_left(17) ^ ((pw as u64) << 32 | ph as u64); + let now = ui.input(|i| i.time); + let mem = egui::Id::new("settingsSlideshowPreviewKey"); + let (last, since) = ui.ctx().data(|d| d.get_temp::<(u64, f64)>(mem)).unwrap_or((0, f64::NEG_INFINITY)); + let current = app.renderer.textures.get(&Slot::SlidePreview).filter(|tex| tex.photo == id); + if last != key { + ui.ctx().data_mut(|d| d.insert_temp(mem, (key, now))); + } + let still = if last == key { now - since } else { 0.0 }; + // the first preview (or another photo's) at once, a changing layout once it has settled + if current.is_none() || still >= DEBOUNCE { + let side = super::detail::texture_side(ui.ctx()); + let edge = app.ui.settings.loupe_edge(pw.max(ph) as f32, usize::MAX, side); + if let Some(job) = app.session.slide_job_with(&s, id, pw, ph, Some(edge), false) { + app.renderer.request(Slot::SlidePreview, job, 90); + } + } else { + ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64((DEBOUNCE - still).clamp(0.01, DEBOUNCE))); + } + let Some(tex) = app.renderer.textures.get(&Slot::SlidePreview).filter(|tex| tex.photo == id) else { + p.rect_filled(rect, 0.0, Color32::from_rgb(s.backdrop_color[0], s.backdrop_color[1], s.backdrop_color[2])); + p.text(rect.center(), Align2::CENTER_CENTER, crate::i18n::tr("Rendering…"), t.font(11.5), Color32::from_gray(200)); + return; + }; + let drawn = super::detail::fit_texture_rect(rect, tex.size); + p.image(tex.tex.id(), drawn, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + if s.show_guides { + // the margins, as lines across the slide (where the image cell's edges are) + let aspect = app.session.slide_photo_aspect(id).unwrap_or(1.5); + let l = lightcraft_slideshow::layout(&s, aspect, tex.size[0], tex.size[1]); + let (kx, ky) = (drawn.width() / tex.size[0].max(1) as f32, drawn.height() / tex.size[1].max(1) as f32); + let stroke = Stroke::new(1.0, Color32::from_rgba_unmultiplied(120, 170, 255, 200)); + let x = |v: f64| drawn.left() + v as f32 * kx; + let y = |v: f64| drawn.top() + v as f32 * ky; + for gx in [x(l.cell.x0), x(l.cell.x1)] { + p.line_segment([pos2(gx, drawn.top()), pos2(gx, drawn.bottom())], stroke); + } + for gy in [y(l.cell.y0), y(l.cell.y1)] { + p.line_segment([pos2(drawn.left(), gy), pos2(drawn.right(), gy)], stroke); + } + } + hint(ui, t, "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview."); +} + +/// The default layout (for the Reset button). +pub fn reset(app: &mut LightcraftApp) { + let d = SlideSettings::default().to_json(); + set(app, d); +} diff --git a/crates/ui-egui/src/panels/slideshow.rs b/crates/ui-egui/src/panels/slideshow.rs index 9e1416a67..13b8f0c5a 100644 --- a/crates/ui-egui/src/panels/slideshow.rs +++ b/crates/ui-egui/src/panels/slideshow.rs @@ -12,8 +12,10 @@ //! transition until the next slide's texture exists ([`ready`]): the current slide is held //! instead of flashing a stand-in. With it off, a slide is rendered when it is due. //! -//! Which picture a slide shows comes from one place, [`slide_job`], so a later slide composer -//! (layout, overlays, backdrop) replaces that function and the playback stays as it is. +//! Which picture a slide shows comes from one place, [`slide_job`]: the photo itself with the +//! plain layout, else the slide composed with the library's layout (`slideshow.layout`: margins, +//! zoom to fill, stroke, shadow, backdrop) by `lightcraft-slideshow` on the render worker. A +//! composed slide is a screen-sized texture; the playback (fades, controls) is the same. use egui::{Align2, Color32, Pos2, Rect, pos2, vec2}; use lightcraft_catalog::PhotoId; @@ -167,21 +169,47 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) { } } -/// The render that shows slide `id` in `area` (logical points at `ppp`): the photo as developed, -/// fitted to the screen. The one place that decides what a slide's picture is. +/// The render that shows slide `id` in `area` (logical points at `ppp`). The one place that +/// decides what a slide's picture is: +/// - with the plain layout (no margins, border, shadow or backdrop: [`SlideSettings::is_plain`]) +/// the photo as developed, fitted to the screen (drawn over black); +/// - otherwise the slide composed with the library's layout (`slideshow.layout`) at the screen's +/// pixels: the photo rendered at its image cell's size, then composed on the render worker, so +/// a slide is ready (and fades) like any other texture. +/// +/// [`SlideSettings::is_plain`]: lightcraft_engine::slide::SlideSettings::is_plain fn slide_job(app: &mut LightcraftApp, id: PhotoId, area: Rect, ppp: f32, texture_side: usize) -> Option { let photo = app.session.catalog.photo(id)?.clone(); let frame = Frame::with_lens(photo.width.max(1) as usize, photo.height.max(1) as usize, &photo.develop, true, photo.embedded_lens.as_ref()); + let native = super::detail::output_px(&frame); + if !app.session.slide_layout.is_plain() { + let (w, h) = slide_px(area, ppp, texture_side); + let edge = app.ui.settings.loupe_edge(w.max(h) as f32, native[0].max(native[1]), texture_side); + return app.session.slide_job(id, w, h, Some(edge)); + } let aspect = frame.aspect() as f32; let aspect = if aspect.is_finite() && aspect > 0.0 { aspect } else { 1.5 }; let fit = (area.width() / aspect).min(area.height()); let drawn_long = fit.max(fit * aspect) * ppp; - let native = super::detail::output_px(&frame); let edge = app.ui.settings.loupe_edge(drawn_long, native[0].max(native[1]), texture_side); let (w, h) = if aspect >= 1.0 { (edge, (edge as f32 / aspect) as usize) } else { ((edge as f32 * aspect) as usize, edge) }; app.session.loupe_job(id, w.max(8), h.max(8), true) } +/// A composed slide's size in pixels: `area` at `ppp`, its long side at most the texture side +/// (and the composer's limit), at least 16 × 16. +pub fn slide_px(area: Rect, ppp: f32, texture_side: usize) -> (usize, usize) { + let px = |v: f32| if v.is_finite() { (v * ppp).round().max(16.0) as usize } else { 16 }; + let (w, h) = (px(area.width()), px(area.height())); + let cap = texture_side.clamp(16, lightcraft_slideshow::MAX_SIDE); + let long = w.max(h); + if long <= cap { + return (w, h); + } + let k = cap as f64 / long as f64; + (((w as f64 * k).round() as usize).max(16), ((h as f64 * k).round() as usize).max(16)) +} + /// The slides to keep rendered now, most wanted first, with their render priority. fn wanted(play: &Playback, now: f64) -> Vec<(PhotoId, u32)> { let f = play.frame(now); diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index 8346650c7..1a7d1f4fc 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -55,6 +55,9 @@ pub enum Slot { /// A slideshow slide (the one up, the one fading out, the next ones prepared in advance; see /// [`crate::panels::slideshow`]). Slide(PhotoId), + /// The small composed slide in Settings ▸ Slideshow (the active photo with the layout being + /// edited). + SlidePreview, } pub struct Tex { diff --git a/crates/ui-egui/src/tests_slideshow.rs b/crates/ui-egui/src/tests_slideshow.rs index 06e402bef..36558267b 100644 --- a/crates/ui-egui/src/tests_slideshow.rs +++ b/crates/ui-egui/src/tests_slideshow.rs @@ -240,3 +240,107 @@ fn a_photo_deleted_while_it_plays_does_not_stall_the_slideshow() { assert!(photos.len() > 3); h.settle(SETTLE); } + +fn painted(img: &egui::ColorImage, x: usize, y: usize) -> [u8; 3] { + let c = img.pixels[y * img.size[0] + x]; + [c.r(), c.g(), c.b()] +} + +#[test] +fn a_composed_slide_shows_during_playback() { + let mut h = grid(); + let r = run(&mut h, "slideshow.layout", json!({"margins": 0.1, "backdropColor": "#ff0000"})); + assert_eq!(r["marginTop"], 0.1); + run(&mut h, "view.slideshow", json!({"interval": 60, "fadeDuration": 0})); + let current = play(&h).current(); + assert!(h.step_until(SETTLE, |h| has_slide(h, current)), "the slide is composed and ready"); + h.step(); + let tex = h.app.renderer.textures.get(&Slot::Slide(current)).unwrap(); + assert_eq!(tex.size, [1000, 700], "a composed slide fills the screen"); + let img = h.paint(); + assert_eq!(img.size, [1000, 700]); + // 10 % of the 700 px short side = 70 px of red backdrop around the image cell + for (x, y) in [(5, 5), (995, 5), (500, 30), (500, 690), (30, 350), (985, 350)] { + assert_eq!(painted(&img, x, y), [255, 0, 0], "margin at ({x}, {y})"); + } + let mid = painted(&img, 500, 350); + assert_ne!(mid, [255, 0, 0], "the photo in the cell"); + // the next slide is composed too, and stepping shows it + let next = play(&h).upcoming(1)[0]; + assert!(h.step_until(SETTLE, |h| has_slide(h, next))); + run(&mut h, "slideshow.next", json!({})); + h.step(); + assert_eq!(painted(&h.paint(), 5, 5), [255, 0, 0]); + h.settle(SETTLE); +} + +#[test] +fn the_default_layout_plays_the_photo_as_before() { + let mut h = grid(); + assert!(h.app.session.slide_layout.is_plain()); + run(&mut h, "view.slideshow", json!({"interval": 60, "fadeDuration": 0})); + let current = play(&h).current(); + assert!(h.step_until(SETTLE, |h| has_slide(h, current))); + h.step(); + // the photo's own render (its aspect, not the screen's), fitted on black, as before + let tex = h.app.renderer.textures.get(&Slot::Slide(current)).unwrap(); + let aspect = h.app.session.slide_photo_aspect(current).unwrap(); + let tex_aspect = tex.size[0] as f64 / tex.size[1] as f64; + assert!((tex_aspect - aspect).abs() < 0.02, "{tex_aspect} vs {aspect}"); + assert_ne!(tex.size, [1000, 700]); + let img = h.paint(); + let r = crate::panels::detail::fit_texture_rect(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 700.0)), tex.size); + if r.left() > 2.0 { + assert_eq!(painted(&img, 1, 350), [0, 0, 0], "black beside the photo"); + } + if r.top() > 2.0 { + assert_eq!(painted(&img, 500, 1), [0, 0, 0], "black above the photo"); + } + h.settle(SETTLE); +} + +#[test] +fn settings_shows_the_slide_layout_and_a_live_preview() { + let mut h = grid(); + let first = h.app.session.visible_cloned()[0]; + run(&mut h, "library.select", json!({"ids": [first.0]})); + run(&mut h, "view.slideshowOptions", json!({})); + h.step(); + let has = |h: &Headless, w: &str| h.app.widgets.iter().any(|(id, _)| id == w); + for w in [ + "slider:slideshowMargins", + "check:settings.slideshowZoomToFill", + "check:settings.slideshowStroke", + "check:settings.slideshowShadow", + "button:slideshowBackdropColor", + "check:settings.slideshowWash", + "slideshow:preview", + ] { + assert!(has(&h, w), "{w}"); + } + // the preview is the composed slide, rendered off the UI thread + assert!(h.step_until(SETTLE, |h| h.app.renderer.textures.get(&Slot::SlidePreview).is_some_and(|t| t.photo == first))); + // a checkbox goes through slideshow.layout, and its controls appear (scrolled into view) + let r = h.request("ui.hoverWidget", json!({"id": "check:settings.slideshowRandom"}), T); + assert_eq!(r["ok"], true, "{r}"); + let r = h.request("ui.scroll", json!({"dy": -600}), T); + assert_eq!(r["ok"], true, "{r}"); + for _ in 0..30 { + h.step(); + } + let r = h.request("ui.clickWidget", json!({"id": "check:settings.slideshowShadow"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(h.app.session.slide_layout.cast_shadow); + assert!(has(&h, "slider:slideshowShadowAngle")); + // a changed layout renders the preview again (once it has settled) + let before = h.app.renderer.textures.get(&Slot::SlidePreview).map(|t| t.key); + run(&mut h, "slideshow.layout", json!({"margins": 0.2, "backdropColor": "#00ff00"})); + assert!(h.step_until(SETTLE, |h| h.app.renderer.textures.get(&Slot::SlidePreview).map(|t| t.key) != before)); + h.step(); + let rect = h.app.widgets.iter().find(|(id, _)| id == "slideshow:preview").map(|(_, r)| *r).unwrap(); + let img = h.paint(); + let c = painted(&img, (rect.left() + 3.0) as usize, (rect.center().y) as usize); + assert!(c[1] > 200 && c[0] < 60, "the green backdrop in the preview margin: {c:?}"); + h.settle(SETTLE); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 552f76952..2d0f88ab1 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -49,6 +49,8 @@ mode ([mcp.md](mcp.md)) is a thin layer over this channel. Implementation: | `engine.execute {command: "activity.list"}` | — | The long-running tasks in flight (imports, exports, preview builds, downloads, the face scan…), oldest first: `{tasks: [{id, kind, label, done, total, unit, detail, cancellable, cancelling, ageMs}]}`; `total` 0 = not known yet; `unit` is `count`, `bytes` or `percent`; `label` is English (the app shows it translated) | | `engine.execute {command: "activity.cancel"}` | `{id}` or `{all: true}` | Asks the task (or every cancellable one) to stop at its next file or photo → `{cancelled: n}`; an unknown id (it may have just finished) or a task that can't be cancelled is an error | | `ui.render` | `{id?, size?, path?}` | Render a photo (PNG to `path`), `{width, height}` | +| `engine.execute {command: "slideshow.layout"}` | `{margins?, marginLeft?, …, zoomToFill?, strokeBorder?, castShadow?, backdropColor?, colorWash?, backgroundImage?, …}` (see `engine.commands`) | The slideshow's slide layout (Layout, Options, Backdrop), saved with the library; omitted keys keep their value, `{}` reads it; out-of-range numbers are clamped, a wrong type or unknown key is refused and nothing changes | +| `engine.execute {command: "slideshow.preview"}` | `{id?, width?, height?, path?}` | One slide composed with the layout, as the slideshow plays it: a PNG written to `path`, or returned base64 in `png` without one; `{id, width, height, cell, photo}` (`cell` and `photo` are `[x0, y0, x1, y1]` in slide pixels). Sides 16–4096; one side given takes the other from the aspect preview | | `app.quit` | — | Close the app | Image navigation is also available directly as the UI command `view.navigate`, with diff --git a/docs/mcp.md b/docs/mcp.md index 81cbc4bd8..845d054fd 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -196,6 +196,12 @@ JSON params (documented in each tool's description and by `list_commands`). Pass leave these per-command tools out when a client struggles with a large tool list; `run_command` still reaches every command. +Slideshow slides, for example: `cmd_slideshow_layout` reads or changes the slide layout (margins, +zoom to fill, stroke border, cast shadow, backdrop colour, colour wash, background image; saved with +the library) and `cmd_slideshow_preview {id?, width?, height?, path?}` composes one slide with it — +written as a PNG to `path`, or returned as base64 PNG in `png` — so an agent can look at a layout +before playing it. `cmd_slideshow_playback` sets the timing and fades. + ## Resources `resources/list` / `resources/read` serve JSON snapshots: diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index f9bc830d3..6995f6f16 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -52,10 +52,10 @@ cited here still exists. Formerly docs/parity.md. | X. Cross-cutting behaviours (BEHAV) | 18 | 6 | 1 | 1 | 8/8 (100%) | 6/9 (67%) | | Y. Menus | 82 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 75 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) | -| Lightroom Classic extras | 33 | 16 | 34 | 9 | — | 23/25 (92%) | -| **Total** | 397 | 50 | 72 | 36 | 192/200 (96%) | 143/155 (92%) | +| Lightroom Classic extras | 35 | 17 | 31 | 9 | — | 23/25 (92%) | +| **Total** | 399 | 51 | 69 | 36 | 192/200 (96%) | 143/155 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.3%** of 519 in-scope rows — P0 98.0% of 200 · P1 95.2% of 155 · P2 47.5% of 163. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.8%** of 519 in-scope rows — P0 98.0% of 200 · P1 95.2% of 155 · P2 49.1% of 163. ## Top gaps and shortcut notes @@ -728,13 +728,13 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-BOOK-VIEWS | Book views | P2 | ⬜ | | | | LRC-BOOK-EXPORT | Book export (PDF/JPEG) | P2 | ⬜ | | | | LRC-SS-TEMPLATES | Slideshow templates | P2 | ⬜ | | | -| LRC-SS-OPTIONS | Slideshow options | P2 | ⬜ | | | -| LRC-SS-LAYOUT | Slideshow layout | P2 | ⬜ | | | +| LRC-SS-OPTIONS | Slideshow options | P2 | ✅ | `cmd:slideshow.layout`, `cmd:slideshow.preview`, `path:crates/slideshow/src/compose.rs` | Zoom to Fill Frame (the photo covers its image cell, cropped to it; off: fits inside), Stroke Border (colour, width as a share of the slide's shorter side, drawn around the photo), Cast Shadow (opacity, offset, radius, angle; a blurred offset rectangle under the photo and its stroke). Composed by `lightcraft-slideshow` (CPU, no UI) for playback, the Settings ▸ Slideshow preview and `slideshow.preview`; edited in Settings ▸ Slideshow until the Slideshow module view exists | +| LRC-SS-LAYOUT | Slideshow layout | P2 | 🟡 | `cmd:slideshow.layout`, `cmd:slideshow.preview`, `path:crates/ui-egui/src/panels/slide_layout.rs` | Margins left / right / top / bottom (linked or each on its own; shares of the slide's shorter side, so any screen or output size looks the same), Show Guides, Aspect Preview (Screen, 16:9, 4:3). Missing: the Slideshow module's slide editor — guides are drawn only in the Settings ▸ Slideshow preview and can't be dragged to set the margins; the aspect preview applies to that preview and `slideshow.preview` (playback always fills the screen, as in Classic) | | LRC-SS-OVERLAYS | Slideshow overlays | P2 | ⬜ | | | -| LRC-SS-BACKDROP | Slideshow backdrop | P2 | ⬜ | | | +| LRC-SS-BACKDROP | Slideshow backdrop | P2 | ✅ | `cmd:slideshow.layout`, `path:crates/slideshow/src/compose.rs` | Background colour, Color Wash (a gradient from a second colour, full at the side its angle points to, at an opacity), Background Image (a library photo filling the slide at an opacity: "Use selected photo" in Settings ▸ Slideshow, `backgroundImage` in `slideshow.layout`; rendered once and shared by the slides). In the web build the composition runs on the main thread when a worker's render comes back | | LRC-SS-TITLES | Slideshow titles | P2 | ⬜ | | | | LRC-SS-MUSIC | Slideshow music | P2 | ⬜ | | | -| LRC-SS-PLAYBACK | Slideshow playback | P2 | 🟡 | `cmd:slideshow.playback`, `cmd:view.slideshowOptions`, `cmd:slideshow.togglePause`, `cmd:slideshow.next`, `cmd:slideshow.previous`, `cmd:slideshow.end` | Playback settings (library preference, `slideshow.playback`; Settings ▸ Slideshow, View ▸ Slideshow Options…): slide duration 0.5–120 s, fade 0–10 s (≤ slide; 0 = cut) as a crossfade or through a colour, random order (seeded shuffle, every photo once per pass, a new order each pass), repeat (off: ends after the last slide), prepare previews in advance (the next 2 slides render off the UI thread; a transition never starts before its slide is rendered — the current one is held). Space / ←/→ (cut) / Esc and a control bar (previous, play/pause, next, n / N, end) that shows on pointer movement. Missing: the Slideshow module view and its "preview in the view" (phase 2 of the module plan), music and sync to music (LRC-SS-MUSIC), pan & zoom, manual-advance mode, choosing the screen to play on, slides composed with layout/overlays/backdrop (LRC-SS-LAYOUT…) | +| LRC-SS-PLAYBACK | Slideshow playback | P2 | 🟡 | `cmd:slideshow.playback`, `cmd:view.slideshowOptions`, `cmd:slideshow.togglePause`, `cmd:slideshow.next`, `cmd:slideshow.previous`, `cmd:slideshow.end` | Playback settings (library preference, `slideshow.playback`; Settings ▸ Slideshow, View ▸ Slideshow Options…): slide duration 0.5–120 s, fade 0–10 s (≤ slide; 0 = cut) as a crossfade or through a colour, random order (seeded shuffle, every photo once per pass, a new order each pass), repeat (off: ends after the last slide), prepare previews in advance (the next 2 slides render off the UI thread; a transition never starts before its slide is rendered — the current one is held). Space / ←/→ (cut) / Esc and a control bar (previous, play/pause, next, n / N, end) that shows on pointer movement. Missing: the Slideshow module view and its "preview in the view" (phase 2 of the module plan), music and sync to music (LRC-SS-MUSIC), pan & zoom, manual-advance mode, choosing the screen to play on, overlays and titles on the slides (LRC-SS-OVERLAYS, LRC-SS-TITLES). Slides are composed with the layout, options and backdrop (LRC-SS-LAYOUT / OPTIONS / BACKDROP) | | LRC-SS-EXPORT | Slideshow export | P2 | ⬜ | | | | LRC-PRINT-LAYOUTSTYLE | Print layout styles | P2 | 🟡 | `cmd:dialog.contactSheet`, `cmd:export.contactSheet`, `crates/engine/src/contact_sheet.rs` | paginated contact sheets; single-image/custom package layouts missing | | LRC-PRINT-IMAGESETTINGS | Print image settings | P2 | ⬜ | | | diff --git a/docs/whats-new.md b/docs/whats-new.md index b8a83b591..a59175377 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,18 @@ ## 2026-10-10 +### Slideshow layout, options and backdrop +- Slides can now be laid out like Lightroom Classic's Slideshow Layout, Options and Backdrop panels: margins (all four + linked, or each on its own), Zoom to Fill Frame (crop the photo to fill its cell), a stroke border (colour, width), + a cast shadow (opacity, offset, radius, angle), and a backdrop colour with an optional colour wash (a gradient from a + second colour, at an angle and opacity) and a background image (a photo from the library, at an opacity). +- Set them in Settings ▸ Slideshow, which now shows the selected photo as a slide as you change them (with the margin + guides, at the screen's aspect or 16:9 / 4:3). Sizes are a share of the slide, so a slide looks the same on any + screen. They are saved with the library. +- View ▸ Slideshow plays the composed slides, prepared ahead off the UI thread like before, so fades and the playback + controls work as they did. With the default layout (no margins, border, shadow or backdrop) it plays the photos as + before. Agents use `slideshow.layout`, and `slideshow.preview` returns one composed slide as a PNG. + ### Slideshow playback - View ▸ Slideshow (⌥⌘↩) now plays with Lightroom Classic's Playback settings: how long each slide stays up, a fade between slides (a crossfade, or through black, white or any colour; 0 cuts), random order (every photo once per diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 051b3aedf..1b4429214 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -48,6 +48,7 @@ pub const TABLE: &[(&str, Class)] = &[ ("develop", Class::Layer(1)), ("faces", Class::Layer(1)), ("scenes", Class::Layer(1)), + ("slideshow", Class::Layer(1)), ("pipeline", Class::Layer(2)), ("gpu", Class::Layer(3)), ("catalog", Class::Layer(3)), From 12dc533396fd7b6e8d000c2a4b5b855257a40849 Mon Sep 17 00:00:00 2001 From: Jason Baldwin <33934381+bitshiftza@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:54:40 +0200 Subject: [PATCH 3/6] LRC-SS-OVERLAYS: slideshow overlays: identity plate, rating stars, text with tokens, watermark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Engine: slide_overlay (OverlaySettings in prefs.json, the new slideshow.overlays command): identity plate (text, or a PNG/JPEG file with its transparency; size, colour, opacity; render behind image), rating stars (own vector stars), up to 16 text overlays with tokens ({caption}, {exposure}, {file}, {date}, {focal}… plus every rename token; unknown tags show as typed and are reported) and a drop shadow, each at one of nine anchors of the slide or photo plus an offset, kept on the slide; the export watermark on the photo. Sizes relative to the slide's shorter side; validated and clamped. - Drawn by the engine as a second pass over lightcraft_slideshow::compose_with (new behind hook for "render behind image"); the watermark's text renderer moved to engine text.rs (TextLayout, Inter + craft-fonts CJK) and is shared; glyphs off the image are skipped so huge text stays cheap. - rename::expand_tokens_with lets overlays add tokens without changing rename. - Playback composes slides when overlays are on; slideshow.preview returns each overlay's box. Settings > Slideshow > Overlays section with live preview. - Docs: parity row (partial), whats-new, control/MCP docs, nine locale catalogs. - Tests: tokens, nine anchors, star count, plate image opacity and missing file, text shadow, resolution independence, hostile input, big-slide cost, CJK with craft-fonts, prefs persistence, preview and headless playback pixel checks. --- crates/engine/src/cmd/slideshow.rs | 33 +- crates/engine/src/export.rs | 190 +-- crates/engine/src/lib.rs | 5 + crates/engine/src/library.rs | 4 + crates/engine/src/rename.rs | 63 +- crates/engine/src/slide.rs | 35 +- crates/engine/src/slide_overlay.rs | 1322 +++++++++++++++++++ crates/engine/src/tests_slide.rs | 90 ++ crates/engine/src/text.rs | 207 +++ crates/slideshow/src/compose.rs | 30 +- crates/slideshow/src/lib.rs | 2 +- crates/ui-egui/locales/de.json | 14 +- crates/ui-egui/locales/es.json | 14 +- crates/ui-egui/locales/fr.json | 14 +- crates/ui-egui/locales/ja.json | 14 +- crates/ui-egui/locales/pt-br.json | 14 +- crates/ui-egui/locales/ru.json | 15 +- crates/ui-egui/locales/uk.json | 14 +- crates/ui-egui/locales/zh-hans.json | 14 +- crates/ui-egui/locales/zh-hant.json | 14 +- crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/panels/settings.rs | 2 + crates/ui-egui/src/panels/slide_layout.rs | 14 +- crates/ui-egui/src/panels/slide_overlays.rs | 265 ++++ crates/ui-egui/src/panels/slideshow.rs | 2 +- crates/ui-egui/src/tests_slideshow.rs | 83 ++ docs/control-protocol.md | 3 +- docs/mcp.md | 5 +- docs/parity-checklist.md | 10 +- docs/whats-new.md | 13 + 30 files changed, 2290 insertions(+), 216 deletions(-) create mode 100644 crates/engine/src/slide_overlay.rs create mode 100644 crates/engine/src/text.rs create mode 100644 crates/ui-egui/src/panels/slide_overlays.rs diff --git a/crates/engine/src/cmd/slideshow.rs b/crates/engine/src/cmd/slideshow.rs index 17bd861f1..1ac391055 100644 --- a/crates/engine/src/cmd/slideshow.rs +++ b/crates/engine/src/cmd/slideshow.rs @@ -2,8 +2,9 @@ //! random order, repeat, preparing previews in advance). `slideshow.layout`: how a slide looks //! (Layout, Options and Backdrop: margins, aspect preview, zoom to fill, stroke, shadow, backdrop //! colour, colour wash, background image). Both are library preferences, saved in the library's -//! `prefs.json`; the app's slideshow (View ▸ Slideshow) plays with them. `slideshow.preview` -//! composes one slide as a PNG. +//! `prefs.json`; the app's slideshow (View ▸ Slideshow) plays with them. `slideshow.overlays`: +//! the identity plate, rating stars, text overlays with tokens and the watermark drawn on every +//! slide (also a library preference). `slideshow.preview` composes one slide as a PNG. use lightcraft_catalog::PhotoId; use serde_json::{Value, json}; @@ -38,6 +39,20 @@ fn layout(s: &mut Session, p: &Value) -> Result { Ok(s.slide_layout.to_json()) } +fn overlays(s: &mut Session, p: &Value) -> Result { + let changes = p.as_object().is_some_and(|o| !o.is_empty()); + if changes { + let next = s.slide_overlays.merged(p).map_err(|e| bad("slideshow.overlays", e))?; + if next != s.slide_overlays { + s.slide_overlays = next; + s.save_prefs()?; + } + } + let mut out = s.slide_overlays.to_json(); + out["warnings"] = json!(s.slide_overlays.warnings()); + Ok(out) +} + /// A slide size from the request: both sides given, one (the other from the aspect preview), or /// neither (1280 wide). Each side 16–[`PREVIEW_MAX`]. fn preview_size(s: &Session, p: &Value) -> Result<(usize, usize)> { @@ -89,6 +104,9 @@ fn preview(s: &mut Session, p: &Value) -> Result { "cell": rect(l.cell), "photo": rect(l.visible), }); + let photo = s.catalog.photo(id).cloned(); + let o = crate::slide_overlay::SlideOverlays::resolve(&s.slide_overlays, photo.as_deref()); + out["overlays"] = o.boxes(&l).into_iter().map(|(k, r)| json!({"kind": k.id(), "box": rect(r)})).collect(); match path { Some(path) => { crate::export::write_file(path, &png).map_err(crate::EngineError::Other)?; @@ -135,12 +153,21 @@ pub fn specs() -> Vec { always, layout ), + cmd!( + "slideshow.overlays", + "Slideshow Overlay Settings", + [], + None, + "{identityPlate?: {enabled?, text?, image?: PNG/JPEG file (drawn instead of the text; \"\" = the text), size?: height as a fraction of the slide's shorter side (0.005–0.5), color?: \"#rrggbb\"|[r,g,b], opacity?: 0–1, …placement, behind?: bool (render behind the photo)}, ratingStars?: {enabled?, color?, size?: a star's height (0.005–0.15), …placement}, texts?: [text overlay] (replaces the list, at most 16; each entry merged onto the one at its index), addText?: text overlay ({} = the default \"{file}\"), removeText?: index, showWatermark?: bool, watermark?: text | the export watermark object {text, vertical, size, opacity, anchor, inset, color, shadow, image, imageWidth} (merged; sizes relative to the photo)} where a text overlay is {text?: template with tokens ({file}, {name}, {title}, {caption}, {date} (YYYY-MM-DD) / {date:%d.%m.%Y}, {exposure}, {shutter}, {aperture}, {focal}, {iso}, {camera}, {lens}, {rating}, {creator}, {copyright}, {folder}, {ext}; unknown tags show as typed), size?: line height (0.005–0.3), color?, opacity?, …placement, shadow?: bool, shadowOpacity?: 0–1, shadowOffset?, shadowRadius?: 0–0.05 (fractions of the shorter side), shadowAngle?: degrees counter-clockwise from the right} and the placement keys are anchor?: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, offsetX?, offsetY?: fractions of the slide's shorter side (x right, y down; −1–1), relativeTo?: \"slide\"|\"photo\" (the overlay is kept on the slide) — omitted keys keep their value, {} reads them; out-of-range numbers are clamped, wrong types refused; saved with the library; slideshow playback and slideshow.preview draw them → the settings and warnings (an unreadable identity plate image, unknown tokens)", + always, + overlays + ), cmd!( query "slideshow.preview", "Slideshow Slide Preview", [], None, - "{id?: photo (default: the active one), width?, height?: pixels (16–4096; one given: the other from the aspect preview; neither: 1280 wide), path?: write the PNG here} — the photo's slide composed with the slideshow layout → {id, width, height, cell: [x0,y0,x1,y1], photo: [x0,y0,x1,y1] (the visible photo, slide pixels), path | png (base64 PNG)}", + "{id?: photo (default: the active one), width?, height?: pixels (16–4096; one given: the other from the aspect preview; neither: 1280 wide), path?: write the PNG here} — the photo's slide composed with the slideshow layout and overlays → {id, width, height, cell: [x0,y0,x1,y1], photo: [x0,y0,x1,y1] (the visible photo, slide pixels), overlays: [{kind: identityPlate|ratingStars|textN|watermark, box: [x0,y0,x1,y1]}], path | png (base64 PNG)}", always, preview ), diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 6a9e9147c..4cd4dd50d 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -242,58 +242,8 @@ impl Default for Watermark { } } -/// Inter SemiBold (OFL, see assets/ATTRIBUTION.md). -static WATERMARK_FONT: &[u8] = include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf"); - -/// The watermark faces: Inter first, then the craft-fonts CJK faces (Mincho first, the watermark's -/// serif look; then any other CJK face — a watermark has no UI language, so every CJK script is -/// offered). Without craft-fonts that is Inter alone, and CJK characters draw as Inter's -/// missing-glyph box. -fn watermark_fonts(craft: &'static [crate::fonts::CraftFont]) -> Vec> { - let mut cjk: Vec<_> = crate::fonts::cjk(craft).collect(); - cjk.sort_by_key(|f| !f.is_mincho()); - std::iter::once(WATERMARK_FONT) - .chain(cjk.into_iter().map(|f| f.bytes)) - .filter_map(|bytes| ab_glyph::FontRef::try_from_slice(bytes).ok()) - .collect() -} - -/// Shape a whole grapheme in one cell. CJK uses vertical origins and forms; Latin stays upright. -/// Unlike Unicode presentation-form characters, GSUB works with BIZ UD fonts too. -fn watermark_cell_glyphs( - font: &ab_glyph::FontRef<'_>, - data: &harfrust::ShaperData, - text: &str, - vertical: bool, - px: f32, - left: f32, - top: f32, -) -> Option> { - use ab_glyph::{Font, ScaleFont}; - let face = harfrust::FontRef::new(font.font_data()).ok()?; - let mut buf = harfrust::UnicodeBuffer::new(); - buf.push_str(text); - buf.set_direction(if vertical { harfrust::Direction::TopToBottom } else { harfrust::Direction::LeftToRight }); - buf.guess_segment_properties(); - let shaped = data.shaper(&face).build().shape(buf, harfrust::ShapeOptions::new()); - if shaped.glyph_infos().is_empty() { - return None; - } - let sf = font.as_scaled(px); - let (sx, sy) = (sf.h_scale_factor(), sf.v_scale_factor()); - let advance_x = shaped.glyph_positions().iter().map(|p| p.x_advance as f32 * sx).sum::(); - let advance_y = shaped.glyph_positions().iter().map(|p| -(p.y_advance as f32) * sy).sum::(); - let (mut x, mut y) = if vertical { (left + px / 2.0, top + (px - advance_y) / 2.0) } else { (left + (px - advance_x) / 2.0, top + sf.ascent()) }; - let mut glyphs = Vec::new(); - for (info, pos) in shaped.glyph_infos().iter().zip(shaped.glyph_positions()) { - let gid = u16::try_from(info.glyph_id).ok().filter(|g| *g != 0)?; - let pen = ab_glyph::point(x + pos.x_offset as f32 * sx, y - pos.y_offset as f32 * sy); - glyphs.push(ab_glyph::GlyphId(gid).with_scale_and_position(px, pen)); - x += pos.x_advance as f32 * sx; - y -= pos.y_advance as f32 * sy; - } - Some(glyphs) -} +#[cfg(test)] +use crate::text::{TEXT_FONT as WATERMARK_FONT, cell_glyphs as watermark_cell_glyphs, faces as watermark_fonts}; /// Draw `wm` onto `img` (straight alpha blending of the encoded values). pub fn draw_watermark(img: &mut Rgba8, wm: &Watermark) { @@ -306,6 +256,23 @@ pub fn draw_watermark(img: &mut Rgba8, wm: &Watermark) { }); } +/// Draw `wm` into the `w × h` area of `img` whose top-left corner is (`x0`, `y0`), sized and +/// anchored as on an image of that size (a slideshow slide's photo). The area is clipped to `img`. +pub fn draw_watermark_in(img: &mut Rgba8, wm: &Watermark, x0: usize, y0: usize, w: usize, h: usize) { + let (width, height) = (img.width, img.height); + watermark_coverage(w, h, wm, crate::fonts::CRAFT_FONTS, |x, y, k, col| { + let (px, py) = (x0.saturating_add(x), y0.saturating_add(y)); + if px >= width || py >= height { + return; + } + if let Some(p) = img.data.get_mut(py * width + px) { + for c in 0..3 { + p[c] = (p[c] as f32 + (col[c] as f32 - p[c] as f32) * k).round() as u8; + } + } + }); +} + /// Draw `wm` onto a high-bit-depth image (`wm.color` is in the image's space, 8-bit encoded; float /// images are linear and blend in linear light). pub fn draw_watermark_deep(img: &mut DeepImage, wm: &Watermark) { @@ -338,7 +305,6 @@ fn watermark_coverage( craft: &'static [crate::fonts::CraftFont], mut blend_px: impl FnMut(usize, usize, f32, [u8; 3]), ) { - use ab_glyph::{Font, PxScale, ScaleFont, point}; if !wm.image.trim().is_empty() { logo_coverage(width, height, wm, blend_px); return; @@ -347,97 +313,10 @@ fn watermark_coverage( if text.is_empty() || width == 0 || height == 0 { return; } - let fonts = watermark_fonts(craft); - let Some(latin) = fonts.first() else { return }; - let vertical_data: Vec<_> = if wm.vertical { - fonts.iter().map(|font| harfrust::FontRef::new(font.font_data()).ok().map(|face| harfrust::ShaperData::new(&face))).collect() - } else { - Vec::new() - }; let short = width.min(height) as f32; let px = (wm.size.clamp(0.005, 0.5) * short).max(6.0); - let mut glyphs = Vec::new(); - let mut x = 0.0f32; - let mut y = 0.0f32; - let mut tw = 0.0f32; - let mut prev = None; - let columns = text.split('\n').count(); - let mut column = 0usize; - use unicode_segmentation::UnicodeSegmentation; - // Horizontal watermarks retain scalar layout and kerning; only vertical cells use graphemes. - let cells: Box> = - if wm.vertical { Box::new(text.graphemes(true)) } else { Box::new(text.char_indices().filter_map(|(i, c)| text.get(i..i + c.len_utf8()))) }; - for cell in cells { - let Some(original) = cell.chars().next() else { continue }; - if cell == "\n" || (wm.vertical && cell == "\r\n") { - if wm.vertical { - column = column.saturating_add(1); - tw = tw.max(y); - y = 0.0; - } else { - tw = tw.max(x); - x = 0.0; - y += px; - } - prev = None; - continue; - } - let cjk = matches!(original as u32, 0x3000..=0x30FF | 0x3400..=0x9FFF | 0xFF01..=0xFF60); - if wm.vertical && (cjk || cell.chars().count() > 1) { - let left = columns.saturating_sub(column.saturating_add(1)) as f32 * px; - let shaped = fonts.iter().zip(&vertical_data).enumerate().find_map(|(face, (font, data))| { - let data = data.as_ref()?; - let cluster = watermark_cell_glyphs(font, data, cell, cjk, px, left, y)?; - let substituted = cluster.len() != 1 || cluster.first().is_some_and(|g| g.id != font.glyph_id(original)); - if cjk && !substituted && matches!(original, '、' | '。' | 'ー' | '(' | ')' | '「' | '」' | '『' | '』' | '【' | '】') { - return None; - } - Some((face, cluster)) - }); - if let Some((face, cluster)) = shaped { - glyphs.extend(cluster.into_iter().map(|g| (face, g, None))); - y += px; - continue; - } - } - let vertical_form = match original { - '、' => '︑', - '。' => '︒', - '(' => '︵', - ')' => '︶', - '「' => '﹁', - '」' => '﹂', - '『' => '﹃', - '』' => '﹄', - '【' => '︻', - '】' => '︼', - '…' => '︙', - other => other, - }; - let has = |font: &ab_glyph::FontRef<'_>, c: char| font.glyph_id(c).0 != 0; - let ch = if wm.vertical && fonts.iter().skip(1).any(|f| has(f, vertical_form)) { vertical_form } else { original }; - // The first face with the glyph; Inter (its missing-glyph box) when none has it. - let face = fonts.iter().position(|f| has(f, ch)).unwrap_or(0); - let sf = fonts.get(face).unwrap_or(latin).as_scaled(PxScale::from(px)); - let id = sf.glyph_id(ch); - if wm.vertical { - let left = columns.saturating_sub(column.saturating_add(1)) as f32 * px; - let pen = point(left + (px - sf.h_advance(id)) / 2.0, y + sf.ascent()); - let rotation = matches!(original, 'ー' | '—' | '–').then_some((left + px / 2.0, y + px / 2.0)); - glyphs.push((face, id.with_scale_and_position(px, pen), rotation)); - y += px; - } else { - if let Some((previous_face, previous)) = prev - && previous_face == face - { - x += sf.kern(previous, id); - } - glyphs.push((face, id.with_scale_and_position(px, point(x, y + sf.ascent())), None)); - x += sf.h_advance(id); - prev = Some((face, id)); - } - } - let (tw, th) = if wm.vertical { (columns as f32 * px, tw.max(y)) } else { (tw.max(x), y + px) }; + let Some(layout) = crate::text::TextLayout::new(text, px, wm.vertical, craft) else { return }; + let (tw, th) = (layout.width, layout.height); let inset = wm.inset.clamp(0.0, 0.4) * short; let (w, h) = (width as f32, height as f32); use Anchor::*; @@ -452,33 +331,20 @@ fn watermark_coverage( BottomLeft | Bottom | BottomRight => h - inset - th, }; let alpha = wm.opacity.clamp(0.0, 1.0); - let mut blend = |gx: i32, gy: i32, cov: f32, col: [u8; 3], a: f32| { - if gx < 0 || gy < 0 || gx >= width as i32 || gy >= height as i32 { - return; - } - blend_px(gx as usize, gy as usize, (cov * a).clamp(0.0, 1.0), col); - }; let passes: &[(f32, [u8; 3], f32)] = if wm.shadow { &[((px * 0.05).max(1.0), [0, 0, 0], 0.45), (0.0, wm.color, 1.0)] } else { &[(0.0, wm.color, 1.0)] }; for &(off, col, a) in passes { - for (face, g, rotation) in &glyphs { - if let Some(o) = fonts.get(*face).and_then(|font| font.outline_glyph(g.clone())) { - let b = o.px_bounds(); - o.draw(|gx, gy, cov| { - let (sx, sy) = (b.min.x + gx as f32, b.min.y + gy as f32); - let (sx, sy) = match rotation { - Some((cx, cy)) => (cx - (sy - cy), cy + (sx - cx)), - None => (sx, sy), - }; - blend((ox + off + sx) as i32, (oy + off + sy) as i32, cov, col, a * alpha); - }); + layout.draw_in(ox + off, oy + off, width as f32, height as f32, |gx, gy, cov| { + if gx < 0 || gy < 0 || gx >= width as i32 || gy >= height as i32 { + return; } - } + blend_px(gx as usize, gy as usize, (cov * a * alpha).clamp(0.0, 1.0), col); + }); } } /// A watermark graphic, decoded once per file (and again when the file changes). -fn watermark_logo(path: &str) -> Option> { +pub(crate) fn watermark_logo(path: &str) -> Option> { use std::collections::HashMap; use std::sync::{Arc, Mutex}; static CACHE: Mutex)>>> = Mutex::new(None); @@ -833,7 +699,7 @@ impl ExportOptions { } /// A `watermark` value: text, or an object of [`WATERMARK_PARAMS`] (issue #183). - fn validate_watermark(cmd: &str, v: &serde_json::Value) -> crate::Result<()> { + pub(crate) fn validate_watermark(cmd: &str, v: &serde_json::Value) -> crate::Result<()> { use serde_json::Value; let bad = |msg: String| crate::cmd::bad(cmd, msg); let obj = match v { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 9d1bd5db8..fab21a5d9 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -56,9 +56,11 @@ pub mod rename; pub mod segment; pub mod sidecar; pub mod slide; +pub mod slide_overlay; pub mod slideshow; pub mod smart; pub mod sync; +pub mod text; mod view; pub mod walk; @@ -278,6 +280,8 @@ pub struct Session { /// Slideshow slide layout: Layout, Options and Backdrop (`slideshow.layout`; persisted in /// prefs.json). pub slide_layout: slide::SlideSettings, + /// Slideshow overlays (identity plate, rating stars, text overlays, watermark). + pub slide_overlays: slide_overlay::OverlaySettings, /// The background image the slides last rendered (shared by slide jobs: rendered once). pub slide_backdrop: std::sync::Arc, /// Where smart previews are kept when not in the library folder (persisted in prefs.json). @@ -364,6 +368,7 @@ impl Session { cache_mb: 0, slideshow: slideshow::PlaybackSettings::default(), slide_layout: slide::SlideSettings::default(), + slide_overlays: slide_overlay::OverlaySettings::default(), slide_backdrop: Default::default(), smart_previews_dir: None, forget_local_days: lightcraft_catalog::DEFAULT_FORGET_DAYS, diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 4f5695a0f..6c7bafaef 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -162,6 +162,8 @@ struct PrefsFile { slideshow: crate::slideshow::PlaybackSettings, /// Slideshow slide layout (margins, options, backdrop). slide_layout: crate::slide::SlideSettings, + /// Slideshow overlays. + slide_overlays: crate::slide_overlay::OverlaySettings, } fn presets_json(s: &Session) -> String { @@ -346,6 +348,7 @@ impl Session { self.cache_mb = prefs.cache_mb; self.slideshow = prefs.slideshow.sanitized(); self.slide_layout = prefs.slide_layout.sanitized(); + self.slide_overlays = prefs.slide_overlays.sanitized(); self.forget_local_days = prefs.forget_local_days.unwrap_or(lightcraft_catalog::DEFAULT_FORGET_DAYS); self.smart_previews_dir = prefs.smart_previews_dir.filter(|_| on_disk).map(PathBuf::from); if let Some(d) = &self.smart_previews_dir { @@ -584,6 +587,7 @@ impl Session { forget_local_days: Some(self.forget_local_days), slideshow: self.slideshow.clone(), slide_layout: self.slide_layout.clone(), + slide_overlays: self.slide_overlays.clone(), }) .unwrap_or_default(); let Some(lib) = self.library.as_mut() else { return Ok(()) }; diff --git a/crates/engine/src/rename.rs b/crates/engine/src/rename.rs index 259d51fcd..31a2696e0 100644 --- a/crates/engine/src/rename.rs +++ b/crates/engine/src/rename.rs @@ -245,6 +245,19 @@ pub fn expand(template: &str, p: &Photo, seq: usize) -> String { /// The template's tokens replaced for `p` (no extension added, nothing sanitized). A bare `{seq}` /// is zero-padded to `seq_width` digits. pub fn expand_tokens(template: &str, p: &Photo, seq: usize, seq_width: usize) -> String { + expand_tokens_with(template, p, seq, seq_width, &|_, _, _| None) +} + +/// [`expand_tokens`] with more tokens: `extra(name, arg, tag)` (the name lower-cased, the text +/// after `:`, the whole tag without braces) answers a token first; `None` leaves it to the rename +/// tokens (and an unknown one stays as typed). Slideshow text overlays add their tokens this way. +pub fn expand_tokens_with( + template: &str, + p: &Photo, + seq: usize, + seq_width: usize, + extra: &dyn Fn(&str, Option<&str>, &str) -> Option, +) -> String { let (stem, ext) = split_ext(&p.file_name); let mut out = String::new(); let mut rest = template; @@ -257,31 +270,35 @@ pub fn expand_tokens(template: &str, p: &Photo, seq: usize, seq_width: usize) -> }; let tok = &rest[i + 1..i + j]; let (name, arg) = tok.split_once(':').map(|(a, b)| (a, Some(b))).unwrap_or((tok, None)); - let v = match name.trim().to_ascii_lowercase().as_str() { - "name" | "filename" => stem.to_string(), - "num" => { - let digits = stem.chars().rev().take_while(char::is_ascii_digit).count(); - stem[stem.len() - digits..].to_string() - } - "seq" | "n" => { - let w: usize = arg.and_then(|a| a.parse().ok()).unwrap_or(seq_width).min(9); - format!("{seq:0w$}") - } - "date" => format_date(p.date(), arg.unwrap_or("%Y%m%d")), - "folder" => match &p.source { - Source::File { path } => { - Path::new(path).parent().and_then(Path::file_name).map(|f| f.to_string_lossy().to_string()).unwrap_or_default() + let lower = name.trim().to_ascii_lowercase(); + let v = match extra(&lower, arg, tok) { + Some(v) => v, + None => match lower.as_str() { + "name" | "filename" => stem.to_string(), + "num" => { + let digits = stem.chars().rev().take_while(char::is_ascii_digit).count(); + stem[stem.len() - digits..].to_string() } - Source::Demo { .. } => String::new(), + "seq" | "n" => { + let w: usize = arg.and_then(|a| a.parse().ok()).unwrap_or(seq_width).min(9); + format!("{seq:0w$}") + } + "date" => format_date(p.date(), arg.unwrap_or("%Y%m%d")), + "folder" => match &p.source { + Source::File { path } => { + Path::new(path).parent().and_then(Path::file_name).map(|f| f.to_string_lossy().to_string()).unwrap_or_default() + } + Source::Demo { .. } => String::new(), + }, + "camera" => p.meta.camera.clone(), + "lens" => p.meta.lens.clone(), + "iso" => p.meta.iso.map(|v| v.to_string()).unwrap_or_default(), + "rating" => p.rating.to_string(), + "title" => p.meta.title.clone(), + "creator" => p.meta.creator.clone(), + "ext" => ext.to_string(), + _ => format!("{{{tok}}}"), }, - "camera" => p.meta.camera.clone(), - "lens" => p.meta.lens.clone(), - "iso" => p.meta.iso.map(|v| v.to_string()).unwrap_or_default(), - "rating" => p.rating.to_string(), - "title" => p.meta.title.clone(), - "creator" => p.meta.creator.clone(), - "ext" => ext.to_string(), - _ => format!("{{{tok}}}"), }; out.push_str(&v); rest = &rest[i + j + 1..]; diff --git a/crates/engine/src/slide.rs b/crates/engine/src/slide.rs index cd487d7d8..ab252cf6d 100644 --- a/crates/engine/src/slide.rs +++ b/crates/engine/src/slide.rs @@ -1,6 +1,7 @@ //! Slideshow slides in the engine: the library's slide layout ([`SlideSettings`], Lightroom -//! Classic's Layout, Options and Backdrop panels; the `slideshow.layout` command) and render jobs -//! that compose a photo into its slide ([`SlideCompose`], [`crate::media::RenderJob::with_slide`]). +//! Classic's Layout, Options and Backdrop panels; the `slideshow.layout` command), its overlays +//! ([`crate::slide_overlay`], `slideshow.overlays`) and render jobs that compose a photo into its +//! slide ([`SlideCompose`], [`crate::media::RenderJob::with_slide`]). //! //! The composer itself is `lightcraft-slideshow` (no UI, no catalog): playback, the Settings //! preview and `slideshow.preview` all compose through it, so they draw the same pixels. @@ -50,6 +51,8 @@ pub struct SlideCompose { pub width: usize, pub height: usize, pub backdrop: Option, + /// The overlays resolved for the slide's photo (`None`: none draw). + pub overlays: Option>, } impl SlideCompose { @@ -59,12 +62,19 @@ impl SlideCompose { ^ (self.width as u64).wrapping_mul(0x9e37_79b9_7f4a_7c15) ^ (self.height as u64).wrapping_mul(0xc2b2_ae3d_27d4_eb4f) ^ self.backdrop.as_ref().map_or(0, |b| b.job.key.rotate_left(29)) + ^ self.overlays.as_ref().map_or(0, |o| o.key().rotate_left(43)) } /// The slide showing `photo` (rendered at [`lightcraft_slideshow::photo_size`], or any size). pub fn compose(&self, photo: &Rgba8) -> Rgba8 { let bg = self.backdrop.as_ref().and_then(Backdrop::image); - lightcraft_slideshow::compose(photo, bg.as_deref(), &self.settings, self.width, self.height) + let Some(o) = &self.overlays else { + return lightcraft_slideshow::compose(photo, bg.as_deref(), &self.settings, self.width, self.height); + }; + let (mut img, l) = + lightcraft_slideshow::compose_with(photo, bg.as_deref(), &self.settings, self.width, self.height, &mut |img, l| o.draw_behind(img, l)); + o.draw_front(&mut img, &l); + img } } @@ -83,9 +93,16 @@ impl crate::Session { Some(if a.is_finite() && a > 0.0 { a } else { 1.5 }) } + /// Does a slide show nothing but the photo fitted on black (the plain layout and no + /// overlays)? Playback then shows the photo's own render. + pub fn slides_plain(&self) -> bool { + self.slide_layout.is_plain() && !self.slide_overlays.any() + } + /// The composition of `settings` at `width × height`, with its background image's render job - /// (the photo `settings.background_image` names, when it is in the catalog). - pub fn slide_compose(&mut self, settings: &SlideSettings, width: usize, height: usize) -> Arc { + /// (the photo `settings.background_image` names, when it is in the catalog) and the library's + /// overlays resolved for `photo`. + pub fn slide_compose(&mut self, settings: &SlideSettings, width: usize, height: usize, photo: Option) -> Arc { let settings = settings.clone().sanitized(); let backdrop = settings.background_image.filter(|_| settings.background_opacity > 0.0).and_then(|bid| { let id = PhotoId(bid); @@ -96,7 +113,11 @@ impl crate::Session { let job = self.render_job(id, w, h, false, true)?; Some(Backdrop { photo: id, job: Box::new(job), cache: self.slide_backdrop.clone() }) }); - Arc::new(SlideCompose { settings, width, height, backdrop }) + let overlays = self.slide_overlays.any().then(|| { + let p = photo.and_then(|id| self.catalog.photo(id)); + Arc::new(crate::slide_overlay::SlideOverlays::resolve(&self.slide_overlays, p.map(|p| &**p))) + }); + Arc::new(SlideCompose { settings, width, height, backdrop, overlays }) } /// A render job whose result is photo `id`'s slide at `width × height` with the library's @@ -126,7 +147,7 @@ impl crate::Session { (w, h) = (((w as f64 * k).round() as usize).max(1), ((h as f64 * k).round() as usize).max(1)); } let job = if view { self.loupe_job(id, w.max(8), h.max(8), true)? } else { self.render_job(id, w.max(8), h.max(8), false, true)? }; - let compose = self.slide_compose(settings, width, height); + let compose = self.slide_compose(settings, width, height, Some(id)); Some(job.with_slide(compose)) } diff --git a/crates/engine/src/slide_overlay.rs b/crates/engine/src/slide_overlay.rs new file mode 100644 index 000000000..8dc450c8b --- /dev/null +++ b/crates/engine/src/slide_overlay.rs @@ -0,0 +1,1322 @@ +//! Slideshow overlays (Lightroom Classic's Slideshow ▸ Overlays panel): the identity plate (text +//! or an image file), the photo's rating as stars, text overlays with tokens (file name, title, +//! caption, capture date, exposure, camera, lens, rating…) and the export watermark. +//! [`OverlaySettings`] is a library preference (`prefs.json`), edited with the +//! `slideshow.overlays` command; [`SlideOverlays`] is those settings resolved for one photo (its +//! texts expanded, its rating) and drawn onto the composed slide. +//! +//! **Where the drawing lives:** text needs the fonts (Inter plus the craft-fonts CJK fallbacks, +//! [`crate::text`]) and tokens need the catalog's photo, both in the engine, so overlays are a +//! second pass in the engine over `lightcraft_slideshow::compose_with`'s slide (its `behind` hook +//! draws an identity plate set to "render behind image" before the photo). The composer stays a +//! layout-and-pixels crate with no fonts and no catalog. +//! +//! **Resolution independence:** sizes and offsets are fractions of the slide's shorter side (also +//! for overlays anchored to the photo), so a slide at any size has the same proportions. An +//! overlay is placed at one of nine anchors of the slide or of the photo, moved by its offset +//! (x right, y down) and kept on the slide. +//! +//! **Never crash:** settings are validated by [`OverlaySettings::merged`] and clamped by +//! [`OverlaySettings::sanitized`]; text is cut at [`TEXT_MAX_CHARS`]; an unreadable identity +//! plate image draws nothing (and `slideshow.overlays` reports it); unknown tokens stay as typed +//! (as in rename and export file names) and are reported. + +use std::sync::Arc; + +use lightcraft_catalog::Photo; +use lightcraft_raster::{Image, Plane, Rgba8}; +use lightcraft_slideshow::{Layout, Rect, hex, parse_color}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; + +use crate::export::{Anchor, Watermark}; + +/// Smallest text, plate and star size (fraction of the slide's shorter side). +pub const SIZE_MIN: f64 = 0.005; +/// Largest text line height. +pub const TEXT_SIZE_MAX: f64 = 0.3; +/// Largest identity plate height. +pub const PLATE_SIZE_MAX: f64 = 0.5; +/// Largest star. +pub const STAR_SIZE_MAX: f64 = 0.15; +/// Largest offset either way (fraction of the shorter side). +pub const OFFSET_MAX: f64 = 1.0; +/// Longest shadow offset and radius (fractions of the shorter side). +pub const SHADOW_OFFSET_MAX: f64 = 0.05; +pub const SHADOW_RADIUS_MAX: f64 = 0.05; +/// Most text overlays. +pub const MAX_TEXTS: usize = 16; +/// Longest overlay text or template (characters; longer is cut). +pub const TEXT_MAX_CHARS: usize = 1000; + +/// What an overlay is anchored to. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum RelativeTo { + #[default] + Slide, + /// The visible photo. + Photo, +} + +impl RelativeTo { + fn id(self) -> &'static str { + match self { + RelativeTo::Slide => "slide", + RelativeTo::Photo => "photo", + } + } +} + +/// The nine anchors, in reading order (for pickers and tests). +pub const ANCHORS: [Anchor; 9] = [ + Anchor::TopLeft, + Anchor::Top, + Anchor::TopRight, + Anchor::Left, + Anchor::Center, + Anchor::Right, + Anchor::BottomLeft, + Anchor::Bottom, + Anchor::BottomRight, +]; + +/// An anchor's id (`topLeft`…). +pub fn anchor_id(a: Anchor) -> &'static str { + match a { + Anchor::TopLeft => "topLeft", + Anchor::Top => "top", + Anchor::TopRight => "topRight", + Anchor::Left => "left", + Anchor::Center => "center", + Anchor::Right => "right", + Anchor::BottomLeft => "bottomLeft", + Anchor::Bottom => "bottom", + Anchor::BottomRight => "bottomRight", + } +} + +/// Where an overlay goes: an anchor of the slide or the photo, and an offset from it. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Placement { + pub anchor: Anchor, + /// Fractions of the slide's shorter side, x to the right, y down. + pub offset_x: f64, + pub offset_y: f64, + pub relative_to: RelativeTo, +} + +impl Default for Placement { + fn default() -> Self { + Placement { anchor: Anchor::BottomRight, offset_x: -0.03, offset_y: -0.03, relative_to: RelativeTo::Slide } + } +} + +impl Placement { + fn sanitized(mut self) -> Self { + self.offset_x = clamp_or(self.offset_x, -OFFSET_MAX, OFFSET_MAX, 0.0); + self.offset_y = clamp_or(self.offset_y, -OFFSET_MAX, OFFSET_MAX, 0.0); + self + } + + fn json(&self, o: &mut Map) { + o.insert("anchor".into(), json!(anchor_id(self.anchor))); + o.insert("offsetX".into(), json!(self.offset_x)); + o.insert("offsetY".into(), json!(self.offset_y)); + o.insert("relativeTo".into(), json!(self.relative_to.id())); + } + + /// Apply a placement key; `false` when `k` isn't one. + fn merge(&mut self, ctx: &str, k: &str, v: &Value) -> Result { + match k { + "anchor" => { + self.anchor = serde_json::from_value(v.clone()).map_err(|_| { + format!("`{ctx}.anchor` must be one of topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, not {v}") + })? + } + "offsetX" => self.offset_x = number(ctx, k, v)?, + "offsetY" => self.offset_y = number(ctx, k, v)?, + "relativeTo" => { + self.relative_to = match v.as_str() { + Some("slide") => RelativeTo::Slide, + Some("photo") => RelativeTo::Photo, + _ => return Err(format!("`{ctx}.relativeTo` must be \"slide\" or \"photo\", not {v}")), + } + } + _ => return Ok(false), + } + Ok(true) + } + + /// The box of a `w × h` overlay on the slide `l`: at the anchor of the slide or the visible + /// photo, moved by the offset, then kept on the slide (when it fits). + pub fn place(&self, l: &Layout, w: f64, h: f64) -> Rect { + let c = match self.relative_to { + RelativeTo::Slide => l.slide, + RelativeTo::Photo => l.visible, + }; + let short = l.slide.width().min(l.slide.height()); + use Anchor::*; + let x = match self.anchor { + TopLeft | Left | BottomLeft => c.x0, + Top | Center | Bottom => (c.x0 + c.x1 - w) / 2.0, + TopRight | Right | BottomRight => c.x1 - w, + } + self.offset_x * short; + let y = match self.anchor { + TopLeft | Top | TopRight => c.y0, + Left | Center | Right => (c.y0 + c.y1 - h) / 2.0, + BottomLeft | Bottom | BottomRight => c.y1 - h, + } + self.offset_y * short; + let s = l.slide; + let fit = |v: f64, lo: f64, hi: f64, len: f64| if v.is_finite() { v.min(hi - len).max(lo) } else { lo }; + let (x, y) = (fit(x, s.x0, s.x1, w), fit(y, s.y0, s.y1, h)); + Rect { x0: x, y0: y, x1: x + w, y1: y + h } + } +} + +/// A soft drop shadow under text. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct TextShadow { + pub on: bool, + pub opacity: f64, + /// Fraction of the slide's shorter side. + pub offset: f64, + /// Blur radius, fraction of the slide's shorter side. + pub radius: f64, + /// Degrees counter-clockwise from the right (−45 = down right). + pub angle: f64, +} + +impl Default for TextShadow { + fn default() -> Self { + TextShadow { on: false, opacity: 0.6, offset: 0.004, radius: 0.004, angle: -45.0 } + } +} + +/// The identity plate: a line of text, or an image file (PNG / JPEG…, its transparency kept). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct IdentityPlate { + pub enabled: bool, + pub text: String, + /// An image file drawn instead of the text (empty = the text). + pub image: String, + /// Height: the text's line height or the image's height, fraction of the shorter side. + pub size: f64, + pub color: [u8; 3], + pub opacity: f64, + pub place: Placement, + /// Draw it before the photo, so the photo covers it where they meet. + pub behind: bool, +} + +impl Default for IdentityPlate { + fn default() -> Self { + IdentityPlate { + enabled: false, + text: "LightCraft".into(), + image: String::new(), + size: 0.05, + color: [255, 255, 255], + opacity: 0.8, + place: Placement { anchor: Anchor::TopLeft, offset_x: 0.03, offset_y: 0.03, relative_to: RelativeTo::Slide }, + behind: false, + } + } +} + +/// The photo's star rating, drawn as stars. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct RatingStars { + pub enabled: bool, + pub color: [u8; 3], + /// A star's height, fraction of the shorter side. + pub size: f64, + pub place: Placement, +} + +impl Default for RatingStars { + fn default() -> Self { + RatingStars { + enabled: false, + color: [235, 235, 235], + size: 0.03, + place: Placement { anchor: Anchor::TopRight, offset_x: -0.015, offset_y: 0.015, relative_to: RelativeTo::Photo }, + } + } +} + +/// A text overlay: a template with tokens (`{title}`, `{caption}`, `{exposure}`…; see [`TOKENS`]). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct TextOverlay { + pub text: String, + /// Line height, fraction of the shorter side. + pub size: f64, + pub color: [u8; 3], + pub opacity: f64, + pub place: Placement, + pub shadow: TextShadow, +} + +impl Default for TextOverlay { + fn default() -> Self { + TextOverlay { + text: "{file}".into(), + size: 0.03, + color: [255, 255, 255], + opacity: 0.9, + place: Placement { anchor: Anchor::Bottom, offset_x: 0.0, offset_y: -0.02, relative_to: RelativeTo::Slide }, + shadow: TextShadow::default(), + } + } +} + +/// Every overlay of a slide. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct OverlaySettings { + pub identity_plate: IdentityPlate, + pub rating_stars: RatingStars, + pub texts: Vec, + /// Draw [`Self::watermark`] on the photo. + pub show_watermark: bool, + /// The export watermark's settings (sizes relative to the photo, as in export). + pub watermark: Watermark, +} + +/// Tokens text overlays add to the rename tokens ([`crate::rename::TOKENS`]): tag, meaning. +pub const TOKENS: &[(&str, &str)] = &[ + ("{file}", "File name with its extension"), + ("{caption}", "Caption metadata"), + ("{copyright}", "Copyright metadata"), + ("{exposure}", "Shutter speed and aperture (1/250 s at f/8)"), + ("{shutter}", "Shutter speed"), + ("{aperture}", "Aperture (f/8)"), + ("{focal}", "Focal length (50 mm)"), + ("{date}", "Capture date as YYYY-MM-DD (or {date:%d.%m.%Y} …)"), +]; + +fn clamp_or(v: f64, lo: f64, hi: f64, fallback: f64) -> f64 { + if v.is_finite() { v.clamp(lo, hi) } else { fallback } +} + +fn wrap_angle(a: f64, fallback: f64) -> f64 { + if !a.is_finite() { + return fallback; + } + let w = (a + 180.0).rem_euclid(360.0) - 180.0; + if w <= -180.0 { 180.0 } else { w } +} + +/// `s` cut at [`TEXT_MAX_CHARS`] characters. +fn cut(s: &mut String) { + if let Some((i, _)) = s.char_indices().nth(TEXT_MAX_CHARS) { + s.truncate(i); + } +} + +fn number(ctx: &str, k: &str, v: &Value) -> Result { + match v.as_f64() { + Some(x) if x.is_finite() => Ok(x), + _ => Err(format!("`{ctx}.{k}` must be a finite number, not {v}")), + } +} + +fn boolean(ctx: &str, k: &str, v: &Value) -> Result { + v.as_bool().ok_or_else(|| format!("`{ctx}.{k}` must be true or false, not {v}")) +} + +fn string(ctx: &str, k: &str, v: &Value) -> Result { + let mut s = v.as_str().ok_or_else(|| format!("`{ctx}.{k}` must be text, not {v}"))?.to_string(); + cut(&mut s); + Ok(s) +} + +fn colour(ctx: &str, k: &str, v: &Value) -> Result<[u8; 3], String> { + parse_color(v).ok_or_else(|| format!("`{ctx}.{k}` must be \"#rrggbb\" or [r, g, b] (0–255), not {v}")) +} + +fn object<'a>(ctx: &str, v: &'a Value) -> Result<&'a Map, String> { + v.as_object().ok_or_else(|| format!("`{ctx}` must be an object, not {v}")) +} + +fn unknown(ctx: &str, k: &str, keys: &str) -> String { + format!("unknown key `{ctx}.{k}` ({keys})") +} + +impl IdentityPlate { + fn sanitized(mut self) -> Self { + let d = IdentityPlate::default(); + cut(&mut self.text); + cut(&mut self.image); + self.size = clamp_or(self.size, SIZE_MIN, PLATE_SIZE_MAX, d.size); + self.opacity = clamp_or(self.opacity, 0.0, 1.0, d.opacity); + self.place = self.place.sanitized(); + self + } + + fn to_json(&self) -> Value { + let mut o = Map::new(); + o.insert("enabled".into(), json!(self.enabled)); + o.insert("text".into(), json!(self.text)); + o.insert("image".into(), json!(self.image)); + o.insert("size".into(), json!(self.size)); + o.insert("color".into(), json!(hex(self.color))); + o.insert("opacity".into(), json!(self.opacity)); + self.place.json(&mut o); + o.insert("behind".into(), json!(self.behind)); + Value::Object(o) + } + + fn merge(&mut self, v: &Value) -> Result<(), String> { + const C: &str = "identityPlate"; + for (k, v) in object(C, v)? { + if self.place.merge(C, k, v)? { + continue; + } + match k.as_str() { + "enabled" => self.enabled = boolean(C, k, v)?, + "text" => self.text = string(C, k, v)?, + "image" => self.image = string(C, k, v)?.trim().to_string(), + "size" => self.size = number(C, k, v)?, + "color" => self.color = colour(C, k, v)?, + "opacity" => self.opacity = number(C, k, v)?, + "behind" => self.behind = boolean(C, k, v)?, + _ => return Err(unknown(C, k, "enabled, text, image, size, color, opacity, anchor, offsetX, offsetY, relativeTo, behind")), + } + } + Ok(()) + } +} + +impl RatingStars { + fn sanitized(mut self) -> Self { + self.size = clamp_or(self.size, SIZE_MIN, STAR_SIZE_MAX, RatingStars::default().size); + self.place = self.place.sanitized(); + self + } + + fn to_json(&self) -> Value { + let mut o = Map::new(); + o.insert("enabled".into(), json!(self.enabled)); + o.insert("color".into(), json!(hex(self.color))); + o.insert("size".into(), json!(self.size)); + self.place.json(&mut o); + Value::Object(o) + } + + fn merge(&mut self, v: &Value) -> Result<(), String> { + const C: &str = "ratingStars"; + for (k, v) in object(C, v)? { + if self.place.merge(C, k, v)? { + continue; + } + match k.as_str() { + "enabled" => self.enabled = boolean(C, k, v)?, + "color" => self.color = colour(C, k, v)?, + "size" => self.size = number(C, k, v)?, + _ => return Err(unknown(C, k, "enabled, color, size, anchor, offsetX, offsetY, relativeTo")), + } + } + Ok(()) + } +} + +impl TextOverlay { + fn sanitized(mut self) -> Self { + let d = TextOverlay::default(); + cut(&mut self.text); + self.size = clamp_or(self.size, SIZE_MIN, TEXT_SIZE_MAX, d.size); + self.opacity = clamp_or(self.opacity, 0.0, 1.0, d.opacity); + self.place = self.place.sanitized(); + let ds = TextShadow::default(); + let sh = &mut self.shadow; + sh.opacity = clamp_or(sh.opacity, 0.0, 1.0, ds.opacity); + sh.offset = clamp_or(sh.offset, 0.0, SHADOW_OFFSET_MAX, ds.offset); + sh.radius = clamp_or(sh.radius, 0.0, SHADOW_RADIUS_MAX, ds.radius); + sh.angle = wrap_angle(sh.angle, ds.angle); + self + } + + fn to_json(&self) -> Value { + let mut o = Map::new(); + o.insert("text".into(), json!(self.text)); + o.insert("size".into(), json!(self.size)); + o.insert("color".into(), json!(hex(self.color))); + o.insert("opacity".into(), json!(self.opacity)); + self.place.json(&mut o); + o.insert("shadow".into(), json!(self.shadow.on)); + o.insert("shadowOpacity".into(), json!(self.shadow.opacity)); + o.insert("shadowOffset".into(), json!(self.shadow.offset)); + o.insert("shadowRadius".into(), json!(self.shadow.radius)); + o.insert("shadowAngle".into(), json!(self.shadow.angle)); + Value::Object(o) + } + + fn merge(&mut self, ctx: &str, v: &Value) -> Result<(), String> { + for (k, v) in object(ctx, v)? { + if self.place.merge(ctx, k, v)? { + continue; + } + match k.as_str() { + "text" => self.text = string(ctx, k, v)?, + "size" => self.size = number(ctx, k, v)?, + "color" => self.color = colour(ctx, k, v)?, + "opacity" => self.opacity = number(ctx, k, v)?, + "shadow" => self.shadow.on = boolean(ctx, k, v)?, + "shadowOpacity" => self.shadow.opacity = number(ctx, k, v)?, + "shadowOffset" => self.shadow.offset = number(ctx, k, v)?, + "shadowRadius" => self.shadow.radius = number(ctx, k, v)?, + "shadowAngle" => self.shadow.angle = number(ctx, k, v)?, + _ => { + return Err(unknown( + ctx, + k, + "text, size, color, opacity, anchor, offsetX, offsetY, relativeTo, shadow, shadowOpacity, shadowOffset, shadowRadius, shadowAngle", + )); + } + } + } + Ok(()) + } +} + +/// The keys [`OverlaySettings::merged`] takes. +const KEYS: &str = "identityPlate, ratingStars, texts, addText, removeText, showWatermark, watermark"; + +impl OverlaySettings { + /// Every value in range (non-finite numbers take the defaults), at most [`MAX_TEXTS`] texts. + pub fn sanitized(mut self) -> Self { + self.identity_plate = self.identity_plate.sanitized(); + self.rating_stars = self.rating_stars.sanitized(); + self.texts.truncate(MAX_TEXTS); + self.texts = self.texts.into_iter().map(TextOverlay::sanitized).collect(); + let d = Watermark::default(); + let w = &mut self.watermark; + let f = |v: f32, lo: f32, hi: f32, fb: f32| if v.is_finite() { v.clamp(lo, hi) } else { fb }; + w.size = f(w.size, 0.005, 0.5, d.size); + w.opacity = f(w.opacity, 0.0, 1.0, d.opacity); + w.inset = f(w.inset, 0.0, 0.4, d.inset); + w.image_width = f(w.image_width, 0.01, 1.0, d.image_width); + cut(&mut w.text); + w.target = None; + self + } + + /// Does any overlay draw (on some photo)? + pub fn any(&self) -> bool { + let p = &self.identity_plate; + (p.enabled && p.opacity > 0.0 && (!p.text.trim().is_empty() || !p.image.trim().is_empty())) + || self.rating_stars.enabled + || self.texts.iter().any(|t| t.opacity > 0.0 && !t.text.trim().is_empty()) + || (self.show_watermark && (!self.watermark.text.trim().is_empty() || !self.watermark.image.trim().is_empty())) + } + + pub fn to_json(&self) -> Value { + let wm = serde_json::to_value(&self.watermark).unwrap_or(Value::Null); + json!({ + "identityPlate": self.identity_plate.to_json(), + "ratingStars": self.rating_stars.to_json(), + "texts": self.texts.iter().map(TextOverlay::to_json).collect::>(), + "showWatermark": self.show_watermark, + "watermark": wm, + }) + } + + /// These settings with the keys `p` names changed, validated and clamped; a wrong type or an + /// unknown key is an error and nothing changes. `texts` replaces the list (each entry merged + /// onto the one at its index, or a new default text); `addText` appends one (an object, `{}` + /// for the default); `removeText` removes the one at an index; `watermark` takes the export + /// watermark's keys (or text alone) merged onto the current one. + pub fn merged(&self, p: &Value) -> Result { + let Some(obj) = p.as_object() else { return Err("parameters must be an object".into()) }; + let mut s = self.clone(); + for (k, v) in obj { + match k.as_str() { + "identityPlate" => s.identity_plate.merge(v)?, + "ratingStars" => s.rating_stars.merge(v)?, + "texts" => { + let list = v.as_array().ok_or_else(|| format!("`texts` must be a list of text overlays, not {v}"))?; + if list.len() > MAX_TEXTS { + return Err(format!("at most {MAX_TEXTS} text overlays, not {}", list.len())); + } + let mut next = Vec::with_capacity(list.len()); + for (i, item) in list.iter().enumerate() { + let mut t = s.texts.get(i).cloned().unwrap_or_default(); + t.merge(&format!("texts[{i}]"), item)?; + next.push(t); + } + s.texts = next; + } + "addText" => { + if s.texts.len() >= MAX_TEXTS { + return Err(format!("at most {MAX_TEXTS} text overlays")); + } + let mut t = TextOverlay::default(); + t.merge("addText", v)?; + s.texts.push(t); + } + "removeText" => { + let i = v.as_u64().ok_or_else(|| format!("`removeText` must be the index of a text overlay, not {v}"))?; + let i = usize::try_from(i).ok().filter(|i| *i < s.texts.len()).ok_or_else(|| format!("no text overlay {i}"))?; + s.texts.remove(i); + } + "showWatermark" => s.show_watermark = boolean("", k, v).map_err(|_| format!("`showWatermark` must be true or false, not {v}"))?, + "watermark" => { + crate::export::ExportOptions::validate_watermark("slideshow.overlays", v).map_err(|e| e.to_string())?; + s.watermark = match v { + Value::String(t) => Watermark { text: t.clone(), ..s.watermark.clone() }, + _ => { + let mut cur = serde_json::to_value(&s.watermark).map_err(|e| e.to_string())?; + if let (Some(c), Some(o)) = (cur.as_object_mut(), v.as_object()) { + for (k, v) in o.iter().filter(|(_, v)| !v.is_null()) { + c.insert(k.clone(), v.clone()); + } + } + serde_json::from_value(cur).map_err(|e| format!("`watermark`: {e}"))? + } + }; + } + other => return Err(format!("unknown setting `{other}` ({KEYS})")), + } + } + Ok(s.sanitized()) + } + + /// A hash of the settings (for render keys). Stable within a run. + pub fn pixel_key(&self) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + self.clone().sanitized().to_json().to_string().hash(&mut h); + h.finish() + } + + /// Problems worth telling the user about: an identity plate image that can't be read, tags in + /// text overlays that aren't tokens (they show as typed). + pub fn warnings(&self) -> Vec { + let mut out = Vec::new(); + let p = &self.identity_plate; + if p.enabled && !p.image.trim().is_empty() && crate::export::watermark_logo(p.image.trim()).is_none() { + out.push(format!("identity plate image {} can't be read; nothing is drawn", p.image.trim())); + } + for (i, t) in self.texts.iter().enumerate() { + for tag in unknown_tokens(&t.text) { + out.push(format!("text overlay {}: {tag} isn't a token and shows as typed", i + 1)); + } + } + out + } +} + +/// An exposure as "1/250 s at f/8" (either part alone when the other is unknown). +fn exposure(p: &Photo) -> String { + let parts: Vec = [shutter(p), aperture(p)].into_iter().filter(|s| !s.is_empty()).collect(); + parts.join(" at ") +} + +fn shutter(p: &Photo) -> String { + let s = p.meta.shutter.trim(); + if s.is_empty() || s.ends_with('s') || s.ends_with('"') { s.to_string() } else { format!("{s} s") } +} + +fn aperture(p: &Photo) -> String { + p.meta.aperture.filter(|a| a.is_finite() && *a > 0.0).map(|a| format!("f/{a:.1}").replace(".0", "")).unwrap_or_default() +} + +/// The overlay tokens beyond the rename ones; `{seq}` has no meaning on a slide and stays as typed. +fn overlay_token(p: &Photo, name: &str, arg: Option<&str>, tag: &str) -> Option { + Some(match name { + "file" => p.file_name.clone(), + "caption" => p.meta.caption.clone(), + "copyright" => p.meta.copyright.clone(), + "exposure" => exposure(p), + "shutter" => shutter(p), + "aperture" => aperture(p), + "focal" => p.meta.focal_mm.filter(|f| f.is_finite() && *f > 0.0).map(|f| format!("{f:.0} mm")).unwrap_or_default(), + "date" if arg.is_none() => crate::rename::expand_tokens("{date:%Y-%m-%d}", p, 1, 1), + "seq" | "n" => format!("{{{tag}}}"), + _ => return None, + }) +} + +/// `template`'s tokens replaced for `p`: the rename tokens ([`crate::rename::expand_tokens`]) and +/// [`TOKENS`]. Unknown tags stay as typed. +pub fn expand(template: &str, p: &Photo) -> String { + crate::rename::expand_tokens_with(template, p, 1, 1, &|name, arg, tag| overlay_token(p, name, arg, tag)) +} + +/// The `{…}` tags in `template` that aren't tokens. +pub fn unknown_tokens(template: &str) -> Vec { + let p = crate::rename::sample_photo(); + let mut out = Vec::new(); + let mut rest = template; + while let Some(i) = rest.find('{') { + let Some(after) = rest.get(i..) else { break }; + let Some(j) = after.find('}') else { break }; + let Some(tag) = after.get(..j + 1) else { break }; + if expand(tag, &p) == tag && !out.iter().any(|t| t == tag) { + out.push(tag.to_string()); + } + rest = after.get(j + 1..).unwrap_or(""); + } + out +} + +/// What an overlay box is, for [`SlideOverlays::boxes`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Kind { + IdentityPlate, + RatingStars, + /// The text overlay at this index. + Text(usize), + Watermark, +} + +impl Kind { + pub fn id(&self) -> String { + match self { + Kind::IdentityPlate => "identityPlate".into(), + Kind::RatingStars => "ratingStars".into(), + Kind::Text(i) => format!("text{i}"), + Kind::Watermark => "watermark".into(), + } + } +} + +/// The overlays of one photo's slide: the settings, its texts expanded, its rating. +#[derive(Clone, Debug, PartialEq)] +pub struct SlideOverlays { + pub settings: OverlaySettings, + /// One per text overlay, tokens replaced. + pub texts: Vec, + pub rating: u8, +} + +/// Gap between stars, as a share of a star's size. +const STAR_GAP: f64 = 0.2; + +impl SlideOverlays { + /// `settings` resolved for `photo` (`None`: no photo, so no tokens and no rating). + pub fn resolve(settings: &OverlaySettings, photo: Option<&Photo>) -> SlideOverlays { + let settings = settings.clone().sanitized(); + let texts = settings.texts.iter().map(|t| photo.map(|p| expand(&t.text, p)).unwrap_or_else(|| t.text.clone())).collect(); + SlideOverlays { settings, texts, rating: photo.map_or(0, |p| p.rating.min(5)) } + } + + /// What makes the drawn overlays differ (for render keys). + pub fn key(&self) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + self.settings.pixel_key().hash(&mut h); + self.texts.hash(&mut h); + self.rating.hash(&mut h); + h.finish() + } + + fn short(l: &Layout) -> f64 { + l.slide.width().min(l.slide.height()).max(1.0) + } + + /// The identity plate's text layout, or its image scaled to its size. + fn plate(&self, l: &Layout) -> Option { + let p = &self.settings.identity_plate; + if !p.enabled || p.opacity <= 0.0 { + return None; + } + let short = Self::short(l); + let px = (p.size * short).max(1.0); + if !p.image.trim().is_empty() { + let img = crate::export::watermark_logo(p.image.trim())?; + if img.width == 0 || img.height == 0 { + return None; + } + let h = px.round().clamp(1.0, l.slide.height().max(1.0)); + let w = (h * img.width as f64 / img.height as f64).round().clamp(1.0, l.slide.width().max(1.0)); + return Some(PlateContent::Image(img, w as usize, h as usize)); + } + let text = p.text.trim(); + crate::text::TextLayout::new(text, px as f32, false, crate::fonts::CRAFT_FONTS).map(PlateContent::Text) + } + + fn text_layout(&self, i: usize, l: &Layout) -> Option { + let t = self.settings.texts.get(i)?; + let text = self.texts.get(i)?.trim(); + if t.opacity <= 0.0 { + return None; + } + crate::text::TextLayout::new(text, (t.size * Self::short(l)).max(1.0) as f32, false, crate::fonts::CRAFT_FONTS) + } + + fn stars_size(&self, l: &Layout) -> Option<(usize, f64)> { + let r = &self.settings.rating_stars; + let n = usize::from(self.rating.min(5)); + (r.enabled && n > 0).then(|| (n, (r.size * Self::short(l)).max(2.0))) + } + + /// Where each overlay goes on the slide `l` (the ones that draw something). + pub fn boxes(&self, l: &Layout) -> Vec<(Kind, Rect)> { + let mut out = Vec::new(); + let s = &self.settings; + if let Some(c) = self.plate(l) { + let (w, h) = c.size(); + out.push((Kind::IdentityPlate, s.identity_plate.place.place(l, w, h))); + } + if let Some((n, size)) = self.stars_size(l) { + let w = n as f64 * size + (n as f64 - 1.0) * size * STAR_GAP; + out.push((Kind::RatingStars, s.rating_stars.place.place(l, w, size))); + } + for (i, t) in s.texts.iter().enumerate() { + if let Some(tl) = self.text_layout(i, l) { + out.push((Kind::Text(i), t.place.place(l, tl.width as f64, tl.height as f64))); + } + } + if s.show_watermark && (!s.watermark.text.trim().is_empty() || !s.watermark.image.trim().is_empty()) { + out.push((Kind::Watermark, l.visible)); + } + out + } + + /// Draw the overlays drawn behind the photo (an identity plate with "render behind image"). + pub fn draw_behind(&self, img: &mut Rgba8, l: &Layout) { + if self.settings.identity_plate.behind { + self.draw_plate(img, l); + } + } + + /// Draw the overlays over the composed slide: the watermark on the photo, then the identity + /// plate (unless it is behind), the rating stars and the text overlays. + pub fn draw_front(&self, img: &mut Rgba8, l: &Layout) { + let s = &self.settings; + if s.show_watermark { + let (x0, y0, x1, y1) = pixels(&l.visible, img.width, img.height); + if x1 > x0 && y1 > y0 { + crate::export::draw_watermark_in(img, &s.watermark, x0, y0, x1 - x0, y1 - y0); + } + } + if !s.identity_plate.behind { + self.draw_plate(img, l); + } + if let Some((n, size)) = self.stars_size(l) { + let w = n as f64 * size + (n as f64 - 1.0) * size * STAR_GAP; + let b = s.rating_stars.place.place(l, w, size); + for i in 0..n { + let x = b.x0 + i as f64 * size * (1.0 + STAR_GAP); + star(img, x, b.y0, size, s.rating_stars.color); + } + } + for (i, t) in s.texts.iter().enumerate() { + let Some(tl) = self.text_layout(i, l) else { continue }; + let b = t.place.place(l, tl.width as f64, tl.height as f64); + let short = Self::short(l); + if t.shadow.on && t.shadow.opacity > 0.0 { + let (d, a) = (t.shadow.offset * short, t.shadow.angle.to_radians()); + let (dx, dy) = (d * a.cos(), -d * a.sin()); + shadow(img, &tl, b.x0 + dx, b.y0 + dy, t.shadow.radius * short / 2.0, (t.shadow.opacity * t.opacity) as f32); + } + draw_text(img, &tl, b.x0, b.y0, t.color, t.opacity as f32); + } + } + + fn draw_plate(&self, img: &mut Rgba8, l: &Layout) { + let p = &self.settings.identity_plate; + let Some(c) = self.plate(l) else { return }; + let (w, h) = c.size(); + let b = p.place.place(l, w, h); + match c { + PlateContent::Text(tl) => draw_text(img, &tl, b.x0, b.y0, p.color, p.opacity as f32), + PlateContent::Image(src, w, h) => { + if let Some(scaled) = scaled_graphic(p.image.trim(), &src, w, h) { + draw_graphic(img, &scaled, b.x0.round() as i64, b.y0.round() as i64, p.opacity as f32); + } + } + } + } +} + +enum PlateContent { + Text(crate::text::TextLayout), + Image(Arc, usize, usize), +} + +impl PlateContent { + fn size(&self) -> (f64, f64) { + match self { + PlateContent::Text(t) => (t.width as f64, t.height as f64), + PlateContent::Image(_, w, h) => (*w as f64, *h as f64), + } + } +} + +/// Whole pixel bounds of `r` clipped to `w × h`. +fn pixels(r: &Rect, w: usize, h: usize) -> (usize, usize, usize, usize) { + let c = |v: f64, max: usize| if v.is_finite() { v.round().clamp(0.0, max as f64) as usize } else { 0 }; + let (x0, y0) = (c(r.x0, w), c(r.y0, h)); + (x0, y0, c(r.x1, w).max(x0), c(r.y1, h).max(y0)) +} + +/// Blend `c` into pixel (`x`, `y`) at `k` (0–1); outside the image does nothing. +fn blend(img: &mut Rgba8, x: i64, y: i64, k: f32, c: [u8; 3]) { + if x < 0 || y < 0 || k <= 0.0 { + return; + } + let (x, y) = (x as usize, y as usize); + if x >= img.width || y >= img.height { + return; + } + let w = img.width; + if let Some(p) = img.data.get_mut(y * w + x) { + let k = k.min(1.0); + for i in 0..3 { + p[i] = (p[i] as f32 + (c[i] as f32 - p[i] as f32) * k).round().clamp(0.0, 255.0) as u8; + } + } +} + +fn draw_text(img: &mut Rgba8, tl: &crate::text::TextLayout, x: f64, y: f64, c: [u8; 3], opacity: f32) { + let (w, h) = (img.width as f32, img.height as f32); + tl.draw_in(x as f32, y as f32, w, h, |gx, gy, cov| blend(img, i64::from(gx), i64::from(gy), cov * opacity, c)); +} + +/// A soft black shadow of the text at (`x`, `y`): its coverage blurred by `sigma` pixels. Only +/// the part that can reach the image is rasterised. +fn shadow(img: &mut Rgba8, tl: &crate::text::TextLayout, x: f64, y: f64, sigma: f64, opacity: f32) { + let pad = (sigma * 3.0).ceil().clamp(0.0, 512.0); + let (iw, ih) = (img.width as f64, img.height as f64); + let mx0 = (x - pad).floor().max(-pad); + let my0 = (y - pad).floor().max(-pad); + let mx1 = (x + tl.width as f64 + pad).ceil().min(iw + pad); + let my1 = (y + tl.height as f64 + pad).ceil().min(ih + pad); + if !(mx1 > mx0 && my1 > my0) || !mx0.is_finite() || !my0.is_finite() { + return; + } + let (w, h) = ((mx1 - mx0) as usize, (my1 - my0) as usize); + let mut mask = Plane::new(w, h); + tl.draw_in((x - mx0) as f32, (y - my0) as f32, w as f32, h as f32, |gx, gy, cov| { + if gx >= 0 + && gy >= 0 + && (gx as usize) < w + && (gy as usize) < h + && let Some(m) = mask.data.get_mut(gy as usize * w + gx as usize) + { + *m = (*m + cov).min(1.0); + } + }); + let mask = if sigma > 0.3 { lightcraft_raster::blur::gaussian(&mask, sigma as f32) } else { mask }; + let (ox, oy) = (mx0 as i64, my0 as i64); + for (i, k) in mask.data.iter().enumerate() { + if *k > 1e-3 { + blend(img, ox + (i % w) as i64, oy + (i / w) as i64, k * opacity, [0, 0, 0]); + } + } +} + +/// The identity plate image scaled to its size, premultiplied: (file, its decoded image, w, h) → +/// pixels, so the slides of a slideshow don't each scale a large logo again. +type ScaledCache = std::sync::Mutex>)>>; +static SCALED: ScaledCache = std::sync::Mutex::new(None); + +/// `src` (decoded from `path`) scaled to `w × h` (premultiplied, so transparent edges stay clean). +fn scaled_graphic(path: &str, src: &Arc, w: usize, h: usize) -> Option>> { + if src.width == 0 || src.height == 0 || w == 0 || h == 0 || src.data.len() != src.width * src.height { + return None; + } + let id = Arc::as_ptr(src) as usize; + let mut cache = SCALED.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some((p, i, cw, ch, img)) = cache.as_ref() + && (p.as_str(), *i, *cw, *ch) == (path, id, w, h) + { + return Some(img.clone()); + } + let pre: Image<[f32; 4]> = Image::from_fn(src.width, src.height, |x, y| { + let p = src.get(x, y); + let a = p[3] as f32 / 255.0; + [p[0] as f32 * a, p[1] as f32 * a, p[2] as f32 * a, a] + }); + let scaled = Arc::new(lightcraft_raster::resample::resize(&pre, w, h, lightcraft_raster::resample::Filter::Mitchell)); + *cache = Some((path.to_string(), id, w, h, scaled.clone())); + Some(scaled) +} + +/// The scaled graphic at (`x`, `y`), blended with its alpha × `opacity`. +fn draw_graphic(img: &mut Rgba8, scaled: &Image<[f32; 4]>, x: i64, y: i64, opacity: f32) { + for yy in 0..scaled.height { + for xx in 0..scaled.width { + let [r, g, b, a] = scaled.get(xx, yy); + let a = a.clamp(0.0, 1.0); + if a <= 1e-4 { + continue; + } + let c = [r, g, b].map(|v| (v / a).round().clamp(0.0, 255.0) as u8); + blend(img, x + xx as i64, y + yy as i64, a * opacity, c); + } + } +} + +/// The ten points of a five-pointed star in a `size` box at (`x`, `y`), point up. +fn star_points(x: f64, y: f64, size: f64) -> [(f64, f64); 10] { + let r = size / 2.0; + // the star's height is r (1 + cos 36°); centre it vertically in the box + let cy = y + size / 2.0 + r * (1.0 - (36f64).to_radians().cos()) / 2.0; + let cx = x + size / 2.0; + let inner = r * 0.382; + let mut pts = [(0.0, 0.0); 10]; + for (i, p) in pts.iter_mut().enumerate() { + let a = (90.0 + 36.0 * i as f64).to_radians(); + let rr = if i % 2 == 0 { r } else { inner }; + *p = (cx + rr * a.cos(), cy - rr * a.sin()); + } + pts +} + +/// A filled star: even-odd scanlines, four a pixel row, with exact horizontal coverage. +fn star(img: &mut Rgba8, x: f64, y: f64, size: f64, c: [u8; 3]) { + if !(x.is_finite() && y.is_finite() && size.is_finite() && size > 0.0) { + return; + } + let pts = star_points(x, y, size); + let (x0, y0) = (x.floor() as i64, y.floor() as i64); + let n = size.ceil() as usize + 2; + let mut row = vec![0f32; n]; + let mut xs: Vec = Vec::with_capacity(pts.len()); + for py in y0..y0 + n as i64 { + row.fill(0.0); + for s in 0..4 { + let sy = py as f64 + (s as f64 + 0.5) / 4.0; + xs.clear(); + for (i, &(xi, yi)) in pts.iter().enumerate() { + let Some(&(xj, yj)) = pts.get((i + pts.len() - 1) % pts.len()) else { continue }; + if (yi > sy) != (yj > sy) { + xs.push((xj - xi) * (sy - yi) / (yj - yi) + xi); + } + } + xs.sort_by(f64::total_cmp); + for &[a, b] in xs.as_chunks::<2>().0 { + let (a, b) = (a - x0 as f64, b - x0 as f64); + let first = a.floor().max(0.0) as usize; + let last = (b.ceil().max(0.0) as usize).min(n); + for (i, v) in row.iter_mut().enumerate().take(last).skip(first) { + let cover = (b.min(i as f64 + 1.0) - a.max(i as f64)).max(0.0); + *v += cover as f32 / 4.0; + } + } + } + for (i, k) in row.iter().enumerate() { + if *k > 0.0 { + blend(img, x0 + i as i64, py, k.min(1.0), c); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn layout(w: usize, h: usize) -> Layout { + lightcraft_slideshow::layout(&lightcraft_slideshow::SlideSettings::default().merged(&json!({"margins": 0.1})).unwrap(), 1.5, w, h) + } + + fn photo() -> Photo { + let mut p = crate::rename::sample_photo(); + p.rating = 3; + p.meta.caption = "Morning fog".into(); + p.meta.shutter = "1/250".into(); + p.meta.aperture = Some(8.0); + p.meta.focal_mm = Some(50.0); + p + } + + #[test] + fn tokens_expand_like_the_rename_tokens_plus_slide_ones() { + let p = photo(); + assert_eq!(expand("{caption}", &p), "Morning fog"); + assert_eq!(expand("{exposure}", &p), "1/250 s at f/8"); + assert_eq!(expand("{focal} · ISO {iso}", &p), format!("50 mm · ISO {}", p.meta.iso.unwrap_or_default())); + assert_eq!(expand("{file}", &p), p.file_name); + assert_eq!(expand("{name}", &p), crate::rename::expand_tokens("{name}", &p, 1, 1), "rename tokens work"); + assert_eq!(expand("{date}", &p), crate::rename::expand_tokens("{date:%Y-%m-%d}", &p, 1, 1)); + assert_eq!(expand("{rating}", &p), "3"); + // unknown and slide-less tags show as typed, and are reported + assert_eq!(expand("{sparkle} {seq} {", &p), "{sparkle} {seq} {"); + assert_eq!(unknown_tokens("{title} {sparkle} {seq} {é}"), vec!["{sparkle}", "{seq}", "{é}"]); + // a photo without metadata: empty values, no panic + let mut bare = p.clone(); + bare.meta = Default::default(); + assert_eq!(expand("[{exposure}|{camera}|{caption}|{focal}]", &bare), "[|||]"); + } + + #[test] + fn nine_anchors_place_inside_the_slide_with_their_offsets() { + let l = layout(1000, 600); + let short = 600.0; + for anchor in ANCHORS { + for relative_to in [RelativeTo::Slide, RelativeTo::Photo] { + let at = |ox: f64, oy: f64| Placement { anchor, offset_x: ox, offset_y: oy, relative_to }.place(&l, 100.0, 40.0); + let b = at(0.0, 0.0); + assert!(b.x0 >= 0.0 && b.y0 >= 0.0 && b.x1 <= 1000.0 && b.y1 <= 600.0, "{anchor:?} {relative_to:?}: {b:?}"); + assert_eq!((b.width(), b.height()), (100.0, 40.0)); + let c = if relative_to == RelativeTo::Slide { l.slide } else { l.visible }; + let (cx, cy) = ((b.x0 + b.x1) / 2.0, (b.y0 + b.y1) / 2.0); + let id = anchor_id(anchor); + let want_x = if id.ends_with("eft") { + c.x0 + 50.0 + } else if id.ends_with("ight") { + c.x1 - 50.0 + } else { + (c.x0 + c.x1) / 2.0 + }; + let want_y = if id.starts_with("top") { + c.y0 + 20.0 + } else if id.starts_with("bottom") { + c.y1 - 20.0 + } else { + (c.y0 + c.y1) / 2.0 + }; + assert!((cx - want_x).abs() < 1e-9 && (cy - want_y).abs() < 1e-9, "{id} {relative_to:?}: {b:?}"); + // an inward offset moves it by that share of the shorter side + let (sx, sy) = (if id.ends_with("ight") { -0.02 } else { 0.02 }, if id.starts_with("bottom") { -0.03 } else { 0.03 }); + let m = at(sx, sy); + assert!((m.x0 - b.x0 - sx * short).abs() < 1e-9 && (m.y0 - b.y0 - sy * short).abs() < 1e-9, "{id}: {m:?}"); + } + } + // pushed off the slide: kept on it + let b = Placement { anchor: Anchor::BottomRight, offset_x: 0.5, offset_y: 0.5, relative_to: RelativeTo::Slide }.place(&l, 100.0, 40.0); + assert_eq!((b.x1, b.y1), (1000.0, 600.0)); + } + + fn blank(w: usize, h: usize) -> Rgba8 { + Image::filled(w, h, [0, 0, 0, 255]) + } + + fn lit(img: &Rgba8, r: &Rect, min: u8) -> usize { + let (x0, y0, x1, y1) = pixels(r, img.width, img.height); + (y0..y1).flat_map(|y| (x0..x1).map(move |x| (x, y))).filter(|&(x, y)| img.get(x, y)[1] >= min).count() + } + + #[test] + fn rating_stars_count_matches_the_rating() { + let l = layout(800, 500); + let s = OverlaySettings::default().merged(&json!({"ratingStars": {"enabled": true, "color": "#00ff00", "size": 0.05}})).unwrap(); + let mut p = photo(); + let mut widths = Vec::new(); + for rating in 0..=5u8 { + p.rating = rating; + let o = SlideOverlays::resolve(&s, Some(&p)); + let boxes = o.boxes(&l); + let mut img = blank(800, 500); + o.draw_front(&mut img, &l); + let Some((_, b)) = boxes.iter().find(|(k, _)| *k == Kind::RatingStars) else { + assert_eq!(rating, 0, "no stars only for an unrated photo"); + assert_eq!(lit(&img, &l.slide, 1), 0); + continue; + }; + widths.push(b.width()); + // count the stars: separate runs of lit columns along the box's middle row + let y = ((b.y0 + b.y1) / 2.0) as usize; + let mut runs = 0; + let mut was = false; + for x in 0..800 { + let on = img.get(x, y)[1] > 128; + runs += usize::from(on && !was); + was = on; + } + assert_eq!(runs, usize::from(rating), "{rating} stars"); + } + assert!(widths.windows(2).all(|w| w[1] > w[0])); + } + + #[test] + fn text_with_a_shadow_draws_the_shadow_at_its_angle() { + let l = layout(800, 500); + let s = OverlaySettings::default() + .merged( + &json!({"texts": [{"text": "Shadow", "size": 0.08, "color": "#ffffff", "opacity": 1, "anchor": "center", "offsetX": 0, "offsetY": 0, + "shadow": true, "shadowOpacity": 1, "shadowOffset": 0.02, "shadowRadius": 0, "shadowAngle": -90}]}), + ) + .unwrap(); + let o = SlideOverlays::resolve(&s, Some(&photo())); + let b = o.boxes(&l)[0].1; + let mut grey = Image::filled(800, 500, [128, 128, 128, 255]); + o.draw_front(&mut grey, &l); + // straight down by 10 px: dark pixels below the text box's glyphs, none above + let dark = |r: Rect| { + let (x0, y0, x1, y1) = pixels(&r, 800, 500); + (y0..y1).flat_map(|y| (x0..x1).map(move |x| (x, y))).filter(|&(x, y)| grey.get(x, y)[0] < 40).count() + }; + assert!(dark(Rect { y0: b.y1 - 2.0, y1: b.y1 + 10.0, ..b }) > 20, "the shadow falls below"); + assert_eq!(dark(Rect { y0: b.y0 - 12.0, y1: b.y0, ..b }), 0, "none above"); + // without the shadow: no dark pixels at all + let off = s.merged(&json!({"texts": [{"shadow": false}]})).unwrap(); + let mut grey2 = Image::filled(800, 500, [128, 128, 128, 255]); + SlideOverlays::resolve(&off, Some(&photo())).draw_front(&mut grey2, &l); + assert!(grey2.data.iter().all(|p| p[0] >= 128)); + } + + #[test] + fn identity_plate_image_draws_at_its_opacity_and_a_missing_one_is_reported() { + let dir = std::env::temp_dir().join(format!("lc-plate-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("plate.png"); + let red = Image::filled(40, 20, [255u8, 0, 0, 255]); + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&red), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::write(&path, png).unwrap(); + let l = layout(800, 500); + let s = OverlaySettings::default() + .merged(&json!({"identityPlate": {"enabled": true, "image": path.to_string_lossy(), "size": 0.1, "opacity": 0.5, "anchor": "topLeft", "offsetX": 0, "offsetY": 0}})) + .unwrap(); + assert!(s.warnings().is_empty()); + let o = SlideOverlays::resolve(&s, None); + let b = o.boxes(&l)[0].1; + assert_eq!((b.x0, b.y0, b.width(), b.height()), (0.0, 0.0, 100.0, 50.0), "50 px high (10 % of 500), its aspect kept"); + let mut img = blank(800, 500); + o.draw_front(&mut img, &l); + let p = img.get(50, 25); + assert!((p[0] as i32 - 128).abs() <= 2 && p[1] == 0, "half-opaque red over black: {p:?}"); + assert_eq!(img.get(150, 25)[0], 0, "only inside its box"); + // behind the image: the photo covers it + let behind = s.merged(&json!({"identityPlate": {"behind": true, "anchor": "center"}})).unwrap(); + let photo_img = Image::filled(30, 20, [0u8, 0, 255, 255]); + let ob = SlideOverlays::resolve(&behind, None); + let (img, l2) = + lightcraft_slideshow::compose_with(&photo_img, None, &lightcraft_slideshow::SlideSettings::default(), 800, 500, &mut |i, l| { + ob.draw_behind(i, l) + }); + let _ = l2; + assert_eq!(img.get(400, 250), [0, 0, 255, 255], "the photo is on top"); + // a missing file: reported, nothing drawn, no panic + let gone = s.merged(&json!({"identityPlate": {"image": dir.join("nope.png").to_string_lossy()}})).unwrap(); + assert_eq!(gone.warnings().len(), 1); + let mut img = blank(800, 500); + SlideOverlays::resolve(&gone, None).draw_front(&mut img, &l); + assert!(img.data.iter().all(|p| p[0] == 0)); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn overlays_are_resolution_independent() { + let s = OverlaySettings::default() + .merged(&json!({ + "identityPlate": {"enabled": true, "text": "Studio", "size": 0.06}, + "ratingStars": {"enabled": true, "size": 0.04}, + "texts": [{"text": "{caption}", "size": 0.04, "anchor": "bottomLeft", "offsetX": 0.02, "offsetY": -0.02, "relativeTo": "photo"}], + })) + .unwrap(); + let o = SlideOverlays::resolve(&s, Some(&photo())); + let (a, b) = (o.boxes(&layout(800, 500)), o.boxes(&layout(1600, 1000))); + assert_eq!(a.len(), 3); + for ((ka, ra), (kb, rb)) in a.iter().zip(&b) { + assert_eq!(ka, kb); + for (va, vb) in [(ra.x0, rb.x0), (ra.y0, rb.y0), (ra.x1, rb.x1), (ra.y1, rb.y1)] { + assert!((va * 2.0 - vb).abs() <= 4.0, "{ka:?}: {ra:?} vs {rb:?}"); + } + } + } + + #[test] + fn hostile_settings_are_refused_or_clamped_and_never_panic() { + let d = OverlaySettings::default(); + for bad in [ + json!({"identityPlate": {"size": "big"}}), + json!({"identityPlate": {"anchor": "middle"}}), + json!({"identityPlate": 3}), + json!({"ratingStars": {"color": "#zzzzzz"}}), + json!({"texts": {"text": "a"}}), + json!({"texts": [{"relativeTo": "page"}]}), + json!({"texts": [{"sparkle": 1}]}), + json!({"texts": vec![json!({}); MAX_TEXTS + 1]}), + json!({"removeText": 0}), + json!({"removeText": -1}), + json!({"watermark": {"size": 9}}), + json!({"watermark": 5}), + json!({"showWatermark": "yes"}), + json!({"glitter": true}), + json!([1]), + ] { + assert!(d.merged(&bad).is_err(), "{bad}"); + } + let s = d + .merged(&json!({"identityPlate": {"size": 1e300, "opacity": -3, "offsetX": -1e9}, "ratingStars": {"size": 0}, "addText": {"size": -1, "shadowAngle": 900, "shadowRadius": 7}})) + .unwrap(); + assert_eq!((s.identity_plate.size, s.identity_plate.opacity, s.identity_plate.place.offset_x), (PLATE_SIZE_MAX, 0.0, -OFFSET_MAX)); + assert_eq!(s.rating_stars.size, SIZE_MIN); + assert_eq!((s.texts[0].size, s.texts[0].shadow.angle, s.texts[0].shadow.radius), (SIZE_MIN, 180.0, SHADOW_RADIUS_MAX)); + assert!(d.merged(&json!({"removeText": 0})).is_err()); + assert!(s.merged(&json!({"removeText": 0})).unwrap().texts.is_empty()); + // round trips (command JSON and prefs.json) + assert_eq!(d.merged(&s.to_json()).unwrap(), s); + let back: OverlaySettings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap(); + assert_eq!(back, s); + // NaN / ∞ from a hand-edited prefs file + let mut wild = s.clone(); + wild.identity_plate.size = f64::NAN; + wild.texts[0].place.offset_y = f64::INFINITY; + wild.watermark.size = f32::NAN; + let w = wild.sanitized(); + assert_eq!( + (w.identity_plate.size, w.texts[0].place.offset_y, w.watermark.size), + (IdentityPlate::default().size, 0.0, Watermark::default().size) + ); + // drawing hostile text at every size: empty, huge, CJK, RTL, control characters + let huge = "W".repeat(50_000); + for text in ["", " ", "\n\n", huge.as_str(), "日本語のテキスト", "שלום עולם", "مرحبا", "a\u{0}b\u{7}c\r\n", "{caption}{"] + { + let s = d + .merged(&json!({"identityPlate": {"enabled": true, "text": text, "size": PLATE_SIZE_MAX}, "ratingStars": {"enabled": true}, "addText": {"text": text, "size": TEXT_SIZE_MAX, "shadow": true, "shadowRadius": SHADOW_RADIUS_MAX}, "showWatermark": true, "watermark": text})) + .unwrap(); + assert!(s.identity_plate.text.chars().count() <= TEXT_MAX_CHARS); + let mut bare = photo(); + bare.meta = Default::default(); + for (w, h) in [(1, 1), (17, 9), (320, 200)] { + let l = lightcraft_slideshow::layout(&lightcraft_slideshow::SlideSettings::default(), f64::NAN, w, h); + let o = SlideOverlays::resolve(&s, Some(&bare)); + let mut img = blank(l.slide.width() as usize, l.slide.height() as usize); + o.draw_behind(&mut img, &l); + o.draw_front(&mut img, &l); + for (_, b) in o.boxes(&l) { + assert!(b.x0.is_finite() && b.y1.is_finite()); + } + } + } + } + + /// Huge text and stars on a big slide cost what shows: glyphs off the slide aren't rasterised. + #[test] + fn huge_overlays_on_a_big_slide_stay_cheap() { + let long = "Wide ".repeat(400); + let s = OverlaySettings::default() + .merged(&json!({ + "ratingStars": {"enabled": true, "size": STAR_SIZE_MAX}, + "addText": {"text": long, "size": TEXT_SIZE_MAX, "shadow": true, "shadowRadius": SHADOW_RADIUS_MAX, "anchor": "topLeft", "offsetX": 0, "offsetY": 0}, + })) + .unwrap(); + let mut p = photo(); + p.rating = 5; + let l = lightcraft_slideshow::layout(&lightcraft_slideshow::SlideSettings::default(), 1.5, 4000, 2500); + let mut img = blank(4000, 2500); + let t = std::time::Instant::now(); + SlideOverlays::resolve(&s, Some(&p)).draw_front(&mut img, &l); + assert!(t.elapsed().as_secs_f64() < 20.0, "{:?}", t.elapsed()); + assert!(img.get(100, 300)[0] > 0, "the text's start shows"); + } + + /// CJK text draws with the craft-fonts faces (skips without them). + #[test] + fn cjk_text_uses_the_craft_fonts() { + if crate::fonts::cjk(crate::fonts::CRAFT_FONTS).next().is_none() { + eprintln!("skipped: built without craft-fonts (CRAFT_FONTS_DIR)"); + return; + } + let cjk = crate::text::TextLayout::new("日本語", 40.0, false, crate::fonts::CRAFT_FONTS).unwrap(); + let tofu = crate::text::TextLayout::new("日本語", 40.0, false, &[]).unwrap(); + let count = |t: &crate::text::TextLayout| { + let mut n = 0usize; + t.draw(0.0, 0.0, |_, _, c| n += usize::from(c > 0.5)); + n + }; + assert_ne!(count(&cjk), count(&tofu), "real glyphs, not missing-glyph boxes"); + } +} diff --git a/crates/engine/src/tests_slide.rs b/crates/engine/src/tests_slide.rs index 84a8b5274..3e41f16ee 100644 --- a/crates/engine/src/tests_slide.rs +++ b/crates/engine/src/tests_slide.rs @@ -137,3 +137,93 @@ fn a_background_image_and_the_slide_key() { let img = s.slide_now(&gone, photos[0], 320, 200).unwrap(); assert_eq!(img.get(3, 3), [255, 0, 0, 255]); } + +#[test] +fn slide_overlays_are_validated_and_persist_with_the_library() { + let lib = temp_dir("overlays"); + { + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + let r = s.execute("slideshow.overlays", &json!({})).unwrap(); + assert_eq!(r["identityPlate"]["enabled"], false, "defaults: {r}"); + assert_eq!(r["texts"], json!([])); + assert!(s.slides_plain()); + let r = s + .execute( + "slideshow.overlays", + &json!({"identityPlate": {"enabled": true, "text": "Studio Nord"}, "ratingStars": {"enabled": true}, "addText": {"text": "{caption} {bogus}"}, "showWatermark": true, "watermark": "© Ann"}), + ) + .unwrap(); + assert_eq!(r["texts"].as_array().map(Vec::len), Some(1)); + assert_eq!(r["watermark"]["text"], "© Ann"); + assert_eq!(r["warnings"].as_array().map(Vec::len), Some(1), "the unknown token is reported: {r}"); + assert!(!s.slides_plain(), "overlays make playback compose its slides"); + // a bad value changes nothing (the good keys beside it included) + for bad in + [json!({"identityPlate": {"size": "big"}, "showWatermark": false}), json!({"texts": [{"anchor": "nowhere"}]}), json!({"removeText": 5})] + { + assert!(s.execute("slideshow.overlays", &bad).is_err(), "{bad}"); + } + assert!(s.slide_overlays.show_watermark); + drop(s); + } + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + let r = s.execute("slideshow.overlays", &json!({})).unwrap(); + assert_eq!((r["identityPlate"]["text"].as_str(), r["texts"][0]["text"].as_str()), (Some("Studio Nord"), Some("{caption} {bogus}"))); + // the layout beside it is untouched by an overlay change + let before = s.slide_layout.clone(); + s.execute("slideshow.overlays", &json!({"removeText": 0})).unwrap(); + assert_eq!(s.slide_layout, before); + drop(s); + // hand-edited values out of range are clamped when the library opens + std::fs::write(lib.join("prefs.json"), r#"{"slideOverlays": {"identityPlate": {"size": 1e9, "opacity": -2}, "texts": [{"size": 1e300}]}}"#) + .unwrap(); + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + assert_eq!((s.slide_overlays.identity_plate.size, s.slide_overlays.identity_plate.opacity), (crate::slide_overlay::PLATE_SIZE_MAX, 0.0)); + assert_eq!(s.slide_overlays.texts[0].size, crate::slide_overlay::TEXT_SIZE_MAX); + drop(s); + let _ = std::fs::remove_dir_all(&lib); +} + +#[test] +fn slide_preview_draws_the_overlays() { + let mut s = Session::with_demo(); + let photos = s.visible_cloned(); + s.execute("library.select", &json!({"ids": [photos[0].0]})).unwrap(); + s.execute("photo.rate", &json!({"rating": 4, "ids": [photos[0].0]})).unwrap(); + s.execute("slideshow.layout", &json!({"margins": 0.2, "backdropColor": "#000000"})).unwrap(); + let before = s.slide_job(photos[0], 320, 200, None).unwrap().key; + let plain = s.slide_now(&s.slide_layout.clone(), photos[0], 640, 400).unwrap(); + s.execute( + "slideshow.overlays", + &json!({ + "identityPlate": {"enabled": true, "text": "LC", "size": 0.1, "color": "#00ff00", "opacity": 1, "anchor": "topLeft", "offsetX": 0.01, "offsetY": 0.01}, + "ratingStars": {"enabled": true, "color": "#ffff00", "size": 0.05, "anchor": "bottomRight", "offsetX": -0.01, "offsetY": -0.01, "relativeTo": "slide"}, + "addText": {"text": "{file}", "size": 0.05, "color": "#ffffff", "opacity": 1, "anchor": "bottom", "offsetX": 0, "offsetY": -0.01}, + }), + ) + .unwrap(); + assert_ne!(s.slide_job(photos[0], 320, 200, None).unwrap().key, before, "overlays change the slide's render key"); + let r = s.execute("slideshow.preview", &json!({"width": 640, "height": 400})).unwrap(); + let kinds: Vec<&str> = r["overlays"].as_array().unwrap().iter().filter_map(|o| o["kind"].as_str()).collect(); + assert_eq!(kinds, ["identityPlate", "ratingStars", "text0"]); + let img = s.slide_now(&s.slide_layout.clone(), photos[0], 640, 400).unwrap(); + let count = |img: &lightcraft_raster::Rgba8, b: &serde_json::Value, want: fn([u8; 4]) -> bool| { + let v: Vec = (0..4).map(|i| b[i].as_f64().unwrap() as usize).collect(); + (v[1]..v[3]).flat_map(|y| (v[0]..v[2]).map(move |x| (x, y))).filter(|&(x, y)| want(img.get(x, y))).count() + }; + for (i, (kind, want)) in [ + ("identityPlate", (|p: [u8; 4]| p[1] > 200 && p[0] < 60) as fn([u8; 4]) -> bool), + ("ratingStars", |p| p[0] > 200 && p[1] > 200 && p[2] < 60), + ("text0", |p| p[0] > 200 && p[1] > 200 && p[2] > 200), + ] + .into_iter() + .enumerate() + { + let b = &r["overlays"][i]["box"]; + assert!(count(&img, b, want) > 10, "{kind} is drawn in its box {b}"); + assert_eq!(count(&plain, b, want), 0, "{kind}: not there without overlays"); + } +} diff --git a/crates/engine/src/text.rs b/crates/engine/src/text.rs new file mode 100644 index 000000000..4c22789b1 --- /dev/null +++ b/crates/engine/src/text.rs @@ -0,0 +1,207 @@ +//! Text drawing in pure Rust, shared by the export watermark and the slideshow overlays: Inter +//! SemiBold (bundled) first, then the craft-fonts CJK faces as fallbacks ([`faces`]); glyph outlines +//! from `ab_glyph`, vertical CJK cells shaped with `harfrust`. [`TextLayout`] lays a string out +//! once (lines split at `\n`, kerning within a face) and reports its size, so callers can anchor +//! it before drawing; drawing hands out per-pixel coverage, and the caller blends. +//! +//! RTL scripts are laid out in logical order without bidi reordering, and scripts no face covers +//! draw as Inter's missing-glyph box: wrong-looking, never a crash. + +use ab_glyph::{Font, FontRef, Glyph, PxScale, ScaleFont, point}; + +/// Inter SemiBold (OFL, see assets/ATTRIBUTION.md). +pub static TEXT_FONT: &[u8] = include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf"); + +/// Most characters one layout draws (longer text is cut there), so hostile text stays cheap. +pub const MAX_CHARS: usize = 4000; + +/// The text faces: Inter first, then the craft-fonts CJK faces (Mincho first, the watermark's +/// serif look; then any other CJK face — drawn text has no UI language, so every CJK script is +/// offered). Without craft-fonts that is Inter alone, and CJK characters draw as Inter's +/// missing-glyph box. +pub fn faces(craft: &'static [crate::fonts::CraftFont]) -> Vec> { + let mut cjk: Vec<_> = crate::fonts::cjk(craft).collect(); + cjk.sort_by_key(|f| !f.is_mincho()); + std::iter::once(TEXT_FONT).chain(cjk.into_iter().map(|f| f.bytes)).filter_map(|bytes| FontRef::try_from_slice(bytes).ok()).collect() +} + +/// Shape a whole grapheme in one cell. CJK uses vertical origins and forms; Latin stays upright. +/// Unlike Unicode presentation-form characters, GSUB works with BIZ UD fonts too. +pub fn cell_glyphs(font: &FontRef<'_>, data: &harfrust::ShaperData, text: &str, vertical: bool, px: f32, left: f32, top: f32) -> Option> { + let face = harfrust::FontRef::new(font.font_data()).ok()?; + let mut buf = harfrust::UnicodeBuffer::new(); + buf.push_str(text); + buf.set_direction(if vertical { harfrust::Direction::TopToBottom } else { harfrust::Direction::LeftToRight }); + buf.guess_segment_properties(); + let shaped = data.shaper(&face).build().shape(buf, harfrust::ShapeOptions::new()); + if shaped.glyph_infos().is_empty() { + return None; + } + let sf = font.as_scaled(px); + let (sx, sy) = (sf.h_scale_factor(), sf.v_scale_factor()); + let advance_x = shaped.glyph_positions().iter().map(|p| p.x_advance as f32 * sx).sum::(); + let advance_y = shaped.glyph_positions().iter().map(|p| -(p.y_advance as f32) * sy).sum::(); + let (mut x, mut y) = if vertical { (left + px / 2.0, top + (px - advance_y) / 2.0) } else { (left + (px - advance_x) / 2.0, top + sf.ascent()) }; + let mut glyphs = Vec::new(); + for (info, pos) in shaped.glyph_infos().iter().zip(shaped.glyph_positions()) { + let gid = u16::try_from(info.glyph_id).ok().filter(|g| *g != 0)?; + let pen = point(x + pos.x_offset as f32 * sx, y - pos.y_offset as f32 * sy); + glyphs.push(ab_glyph::GlyphId(gid).with_scale_and_position(px, pen)); + x += pos.x_advance as f32 * sx; + y -= pos.y_advance as f32 * sy; + } + Some(glyphs) +} + +/// A string laid out at one size: its glyphs relative to the box's top-left corner and the box's +/// size in pixels (lines are `px` apart; vertical text: columns `px` wide, right to left). +pub struct TextLayout { + fonts: Vec>, + /// (face, glyph, rotation centre for glyphs turned 90° in vertical text). + glyphs: Vec<(usize, Glyph, Option<(f32, f32)>)>, + pub width: f32, + pub height: f32, +} + +impl TextLayout { + /// `text` (cut at [`MAX_CHARS`]) at `px` pixels a line; `None` when there is nothing to draw + /// (empty text, a non-finite or non-positive size). + pub fn new(text: &str, px: f32, vertical: bool, craft: &'static [crate::fonts::CraftFont]) -> Option { + use unicode_segmentation::UnicodeSegmentation; + if text.is_empty() || !px.is_finite() || px <= 0.0 { + return None; + } + let text = match text.char_indices().nth(MAX_CHARS) { + Some((i, _)) => text.get(..i).unwrap_or(text), + None => text, + }; + let fonts = faces(craft); + let latin = fonts.first()?.clone(); + let vertical_data: Vec<_> = if vertical { + fonts.iter().map(|font| harfrust::FontRef::new(font.font_data()).ok().map(|face| harfrust::ShaperData::new(&face))).collect() + } else { + Vec::new() + }; + let mut glyphs = Vec::new(); + let mut x = 0.0f32; + let mut y = 0.0f32; + let mut tw = 0.0f32; + let mut prev = None; + let columns = text.split('\n').count(); + let mut column = 0usize; + // Horizontal text keeps scalar layout and kerning; only vertical cells use graphemes. + let cells: Box> = + if vertical { Box::new(text.graphemes(true)) } else { Box::new(text.char_indices().filter_map(|(i, c)| text.get(i..i + c.len_utf8()))) }; + for cell in cells { + let Some(original) = cell.chars().next() else { continue }; + if cell == "\n" || (vertical && cell == "\r\n") { + if vertical { + column = column.saturating_add(1); + tw = tw.max(y); + y = 0.0; + } else { + tw = tw.max(x); + x = 0.0; + y += px; + } + prev = None; + continue; + } + if original.is_control() { + continue; + } + let cjk = matches!(original as u32, 0x3000..=0x30FF | 0x3400..=0x9FFF | 0xFF01..=0xFF60); + if vertical && (cjk || cell.chars().count() > 1) { + let left = columns.saturating_sub(column.saturating_add(1)) as f32 * px; + let shaped = fonts.iter().zip(&vertical_data).enumerate().find_map(|(face, (font, data))| { + let data = data.as_ref()?; + let cluster = cell_glyphs(font, data, cell, cjk, px, left, y)?; + let substituted = cluster.len() != 1 || cluster.first().is_some_and(|g| g.id != font.glyph_id(original)); + if cjk && !substituted && matches!(original, '、' | '。' | 'ー' | '(' | ')' | '「' | '」' | '『' | '』' | '【' | '】') + { + return None; + } + Some((face, cluster)) + }); + if let Some((face, cluster)) = shaped { + glyphs.extend(cluster.into_iter().map(|g| (face, g, None))); + y += px; + continue; + } + } + let vertical_form = match original { + '、' => '︑', + '。' => '︒', + '(' => '︵', + ')' => '︶', + '「' => '﹁', + '」' => '﹂', + '『' => '﹃', + '』' => '﹄', + '【' => '︻', + '】' => '︼', + '…' => '︙', + other => other, + }; + let has = |font: &FontRef<'_>, c: char| font.glyph_id(c).0 != 0; + let ch = if vertical && fonts.iter().skip(1).any(|f| has(f, vertical_form)) { vertical_form } else { original }; + // The first face with the glyph; Inter (its missing-glyph box) when none has it. + let face = fonts.iter().position(|f| has(f, ch)).unwrap_or(0); + let sf = fonts.get(face).unwrap_or(&latin).as_scaled(PxScale::from(px)); + let id = sf.glyph_id(ch); + if vertical { + let left = columns.saturating_sub(column.saturating_add(1)) as f32 * px; + let pen = point(left + (px - sf.h_advance(id)) / 2.0, y + sf.ascent()); + let rotation = matches!(original, 'ー' | '—' | '–').then_some((left + px / 2.0, y + px / 2.0)); + glyphs.push((face, id.with_scale_and_position(px, pen), rotation)); + y += px; + } else { + if let Some((previous_face, previous)) = prev + && previous_face == face + { + x += sf.kern(previous, id); + } + glyphs.push((face, id.with_scale_and_position(px, point(x, y + sf.ascent())), None)); + x += sf.h_advance(id); + prev = Some((face, id)); + } + } + let (width, height) = if vertical { (columns as f32 * px, tw.max(y)) } else { (tw.max(x), y + px) }; + Some(TextLayout { fonts, glyphs, width, height }) + } + + /// Call `f(x, y, coverage)` for every pixel the text covers with its top-left corner at + /// (`ox`, `oy`); coordinates may be negative or past the image (the caller clips). + pub fn draw(&self, ox: f32, oy: f32, f: impl FnMut(i32, i32, f32)) { + self.draw_in(ox, oy, f32::INFINITY, f32::INFINITY, f); + } + + /// [`Self::draw`], skipping the glyphs that fall wholly outside `0..clip_w × 0..clip_h` + /// (so huge text costs what shows, not what is laid out). + pub fn draw_in(&self, ox: f32, oy: f32, clip_w: f32, clip_h: f32, mut f: impl FnMut(i32, i32, f32)) { + for (face, g, rotation) in &self.glyphs { + if rotation.is_none() { + // a glyph lies within one line height below its pen's top and an em or so after it + let (px, py) = (ox + g.position.x, oy + g.position.y); + let em = g.scale.y.max(g.scale.x); + if px + 2.0 * em < 0.0 || px - em > clip_w || py + em < 0.0 || py - 2.0 * em > clip_h { + continue; + } + } + if let Some(o) = self.fonts.get(*face).and_then(|font| font.outline_glyph(g.clone())) { + let b = o.px_bounds(); + if rotation.is_none() && (ox + b.max.x < 0.0 || ox + b.min.x > clip_w || oy + b.max.y < 0.0 || oy + b.min.y > clip_h) { + continue; + } + o.draw(|gx, gy, cov| { + let (sx, sy) = (b.min.x + gx as f32, b.min.y + gy as f32); + let (sx, sy) = match rotation { + Some((cx, cy)) => (cx - (sy - cy), cy + (sx - cx)), + None => (sx, sy), + }; + f((ox + sx) as i32, (oy + sy) as i32, cov); + }); + } + } + } +} diff --git a/crates/slideshow/src/compose.rs b/crates/slideshow/src/compose.rs index 998e591c9..61ce91f3f 100644 --- a/crates/slideshow/src/compose.rs +++ b/crates/slideshow/src/compose.rs @@ -204,6 +204,20 @@ fn blurred_step(n: usize, a: f64, b: f64, sigma: f64) -> Vec { /// backdrop image (any size; it fills the slide) composed with `s` at `out_w × out_h` pixels /// (limited to [`MAX_SIDE`] a side, at least 1 × 1). An empty `photo` draws the backdrop alone. pub fn compose(photo: &Rgba8, backdrop_image: Option<&Rgba8>, s: &SlideSettings, out_w: usize, out_h: usize) -> Rgba8 { + compose_with(photo, backdrop_image, s, out_w, out_h, &mut |_, _| {}).0 +} + +/// [`compose`], calling `behind(slide, layout)` once the backdrop is drawn and before the shadow, +/// the stroke and the photo (so it draws behind the photo: a slideshow identity plate with +/// "render behind image"), and returning the layout it drew with (for overlays drawn on top). +pub fn compose_with( + photo: &Rgba8, + backdrop_image: Option<&Rgba8>, + s: &SlideSettings, + out_w: usize, + out_h: usize, + behind: &mut dyn FnMut(&mut Rgba8, &Layout), +) -> (Rgba8, Layout) { let s = s.clone().sanitized(); let (w, h) = slide_size(out_w, out_h); let has_photo = photo.width > 0 && photo.height > 0 && photo.data.len() == photo.width * photo.height; @@ -237,6 +251,7 @@ pub fn compose(photo: &Rgba8, backdrop_image: Option<&Rgba8>, s: &SlideSettings, } }); } + behind(&mut out, &l); // cast shadow: a blurred rectangle (separable: the product of two blurred steps) if let Some((r, sigma)) = l.shadow { let op = s.shadow_opacity as f32; @@ -261,7 +276,7 @@ pub fn compose(photo: &Rgba8, backdrop_image: Option<&Rgba8>, s: &SlideSettings, if has_photo { draw_image(&mut out, photo, &l.photo, &l.cell, 1.0); } - out + (out, l) } #[cfg(test)] @@ -459,6 +474,19 @@ mod tests { } } + #[test] + fn behind_draws_under_the_photo_and_the_layout_comes_back() { + let s = with(json!({"margins": 0.2})); + let mut seen = None; + let (img, l) = compose_with(&solid(10, 10, GREEN), None, &s, 200, 100, &mut |out, l| { + seen = Some(*l); + out.data.fill([255, 0, 0, 255]); + }); + assert_eq!(seen, Some(l), "the hook gets the layout the slide is drawn with"); + assert_eq!(px(&img, 2, 2), RED, "the hook's paint stays on the backdrop"); + assert_eq!(px(&img, 100, 50), GREEN, "and the photo covers it"); + } + #[test] fn hostile_input_never_panics() { let wild = SlideSettings { diff --git a/crates/slideshow/src/lib.rs b/crates/slideshow/src/lib.rs index 4d61716a5..5827491d9 100644 --- a/crates/slideshow/src/lib.rs +++ b/crates/slideshow/src/lib.rs @@ -19,5 +19,5 @@ mod compose; mod settings; -pub use compose::{Layout, Rect, compose, layout, photo_size}; +pub use compose::{Layout, Rect, compose, compose_with, layout, photo_size}; pub use settings::{Aspect, MARGIN_MAX, MAX_SIDE, SHADOW_OFFSET_MAX, SHADOW_RADIUS_MAX, STROKE_MAX, SlideSettings, hex, parse_color}; diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index fa4855fe3..d37a5e23a 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2057,5 +2057,17 @@ "Select a photo, then turn this on to fill the slide behind every photo with it.": "Wählen Sie ein Foto aus und schalten Sie dies ein, um die Folie hinter jedem Foto damit zu füllen.", "Reset layout": "Layout zurücksetzen", "Select a photo to preview its slide": "Wählen Sie ein Foto, um seine Folie in der Vorschau zu sehen", - "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "Das ausgewählte Foto als Folie. Die Wiedergabe füllt den Bildschirm; die Seitenverhältnis-Vorschau ändert nur diese Vorschau." + "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "Das ausgewählte Foto als Folie. Die Wiedergabe füllt den Bildschirm; die Seitenverhältnis-Vorschau ändert nur diese Vorschau.", + "Overlays": "Überlagerungen", + "Identity plate": "Erkennungstafel", + "Your name or studio": "Ihr Name oder Studio", + "Anchored to": "Verankert an", + "Slide": "Folie", + "Render behind image": "Hinter dem Bild rendern", + "Rating stars": "Sternebewertung", + "Text overlays": "Textüberlagerungen", + "Add text overlay": "Textüberlagerung hinzufügen", + "Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "Token: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Anderer Text erscheint wie eingegeben.", + "Use export watermark": "Export-Wasserzeichen verwenden", + "The export watermark, drawn on the photo and sized relative to it.": "Das Export-Wasserzeichen, auf das Foto gezeichnet und relativ zu ihm skaliert." } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 4ab5578fe..3f592a18e 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1902,5 +1902,17 @@ "Select a photo, then turn this on to fill the slide behind every photo with it.": "Selecciona una foto y activa esta opción para llenar con ella el fondo detrás de cada foto.", "Reset layout": "Restablecer diseño", "Select a photo to preview its slide": "Selecciona una foto para previsualizar su diapositiva", - "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "La foto seleccionada como diapositiva. La reproducción llena la pantalla; la vista previa de proporción solo cambia esta vista previa." + "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "La foto seleccionada como diapositiva. La reproducción llena la pantalla; la vista previa de proporción solo cambia esta vista previa.", + "Overlays": "Superposiciones", + "Identity plate": "Placa de identidad", + "Your name or studio": "Tu nombre o estudio", + "Anchored to": "Anclado a", + "Slide": "Diapositiva", + "Render behind image": "Representar detrás de la imagen", + "Rating stars": "Estrellas de valoración", + "Text overlays": "Superposiciones de texto", + "Add text overlay": "Añadir superposición de texto", + "Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. El resto del texto se muestra tal cual.", + "Use export watermark": "Usar la marca de agua de exportación", + "The export watermark, drawn on the photo and sized relative to it.": "La marca de agua de exportación, dibujada sobre la foto y escalada respecto a ella." } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index e456530b5..5f1a0d596 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1902,5 +1902,17 @@ "Select a photo, then turn this on to fill the slide behind every photo with it.": "Sélectionnez une photo, puis activez cette option pour en remplir le fond derrière chaque photo.", "Reset layout": "Réinitialiser la disposition", "Select a photo to preview its slide": "Sélectionnez une photo pour prévisualiser sa diapositive", - "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "La photo sélectionnée en diapositive. La lecture remplit l’écran ; l’aperçu des proportions ne change que cet aperçu." + "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "La photo sélectionnée en diapositive. La lecture remplit l’écran ; l’aperçu des proportions ne change que cet aperçu.", + "Overlays": "Incrustations", + "Identity plate": "Plaque d'identification", + "Your name or studio": "Votre nom ou studio", + "Anchored to": "Ancré à", + "Slide": "Diapositive", + "Render behind image": "Afficher derrière l'image", + "Rating stars": "Étoiles de notation", + "Text overlays": "Incrustations de texte", + "Add text overlay": "Ajouter une incrustation de texte", + "Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "Jetons : {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Le reste du texte s'affiche tel quel.", + "Use export watermark": "Utiliser le filigrane d'exportation", + "The export watermark, drawn on the photo and sized relative to it.": "Le filigrane d'exportation, dessiné sur la photo et dimensionné par rapport à elle." } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index a8da442e4..74ef29b77 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1904,5 +1904,17 @@ "Select a photo, then turn this on to fill the slide behind every photo with it.": "写真を選択してからこれをオンにすると、各写真の背後をその写真で埋めます。", "Reset layout": "レイアウトをリセット", "Select a photo to preview its slide": "写真を選択するとスライドをプレビューできます", - "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "選択した写真をスライドとして表示します。再生では画面全体に表示され、縦横比プレビューはこのプレビューだけを変更します。" + "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "選択した写真をスライドとして表示します。再生では画面全体に表示され、縦横比プレビューはこのプレビューだけを変更します。", + "Overlays": "オーバーレイ", + "Identity plate": "ID プレート", + "Your name or studio": "あなたの名前またはスタジオ名", + "Anchored to": "基準", + "Slide": "スライド", + "Render behind image": "画像の背後に表示", + "Rating stars": "レーティングの星", + "Text overlays": "テキストオーバーレイ", + "Add text overlay": "テキストオーバーレイを追加", + "Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "トークン: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}。その他の文字は入力どおりに表示されます。", + "Use export watermark": "書き出しの透かしを使用", + "The export watermark, drawn on the photo and sized relative to it.": "書き出しの透かし。写真の上に、写真に対する比率で描かれます。" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index e811a1be1..4653d1055 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1902,5 +1902,17 @@ "Select a photo, then turn this on to fill the slide behind every photo with it.": "Selecione uma foto e ative esta opção para preencher com ela o fundo atrás de cada foto.", "Reset layout": "Redefinir layout", "Select a photo to preview its slide": "Selecione uma foto para visualizar o slide dela", - "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "A foto selecionada como slide. A reprodução preenche a tela; a visualização da proporção muda só esta visualização." + "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "A foto selecionada como slide. A reprodução preenche a tela; a visualização da proporção muda só esta visualização.", + "Overlays": "Sobreposições", + "Identity plate": "Placa de identidade", + "Your name or studio": "Seu nome ou estúdio", + "Anchored to": "Ancorado em", + "Slide": "Slide", + "Render behind image": "Renderizar atrás da imagem", + "Rating stars": "Estrelas de classificação", + "Text overlays": "Sobreposições de texto", + "Add text overlay": "Adicionar sobreposição de texto", + "Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. O restante do texto aparece como digitado.", + "Use export watermark": "Usar a marca d'água de exportação", + "The export watermark, drawn on the photo and sized relative to it.": "A marca d'água de exportação, desenhada sobre a foto e dimensionada em relação a ela." } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 2685f8609..44cd11cb7 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1616,5 +1616,18 @@ "Select a photo, then turn this on to fill the slide behind every photo with it.": "Выберите фото и включите этот параметр, чтобы заполнить им фон за каждым фото.", "Reset layout": "Сбросить макет", "Select a photo to preview its slide": "Выберите фото, чтобы увидеть его слайд", - "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "Выбранное фото в виде слайда. При воспроизведении слайд заполняет экран; предпросмотр пропорций меняет только этот предпросмотр." + "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "Выбранное фото в виде слайда. При воспроизведении слайд заполняет экран; предпросмотр пропорций меняет только этот предпросмотр.", + "Overlays": "Наложения", + "Identity plate": "Фирменная табличка", + "Your name or studio": "Ваше имя или студия", + "Anchored to": "Привязка к", + "Slide": "Слайд", + "Render behind image": "Рисовать за изображением", + "Rating stars": "Звёзды рейтинга", + "Text overlays": "Текстовые наложения", + "Add text overlay": "Добавить текстовое наложение", + "Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "Токены: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Остальной текст выводится как написан.", + "Use export watermark": "Использовать водяной знак экспорта", + "The export watermark, drawn on the photo and sized relative to it.": "Водяной знак экспорта, рисуется на фото и масштабируется относительно него.", + "© Your Name": "© Ваше имя" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index e3ddc10ae..a279f5c3c 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2096,5 +2096,17 @@ "Select a photo, then turn this on to fill the slide behind every photo with it.": "Виберіть фото й увімкніть цей параметр, щоб заповнити ним тло за кожним фото.", "Reset layout": "Скинути макет", "Select a photo to preview its slide": "Виберіть фото, щоб переглянути його слайд", - "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "Вибране фото як слайд. Під час відтворення слайд заповнює екран; попередній перегляд пропорцій змінює лише цей перегляд." + "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "Вибране фото як слайд. Під час відтворення слайд заповнює екран; попередній перегляд пропорцій змінює лише цей перегляд.", + "Overlays": "Накладання", + "Identity plate": "Фірмова табличка", + "Your name or studio": "Ваше ім'я або студія", + "Anchored to": "Прив'язка до", + "Slide": "Слайд", + "Render behind image": "Малювати за зображенням", + "Rating stars": "Зірки рейтингу", + "Text overlays": "Текстові накладання", + "Add text overlay": "Додати текстове накладання", + "Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "Токени: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Решта тексту виводиться як написано.", + "Use export watermark": "Використати водяний знак експорту", + "The export watermark, drawn on the photo and sized relative to it.": "Водяний знак експорту, малюється на фото й масштабується відносно нього." } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 065494810..cb3b27456 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1902,5 +1902,17 @@ "Select a photo, then turn this on to fill the slide behind every photo with it.": "选择一张照片,然后打开此项,用它填充每张照片后面的幻灯片背景。", "Reset layout": "重置布局", "Select a photo to preview its slide": "选择一张照片以预览其幻灯片", - "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "将所选照片显示为幻灯片。播放时会填满屏幕;长宽比预览只改变此预览。" + "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "将所选照片显示为幻灯片。播放时会填满屏幕;长宽比预览只改变此预览。", + "Overlays": "叠加", + "Identity plate": "身份标识", + "Your name or studio": "你的名字或工作室", + "Anchored to": "锚定到", + "Slide": "幻灯片", + "Render behind image": "在图像后面渲染", + "Rating stars": "评级星标", + "Text overlays": "文本叠加", + "Add text overlay": "添加文本叠加", + "Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "标记:{file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}。其他文字按原样显示。", + "Use export watermark": "使用导出水印", + "The export watermark, drawn on the photo and sized relative to it.": "导出水印,绘制在照片上,大小相对于照片。" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 27802e68e..e0ba2692e 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1902,5 +1902,17 @@ "Select a photo, then turn this on to fill the slide behind every photo with it.": "選取一張照片,然後開啟此項,以它填滿每張照片後方的投影片背景。", "Reset layout": "重設版面", "Select a photo to preview its slide": "選取一張照片以預覽其投影片", - "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "將所選照片顯示為投影片。播放時會填滿螢幕;長寬比預覽只會改變此預覽。" + "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "將所選照片顯示為投影片。播放時會填滿螢幕;長寬比預覽只會改變此預覽。", + "Overlays": "疊加", + "Identity plate": "身分標識", + "Your name or studio": "你的名字或工作室", + "Anchored to": "錨定到", + "Slide": "幻燈片", + "Render behind image": "在影像後方繪製", + "Rating stars": "評等星號", + "Text overlays": "文字疊加", + "Add text overlay": "新增文字疊加", + "Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "標記:{file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}。其他文字按原樣顯示。", + "Use export watermark": "使用匯出浮水印", + "The export watermark, drawn on the photo and sized relative to it.": "匯出浮水印,繪製在照片上,大小相對於照片。" } diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 3d40f9cc1..4acd6dedc 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -28,6 +28,7 @@ pub mod rules_editor; pub mod second; pub mod settings; pub mod slide_layout; +pub mod slide_overlays; pub mod slideshow; pub mod strip; pub mod topbar; diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 2be60a4a5..8a7d9e8d8 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -657,6 +657,8 @@ fn slideshow_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { playback_section(app, ui, t); ui.add_space(6.0); super::slide_layout::sections(app, ui, t); + ui.add_space(6.0); + super::slide_overlays::sections(app, ui, t); }); }); ui.add_space(12.0); diff --git a/crates/ui-egui/src/panels/slide_layout.rs b/crates/ui-egui/src/panels/slide_layout.rs index c0e03844c..2f5ef0883 100644 --- a/crates/ui-egui/src/panels/slide_layout.rs +++ b/crates/ui-egui/src/panels/slide_layout.rs @@ -1,5 +1,6 @@ //! Settings ▸ Slideshow: the slide's Layout, Options and Backdrop sections (Lightroom Classic's -//! Slideshow panels of the same names) and a small live preview of the composed slide. +//! Slideshow panels of the same names) and a small live preview of the composed slide (with the +//! overlays of [`super::slide_overlays`]). //! //! Every control goes through `slideshow.layout` (validated there, saved with the library). //! Sliders change the layout in memory while dragged (the preview follows) and save it when let @@ -177,7 +178,16 @@ pub fn preview(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { }; let ppp = ui.ctx().pixels_per_point(); let (pw, ph) = (((PREVIEW_W * ppp).round() as usize).max(16), ((h * ppp).round() as usize).max(16)); - let key = s.pixel_key() ^ id.0.rotate_left(17) ^ ((pw as u64) << 32 | ph as u64); + // the overlays and what they show of the photo (its rating, its metadata) change it too + let shown = app.session.catalog.photo(id).filter(|_| app.session.slide_overlays.any()).map_or(0, |p| { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + p.rating.hash(&mut h); + serde_json::to_string(&p.meta).unwrap_or_default().hash(&mut h); + h.finish() + }); + let overlays = if app.session.slide_overlays.any() { app.session.slide_overlays.pixel_key() ^ shown.rotate_left(5) } else { 0 }; + let key = s.pixel_key() ^ id.0.rotate_left(17) ^ ((pw as u64) << 32 | ph as u64) ^ overlays.rotate_left(29); let now = ui.input(|i| i.time); let mem = egui::Id::new("settingsSlideshowPreviewKey"); let (last, since) = ui.ctx().data(|d| d.get_temp::<(u64, f64)>(mem)).unwrap_or((0, f64::NEG_INFINITY)); diff --git a/crates/ui-egui/src/panels/slide_overlays.rs b/crates/ui-egui/src/panels/slide_overlays.rs new file mode 100644 index 000000000..3824eeb0d --- /dev/null +++ b/crates/ui-egui/src/panels/slide_overlays.rs @@ -0,0 +1,265 @@ +//! Settings ▸ Slideshow ▸ Overlays (Lightroom Classic's Slideshow Overlays panel): the identity +//! plate (text or an image file), rating stars, text overlays with tokens and the watermark. +//! +//! Every control goes through `slideshow.overlays` (validated there, saved with the library), like +//! the Layout sections beside it ([`super::slide_layout`]); the live preview next to them shows the +//! overlays on the active photo's slide. Sliders change the settings in memory while dragged and +//! save them when let go; text fields save when the edit ends (Return or leaving the field). + +use egui::RichText; +use lightcraft_engine::export::Anchor; +use lightcraft_engine::slide_overlay::{ + ANCHORS, OFFSET_MAX, OverlaySettings, PLATE_SIZE_MAX, Placement, RelativeTo, SHADOW_OFFSET_MAX, SHADOW_RADIUS_MAX, STAR_SIZE_MAX, TEXT_SIZE_MAX, +}; +use serde_json::{Value, json}; + +use super::settings::{check, choices, heading, hint, row}; +use crate::LightcraftApp; +use crate::text_field::TextField; +use crate::theme::Tokens; +use crate::widgets::register; + +#[derive(Clone, Copy)] +enum Unit { + /// A fraction shown as a percentage (of the slide's shorter side, or an opacity). + Percent, + Degrees, +} + +fn set(app: &mut LightcraftApp, p: Value) { + let _ = app.run("slideshow.overlays", p); +} + +/// A slider (`slider:{id}`) over `lo..=hi`; `wrap(value)` makes the `slideshow.overlays` request. +fn slider(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, value: f64, lo: f64, hi: f64, unit: Unit, wrap: &dyn Fn(f64) -> Value) { + let (k, suffix) = match unit { + Unit::Percent => (100.0, " %"), + Unit::Degrees => (1.0, "°"), + }; + let mut v = value * k; + let r = ui.add(egui::Slider::new(&mut v, lo * k..=hi * k).suffix(suffix).max_decimals(1)); + register(ui.ctx(), format!("slider:{id}"), r.rect); + let p = wrap(v / k); + if r.changed() + && let Ok(next) = app.session.slide_overlays.merged(&p) + { + app.session.slide_overlays = next; + } + if r.drag_stopped() || (r.changed() && !r.dragged()) { + set(app, p); + } +} + +/// A colour button (`button:{id}`). +fn colour(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, value: [u8; 3], wrap: &dyn Fn([u8; 3]) -> Value) { + let mut c = value; + let r = egui::color_picker::color_edit_button_srgb(ui, &mut c); + register(ui.ctx(), format!("button:{id}"), r.rect); + if r.changed() { + set(app, wrap(c)); + } +} + +/// A checkbox (`check:settings.{id}`); true when it is on. +fn toggle(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, value: bool, label: &str, wrap: &dyn Fn(bool) -> Value) -> bool { + let mut v = value; + if check(ui, &format!("settings.{id}"), &mut v, label) { + set(app, wrap(v)); + } + v +} + +/// A one-line text field (`field:{id}`), saved when the edit ends. +fn text(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, value: &str, hint: &str, wrap: &dyn Fn(&str) -> Value) { + let mem = egui::Id::new(("slideshow-overlay-field", id)); + let mut s: String = ui.data(|d| d.get_temp(mem)).unwrap_or_else(|| value.to_string()); + let widget = format!("field:{id}"); + let r = TextField::singleline(&widget, &mut s).hint(crate::i18n::tr(hint)).width(ui.available_width().clamp(80.0, 260.0)).show(ui); + if r.editing { + ui.data_mut(|d| d.insert_temp(mem, s.clone())); + } else { + ui.data_mut(|d| d.remove::(mem)); + } + if r.committed() && s != value { + set(app, wrap(&s)); + } +} + +/// The nine anchors as a 3 × 3 grid of buttons (`button:{id}-{0..8}`); true when one is picked. +fn anchor_grid(ui: &mut egui::Ui, id: &str, value: &mut Anchor) -> bool { + const GLYPHS: [&str; 9] = ["↖", "↑", "↗", "←", "•", "→", "↙", "↓", "↘"]; + let mut changed = false; + ui.vertical(|ui| { + ui.spacing_mut().item_spacing = egui::vec2(2.0, 2.0); + for r in 0..3 { + ui.horizontal(|ui| { + for c in 0..3 { + let i = r * 3 + c; + let (Some(a), Some(g)) = (ANCHORS.get(i), GLYPHS.get(i)) else { continue }; + if crate::widgets::text_button(ui, &format!("{id}-{i}"), g, *value == *a).clicked() && *value != *a { + *value = *a; + changed = true; + } + } + }); + } + }); + changed +} + +/// Anchor, offsets and what the overlay is anchored to; `wrap` turns placement keys into the request. +fn placement(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, id: &str, p: &Placement, photo_too: bool, wrap: &dyn Fn(Value) -> Value) { + row(ui, t, "Position", |ui| { + let mut a = p.anchor; + if anchor_grid(ui, &format!("{id}Anchor"), &mut a) { + set(app, wrap(json!({"anchor": lightcraft_engine::slide_overlay::anchor_id(a)}))); + } + }); + if photo_too { + row(ui, t, "Anchored to", |ui| { + let mut r = p.relative_to; + if choices(ui, &format!("{id}RelativeTo"), &[(RelativeTo::Slide, "Slide"), (RelativeTo::Photo, "Photo")], &mut r) { + let v = if r == RelativeTo::Photo { "photo" } else { "slide" }; + set(app, wrap(json!({"relativeTo": v}))); + } + }); + } + row(ui, t, "Offset X", |ui| { + slider(app, ui, &format!("{id}OffsetX"), p.offset_x, -OFFSET_MAX / 2.0, OFFSET_MAX / 2.0, Unit::Percent, &|v| wrap(json!({"offsetX": v}))) + }); + row(ui, t, "Offset Y", |ui| { + slider(app, ui, &format!("{id}OffsetY"), p.offset_y, -OFFSET_MAX / 2.0, OFFSET_MAX / 2.0, Unit::Percent, &|v| wrap(json!({"offsetY": v}))) + }); +} + +/// The request changing text overlay `i` of `n` by `change` (the others kept). +fn text_change(i: usize, n: usize, change: Value) -> Value { + let list: Vec = (0..n).map(|k| if k == i { change.clone() } else { json!({}) }).collect(); + json!({ "texts": list }) +} + +/// The Overlays sections. +pub fn sections(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + let s: OverlaySettings = app.session.slide_overlays.clone(); + heading(ui, t, "Overlays"); + + // identity plate + let p = s.identity_plate.clone(); + let plate = |v: Value| json!({ "identityPlate": v }); + if toggle(app, ui, "slideshowPlate", p.enabled, "Identity plate", &|v| plate(json!({"enabled": v}))) { + let mem = egui::Id::new("slideshow-plate-graphic"); + let mut graphic = !p.image.is_empty() || ui.data(|d| d.get_temp::(mem)).unwrap_or(false); + let before = graphic; + row(ui, t, "Style", |ui| choices(ui, "slideshowPlateStyle", &[(false, "Text"), (true, "Image")], &mut graphic)); + if graphic != before { + ui.data_mut(|d| d.insert_temp(mem, graphic)); + if !graphic && !p.image.is_empty() { + set(app, plate(json!({"image": ""}))); + } + } + if graphic { + row(ui, t, "Image", |ui| { + if app.services.pick_files.is_some() + && crate::widgets::text_button(ui, "slideshowPlateChoose", "Choose…", false).clicked() + && let Some(f) = app.services.pick_files.as_mut().and_then(|pick| pick().into_iter().next()) + { + set(app, plate(json!({"image": f}))); + } + text(app, ui, "slideshowPlateImage", &p.image, "logo.png", &|v| plate(json!({"image": v}))); + }); + } else { + row(ui, t, "Text", |ui| text(app, ui, "slideshowPlateText", &p.text, "Your name or studio", &|v| plate(json!({"text": v})))); + row(ui, t, "Colour", |ui| colour(app, ui, "slideshowPlateColor", p.color, &|c| plate(json!({"color": c})))); + } + row(ui, t, "Size", |ui| slider(app, ui, "slideshowPlateSize", p.size, 0.01, PLATE_SIZE_MAX, Unit::Percent, &|v| plate(json!({"size": v})))); + row(ui, t, "Opacity", |ui| slider(app, ui, "slideshowPlateOpacity", p.opacity, 0.0, 1.0, Unit::Percent, &|v| plate(json!({"opacity": v})))); + placement(app, ui, t, "slideshowPlate", &p.place, true, &plate); + toggle(app, ui, "slideshowPlateBehind", p.behind, "Render behind image", &|v| plate(json!({"behind": v}))); + } + + // rating stars + ui.add_space(4.0); + let r = s.rating_stars.clone(); + let stars = |v: Value| json!({ "ratingStars": v }); + if toggle(app, ui, "slideshowStars", r.enabled, "Rating stars", &|v| stars(json!({"enabled": v}))) { + row(ui, t, "Colour", |ui| colour(app, ui, "slideshowStarsColor", r.color, &|c| stars(json!({"color": c})))); + row(ui, t, "Size", |ui| slider(app, ui, "slideshowStarsSize", r.size, 0.01, STAR_SIZE_MAX, Unit::Percent, &|v| stars(json!({"size": v})))); + placement(app, ui, t, "slideshowStars", &r.place, true, &stars); + } + + // text overlays + ui.add_space(6.0); + ui.label(RichText::new(crate::i18n::tr("Text overlays")).font(t.semibold(11.5)).color(t.text)); + let n = s.texts.len(); + for (i, o) in s.texts.iter().enumerate() { + let id = format!("slideshowText{i}"); + let wrap = |v: Value| text_change(i, n, v); + ui.add_space(2.0); + row(ui, t, &format!("{} {}", crate::i18n::tr("Text"), i + 1), |ui| { + text(app, ui, &id, &o.text, "{title} · {exposure}", &|v| wrap(json!({"text": v}))); + if crate::widgets::text_button(ui, &format!("{id}Remove"), "Remove", false).clicked() { + set(app, json!({"removeText": i})); + } + }); + if app.session.slide_overlays.texts.len() != n { + return; // removed: the indices moved; drawn again next frame + } + row(ui, t, "Size", |ui| { + slider(app, ui, &format!("{id}Size"), o.size, 0.01, TEXT_SIZE_MAX / 2.0, Unit::Percent, &|v| wrap(json!({"size": v}))) + }); + row(ui, t, "Colour", |ui| colour(app, ui, &format!("{id}Color"), o.color, &|c| wrap(json!({"color": c})))); + row(ui, t, "Opacity", |ui| slider(app, ui, &format!("{id}Opacity"), o.opacity, 0.0, 1.0, Unit::Percent, &|v| wrap(json!({"opacity": v})))); + placement(app, ui, t, &id, &o.place, true, &wrap); + let sh = o.shadow; + if toggle(app, ui, &format!("{id}Shadow"), sh.on, "Shadow", &|v| wrap(json!({"shadow": v}))) { + row(ui, t, "Opacity", |ui| { + slider(app, ui, &format!("{id}ShadowOpacity"), sh.opacity, 0.0, 1.0, Unit::Percent, &|v| wrap(json!({"shadowOpacity": v}))) + }); + row(ui, t, "Offset", |ui| { + slider(app, ui, &format!("{id}ShadowOffset"), sh.offset, 0.0, SHADOW_OFFSET_MAX, Unit::Percent, &|v| wrap(json!({"shadowOffset": v}))) + }); + row(ui, t, "Radius", |ui| { + slider(app, ui, &format!("{id}ShadowRadius"), sh.radius, 0.0, SHADOW_RADIUS_MAX, Unit::Percent, &|v| wrap(json!({"shadowRadius": v}))) + }); + row(ui, t, "Angle", |ui| { + slider(app, ui, &format!("{id}ShadowAngle"), sh.angle, -180.0, 180.0, Unit::Degrees, &|v| wrap(json!({"shadowAngle": v}))) + }); + } + } + if n < lightcraft_engine::slide_overlay::MAX_TEXTS && crate::widgets::text_button(ui, "slideshowAddText", "Add text overlay", false).clicked() { + set(app, json!({"addText": {}})); + } + hint( + ui, + t, + "Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.", + ); + + // watermark + ui.add_space(4.0); + let w = s.watermark.clone(); + let wm = |v: Value| json!({ "watermark": v }); + if toggle(app, ui, "slideshowWatermark", s.show_watermark, "Watermark", &|v| json!({"showWatermark": v})) { + row(ui, t, "Text", |ui| text(app, ui, "slideshowWatermarkText", &w.text, "© Your Name", &|v| wm(json!({"text": v})))); + row(ui, t, "Size", |ui| slider(app, ui, "slideshowWatermarkSize", w.size as f64, 0.01, 0.15, Unit::Percent, &|v| wm(json!({"size": v})))); + row(ui, t, "Opacity", |ui| { + slider(app, ui, "slideshowWatermarkOpacity", w.opacity as f64, 0.05, 1.0, Unit::Percent, &|v| wm(json!({"opacity": v}))) + }); + row(ui, t, "Position", |ui| { + let mut a = w.anchor; + if anchor_grid(ui, "slideshowWatermarkAnchor", &mut a) { + set(app, wm(json!({"anchor": lightcraft_engine::slide_overlay::anchor_id(a)}))); + } + }); + let export_wm = app.session.last_export.as_ref().and_then(|e| e.get("watermark")).filter(|v| v.is_object() || v.is_string()).cloned(); + if let Some(e) = export_wm + && crate::widgets::text_button(ui, "slideshowUseExportWatermark", "Use export watermark", false).clicked() + { + set(app, wm(e)); + } + hint(ui, t, "The export watermark, drawn on the photo and sized relative to it."); + } + for warning in app.session.slide_overlays.warnings() { + ui.label(RichText::new(warning).size(11.0).color(t.caution)); + } +} diff --git a/crates/ui-egui/src/panels/slideshow.rs b/crates/ui-egui/src/panels/slideshow.rs index 13b8f0c5a..42a514d5f 100644 --- a/crates/ui-egui/src/panels/slideshow.rs +++ b/crates/ui-egui/src/panels/slideshow.rs @@ -182,7 +182,7 @@ fn slide_job(app: &mut LightcraftApp, id: PhotoId, area: Rect, ppp: f32, texture let photo = app.session.catalog.photo(id)?.clone(); let frame = Frame::with_lens(photo.width.max(1) as usize, photo.height.max(1) as usize, &photo.develop, true, photo.embedded_lens.as_ref()); let native = super::detail::output_px(&frame); - if !app.session.slide_layout.is_plain() { + if !app.session.slides_plain() { let (w, h) = slide_px(area, ppp, texture_side); let edge = app.ui.settings.loupe_edge(w.max(h) as f32, native[0].max(native[1]), texture_side); return app.session.slide_job(id, w, h, Some(edge)); diff --git a/crates/ui-egui/src/tests_slideshow.rs b/crates/ui-egui/src/tests_slideshow.rs index 36558267b..b7f26c768 100644 --- a/crates/ui-egui/src/tests_slideshow.rs +++ b/crates/ui-egui/src/tests_slideshow.rs @@ -344,3 +344,86 @@ fn settings_shows_the_slide_layout_and_a_live_preview() { assert!(c[1] > 200 && c[0] < 60, "the green backdrop in the preview margin: {c:?}"); h.settle(SETTLE); } + +#[test] +fn overlays_show_during_playback() { + let mut h = grid(); + assert!(h.app.session.slides_plain()); + // the default layout with an identity plate: playback composes the slide to draw it + run( + &mut h, + "slideshow.overlays", + json!({"identityPlate": {"enabled": true, "text": "LC", "size": 0.12, "color": "#00ff00", "opacity": 1, "anchor": "topLeft", "offsetX": 0.02, "offsetY": 0.02}}), + ); + assert!(!h.app.session.slides_plain()); + run(&mut h, "view.slideshow", json!({"interval": 60, "fadeDuration": 0})); + let current = play(&h).current(); + assert!(h.step_until(SETTLE, |h| has_slide(h, current)), "the slide is composed and ready"); + h.step(); + assert_eq!(h.app.renderer.textures.get(&Slot::Slide(current)).unwrap().size, [1000, 700]); + let img = h.paint(); + // the plate's box: 14 px in from the top-left corner, 84 px high + let green = (14..98).flat_map(|y| (14..120).map(move |x| (x, y))).filter(|&(x, y)| { + let c = painted(&img, x, y); + c[1] > 200 && c[0] < 80 && c[2] < 80 + }); + assert!(green.count() > 200, "the identity plate is on the slide"); + h.settle(SETTLE); +} + +#[test] +fn settings_shows_the_overlays_and_the_preview_follows_them() { + let mut h = grid(); + let first = h.app.session.visible_cloned()[0]; + run(&mut h, "library.select", json!({"ids": [first.0]})); + run(&mut h, "view.slideshowOptions", json!({})); + assert!(h.step_until(SETTLE, |h| h.app.renderer.textures.get(&Slot::SlidePreview).is_some_and(|t| t.photo == first))); + let has = |h: &Headless, w: &str| h.app.widgets.iter().any(|(id, _)| id == w); + // scroll down to the Overlays section + let r = h.request("ui.hoverWidget", json!({"id": "check:settings.slideshowRandom"}), T); + assert_eq!(r["ok"], true, "{r}"); + for _ in 0..4 { + let r = h.request("ui.scroll", json!({"dy": -800}), T); + assert_eq!(r["ok"], true, "{r}"); + for _ in 0..10 { + h.step(); + } + } + for w in ["check:settings.slideshowPlate", "check:settings.slideshowStars", "button:slideshowAddText", "check:settings.slideshowWatermark"] { + assert!(has(&h, w), "{w}"); + } + let r = h.request("ui.clickWidget", json!({"id": "button:slideshowAddText"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert_eq!(h.app.session.slide_overlays.texts.len(), 1); + h.step(); + assert!(has(&h, "field:slideshowText0"), "the text overlay's template field"); + // its rows are below the fold: scroll on down + for _ in 0..4 { + let r = h.request("ui.scroll", json!({"dy": -800}), T); + assert_eq!(r["ok"], true, "{r}"); + for _ in 0..10 { + h.step(); + } + } + assert!(has(&h, "button:slideshowText0Anchor-4"), "its anchor grid"); + let r = h.request("ui.clickWidget", json!({"id": "button:slideshowText0Anchor-0"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert_eq!(h.app.session.slide_overlays.texts[0].place.anchor, lightcraft_engine::export::Anchor::TopLeft, "picked in the grid"); + // removed from its row the text overlay from its row + let r = h.request("ui.clickWidget", json!({"id": "button:slideshowText0Remove"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(h.app.session.slide_overlays.texts.is_empty()); + h.step(); + let r = h.request("ui.clickWidget", json!({"id": "check:settings.slideshowPlate"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(h.app.session.slide_overlays.identity_plate.enabled); + // the preview is rendered again with the overlays + let before = h.app.renderer.textures.get(&Slot::SlidePreview).map(|t| t.key); + run(&mut h, "slideshow.overlays", json!({"identityPlate": {"text": "Studio", "color": "#ff00ff"}})); + assert!(h.step_until(SETTLE, |h| h.app.renderer.textures.get(&Slot::SlidePreview).map(|t| t.key) != before)); + h.settle(SETTLE); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 2d0f88ab1..2a4525d3e 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -50,7 +50,8 @@ mode ([mcp.md](mcp.md)) is a thin layer over this channel. Implementation: | `engine.execute {command: "activity.cancel"}` | `{id}` or `{all: true}` | Asks the task (or every cancellable one) to stop at its next file or photo → `{cancelled: n}`; an unknown id (it may have just finished) or a task that can't be cancelled is an error | | `ui.render` | `{id?, size?, path?}` | Render a photo (PNG to `path`), `{width, height}` | | `engine.execute {command: "slideshow.layout"}` | `{margins?, marginLeft?, …, zoomToFill?, strokeBorder?, castShadow?, backdropColor?, colorWash?, backgroundImage?, …}` (see `engine.commands`) | The slideshow's slide layout (Layout, Options, Backdrop), saved with the library; omitted keys keep their value, `{}` reads it; out-of-range numbers are clamped, a wrong type or unknown key is refused and nothing changes | -| `engine.execute {command: "slideshow.preview"}` | `{id?, width?, height?, path?}` | One slide composed with the layout, as the slideshow plays it: a PNG written to `path`, or returned base64 in `png` without one; `{id, width, height, cell, photo}` (`cell` and `photo` are `[x0, y0, x1, y1]` in slide pixels). Sides 16–4096; one side given takes the other from the aspect preview | +| `engine.execute {command: "slideshow.overlays"}` | `{identityPlate?: {enabled?, text?, image?, size?, color?, opacity?, anchor?, offsetX?, offsetY?, relativeTo?, behind?}, ratingStars?: {enabled?, color?, size?, anchor?, …}, texts?: [{text?, size?, color?, opacity?, anchor?, offsetX?, offsetY?, relativeTo?, shadow?, shadowOpacity?, shadowOffset?, shadowRadius?, shadowAngle?}], addText?, removeText?, showWatermark?, watermark?}` (see `engine.commands`) | The overlays drawn on every slide, saved with the library: sizes and offsets are fractions of the slide's shorter side, anchors `topLeft`…`bottomRight` of the `slide` or the `photo`; text overlays expand tokens (`{caption}`, `{exposure}`, `{date}`, the file-naming tokens…; unknown tags show as typed); `watermark` takes the export watermark's keys. Replies with the settings and `warnings` | +| `engine.execute {command: "slideshow.preview"}` | `{id?, width?, height?, path?}` | One slide composed with the layout and overlays, as the slideshow plays it: a PNG written to `path`, or returned base64 in `png` without one; `{id, width, height, cell, photo, overlays}` (`cell` and `photo` are `[x0, y0, x1, y1]` in slide pixels; `overlays` lists `{kind, box}` for each drawn overlay). Sides 16–4096; one side given takes the other from the aspect preview | | `app.quit` | — | Close the app | Image navigation is also available directly as the UI command `view.navigate`, with diff --git a/docs/mcp.md b/docs/mcp.md index 845d054fd..1631eab88 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -200,7 +200,10 @@ Slideshow slides, for example: `cmd_slideshow_layout` reads or changes the slide zoom to fill, stroke border, cast shadow, backdrop colour, colour wash, background image; saved with the library) and `cmd_slideshow_preview {id?, width?, height?, path?}` composes one slide with it — written as a PNG to `path`, or returned as base64 PNG in `png` — so an agent can look at a layout -before playing it. `cmd_slideshow_playback` sets the timing and fades. +before playing it. `cmd_slideshow_playback` sets the timing and fades. `cmd_slideshow_overlays` sets the +overlays drawn on every slide (identity plate text or image, rating stars, text overlays with tokens such as +`{caption}` or `{exposure}`, the export watermark); its reply lists `warnings` (an unreadable plate image, tags that +aren't tokens), and `cmd_slideshow_preview` returns each overlay's box. ## Resources diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 6995f6f16..1cc7972fd 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -52,10 +52,10 @@ cited here still exists. Formerly docs/parity.md. | X. Cross-cutting behaviours (BEHAV) | 18 | 6 | 1 | 1 | 8/8 (100%) | 6/9 (67%) | | Y. Menus | 82 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 75 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) | -| Lightroom Classic extras | 35 | 17 | 31 | 9 | — | 23/25 (92%) | -| **Total** | 399 | 51 | 69 | 36 | 192/200 (96%) | 143/155 (92%) | +| Lightroom Classic extras | 35 | 18 | 30 | 9 | — | 23/25 (92%) | +| **Total** | 399 | 52 | 68 | 36 | 192/200 (96%) | 143/155 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.8%** of 519 in-scope rows — P0 98.0% of 200 · P1 95.2% of 155 · P2 49.1% of 163. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.9%** of 519 in-scope rows — P0 98.0% of 200 · P1 95.2% of 155 · P2 49.4% of 163. ## Top gaps and shortcut notes @@ -730,11 +730,11 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-SS-TEMPLATES | Slideshow templates | P2 | ⬜ | | | | LRC-SS-OPTIONS | Slideshow options | P2 | ✅ | `cmd:slideshow.layout`, `cmd:slideshow.preview`, `path:crates/slideshow/src/compose.rs` | Zoom to Fill Frame (the photo covers its image cell, cropped to it; off: fits inside), Stroke Border (colour, width as a share of the slide's shorter side, drawn around the photo), Cast Shadow (opacity, offset, radius, angle; a blurred offset rectangle under the photo and its stroke). Composed by `lightcraft-slideshow` (CPU, no UI) for playback, the Settings ▸ Slideshow preview and `slideshow.preview`; edited in Settings ▸ Slideshow until the Slideshow module view exists | | LRC-SS-LAYOUT | Slideshow layout | P2 | 🟡 | `cmd:slideshow.layout`, `cmd:slideshow.preview`, `path:crates/ui-egui/src/panels/slide_layout.rs` | Margins left / right / top / bottom (linked or each on its own; shares of the slide's shorter side, so any screen or output size looks the same), Show Guides, Aspect Preview (Screen, 16:9, 4:3). Missing: the Slideshow module's slide editor — guides are drawn only in the Settings ▸ Slideshow preview and can't be dragged to set the margins; the aspect preview applies to that preview and `slideshow.preview` (playback always fills the screen, as in Classic) | -| LRC-SS-OVERLAYS | Slideshow overlays | P2 | ⬜ | | | +| LRC-SS-OVERLAYS | Slideshow overlays | P2 | 🟡 | `cmd:slideshow.overlays`, `cmd:slideshow.preview`, `path:crates/engine/src/slide_overlay.rs`, `path:crates/ui-egui/src/panels/slide_overlays.rs` | Identity plate (text with size / colour / opacity, or a PNG / JPEG file with its transparency; render behind image), rating stars (our own vector stars, colour, size), text overlays (up to 16) with tokens (`{file}`, `{caption}`, `{title}`, `{date}`, `{exposure}`, `{shutter}`, `{aperture}`, `{focal}`, `{iso}`, `{camera}`, `{lens}`, `{rating}`, `{copyright}` and the other rename tokens; unknown tags show as typed and are reported), size / colour / opacity and a drop shadow (opacity, offset, radius, angle); each at one of nine anchors of the slide or the photo plus an offset, kept on the slide; the export watermark on the photo. Sizes relative to the slide; drawn by the engine over the composed slide (Inter + craft-fonts CJK, the export watermark's text renderer) for playback, the Settings ▸ Slideshow preview and `slideshow.preview`. Missing: dragging overlays on the slide in a Slideshow module editor, font family / style choice (Inter SemiBold only), the Identity Plate Editor's styled multi-run text, right-to-left reordering (Arabic / Hebrew draw in logical order, and without glyphs in Inter) | | LRC-SS-BACKDROP | Slideshow backdrop | P2 | ✅ | `cmd:slideshow.layout`, `path:crates/slideshow/src/compose.rs` | Background colour, Color Wash (a gradient from a second colour, full at the side its angle points to, at an opacity), Background Image (a library photo filling the slide at an opacity: "Use selected photo" in Settings ▸ Slideshow, `backgroundImage` in `slideshow.layout`; rendered once and shared by the slides). In the web build the composition runs on the main thread when a worker's render comes back | | LRC-SS-TITLES | Slideshow titles | P2 | ⬜ | | | | LRC-SS-MUSIC | Slideshow music | P2 | ⬜ | | | -| LRC-SS-PLAYBACK | Slideshow playback | P2 | 🟡 | `cmd:slideshow.playback`, `cmd:view.slideshowOptions`, `cmd:slideshow.togglePause`, `cmd:slideshow.next`, `cmd:slideshow.previous`, `cmd:slideshow.end` | Playback settings (library preference, `slideshow.playback`; Settings ▸ Slideshow, View ▸ Slideshow Options…): slide duration 0.5–120 s, fade 0–10 s (≤ slide; 0 = cut) as a crossfade or through a colour, random order (seeded shuffle, every photo once per pass, a new order each pass), repeat (off: ends after the last slide), prepare previews in advance (the next 2 slides render off the UI thread; a transition never starts before its slide is rendered — the current one is held). Space / ←/→ (cut) / Esc and a control bar (previous, play/pause, next, n / N, end) that shows on pointer movement. Missing: the Slideshow module view and its "preview in the view" (phase 2 of the module plan), music and sync to music (LRC-SS-MUSIC), pan & zoom, manual-advance mode, choosing the screen to play on, overlays and titles on the slides (LRC-SS-OVERLAYS, LRC-SS-TITLES). Slides are composed with the layout, options and backdrop (LRC-SS-LAYOUT / OPTIONS / BACKDROP) | +| LRC-SS-PLAYBACK | Slideshow playback | P2 | 🟡 | `cmd:slideshow.playback`, `cmd:view.slideshowOptions`, `cmd:slideshow.togglePause`, `cmd:slideshow.next`, `cmd:slideshow.previous`, `cmd:slideshow.end` | Playback settings (library preference, `slideshow.playback`; Settings ▸ Slideshow, View ▸ Slideshow Options…): slide duration 0.5–120 s, fade 0–10 s (≤ slide; 0 = cut) as a crossfade or through a colour, random order (seeded shuffle, every photo once per pass, a new order each pass), repeat (off: ends after the last slide), prepare previews in advance (the next 2 slides render off the UI thread; a transition never starts before its slide is rendered — the current one is held). Space / ←/→ (cut) / Esc and a control bar (previous, play/pause, next, n / N, end) that shows on pointer movement. Missing: the Slideshow module view and its "preview in the view" (phase 2 of the module plan), music and sync to music (LRC-SS-MUSIC), pan & zoom, manual-advance mode, choosing the screen to play on, titles on the slides (LRC-SS-TITLES). Slides are composed with the layout, options and backdrop (LRC-SS-LAYOUT / OPTIONS / BACKDROP) and carry the overlays (LRC-SS-OVERLAYS) | | LRC-SS-EXPORT | Slideshow export | P2 | ⬜ | | | | LRC-PRINT-LAYOUTSTYLE | Print layout styles | P2 | 🟡 | `cmd:dialog.contactSheet`, `cmd:export.contactSheet`, `crates/engine/src/contact_sheet.rs` | paginated contact sheets; single-image/custom package layouts missing | | LRC-PRINT-IMAGESETTINGS | Print image settings | P2 | ⬜ | | | diff --git a/docs/whats-new.md b/docs/whats-new.md index a59175377..6f14bced6 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,19 @@ ## 2026-10-10 +### Slideshow overlays +- Slides can now carry Lightroom Classic's Slideshow overlays: an identity plate (a line of text in a colour, or an + image file such as a PNG logo with transparency, at a size and opacity, optionally drawn behind the photo), the + photo's rating as stars, and any number of text overlays (up to 16) built from tokens: `{caption}`, `{title}`, + `{file}`, `{date}`, `{exposure}` ("1/250 s at f/8"), `{iso}`, `{focal}`, `{camera}`, `{lens}`, `{rating}` and the + other file-naming tokens, with a size, colour, opacity and an optional soft shadow (opacity, offset, radius, angle). + Each sits at one of nine anchors of the slide or of the photo, moved by an offset, and never leaves the slide. +- The export watermark can be drawn on every slide's photo too ("Use export watermark" takes the one you last + exported with). +- Set them in Settings ▸ Slideshow ▸ Overlays; the slide preview there and View ▸ Slideshow show them. Sizes are a + share of the slide, so slides look the same on any screen. They are saved with the library. Agents use + `slideshow.overlays`; `slideshow.preview` now also returns where each overlay landed. + ### Slideshow layout, options and backdrop - Slides can now be laid out like Lightroom Classic's Slideshow Layout, Options and Backdrop panels: margins (all four linked, or each on its own), Zoom to Fill Frame (crop the photo to fill its cell), a stroke border (colour, width), From 570b2a10c545dd4b86cdea2179f40c35457a30f0 Mon Sep 17 00:00:00 2001 From: Jason Baldwin <33934381+bitshiftza@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:59:58 +0200 Subject: [PATCH 4/6] LRC-SS-PLAYBACK: the render-ahead test waits for the slide up too (renders finish in any order) --- crates/ui-egui/src/tests_slideshow.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/tests_slideshow.rs b/crates/ui-egui/src/tests_slideshow.rs index 06e402bef..2005b9947 100644 --- a/crates/ui-egui/src/tests_slideshow.rs +++ b/crates/ui-egui/src/tests_slideshow.rs @@ -74,9 +74,12 @@ fn next_slides_are_prepared_before_they_are_due() { let ahead = play(&h).upcoming(crate::panels::slideshow::AHEAD); assert_eq!(ahead.len(), 2); let start = h.time(); - assert!(h.step_until(SETTLE, |h| ahead.iter().all(|id| has_slide(h, *id))), "the next two slides are rendered"); + // the slide up and the next two; their renders can finish in any order let current = play(&h).current(); - assert!(has_slide(&h, current)); + assert!( + h.step_until(SETTLE, |h| has_slide(h, current) && ahead.iter().all(|id| has_slide(h, *id))), + "the slide up and the next two slides are rendered" + ); assert!(h.time() - start < 60.0, "before the first is due"); assert_eq!(play(&h).current(), current, "still on the first slide"); h.settle(SETTLE); From 6246ea67a46c7186bd3a798648006b385d01c588 Mon Sep 17 00:00:00 2001 From: Jason Baldwin <33934381+bitshiftza@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:55:01 +0200 Subject: [PATCH 5/6] LRC-SS-PLAYBACK: during a slideshow the toast shows at the top, clear of the playback controls (toasts take the pointer since #7ccff3b9) --- crates/ui-egui/src/panels/mod.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index ef7f13111..a7eff832c 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -92,7 +92,9 @@ pub fn toast(app: &mut LightcraftApp, ctx: &egui::Context) { .order(egui::Order::Foreground) .movable(false) .pivot(Align2::CENTER_CENTER) - .fixed_pos(pos2(canvas.center().x, canvas.bottom() - 60.0)) + // a slideshow's playback controls (and the template buttons) sit at the bottom: the toast, + // which takes the pointer (its text can be selected), goes to the top there + .fixed_pos(pos2(canvas.center().x, if app.ui.slideshow.is_some() { canvas.top() + 60.0 } else { canvas.bottom() - 60.0 })) .show(ctx, |ui| { ui.set_max_width(wrap); egui::Frame::NONE.fill(background).corner_radius(egui::CornerRadius::same(6)).inner_margin(egui::Margin::symmetric(20, 11)).show( From 21b417fe3c12e5e995a7b394d36b4bbceddcbd3f Mon Sep 17 00:00:00 2001 From: Jason Baldwin <33934381+bitshiftza@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:04:13 +0200 Subject: [PATCH 6/6] LRC-SS-PLAYBACK: the toast also goes to the top while a dialog is open, clear of its buttons --- crates/ui-egui/src/panels/mod.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index a7eff832c..cb19353e8 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -92,9 +92,12 @@ pub fn toast(app: &mut LightcraftApp, ctx: &egui::Context) { .order(egui::Order::Foreground) .movable(false) .pivot(Align2::CENTER_CENTER) - // a slideshow's playback controls (and the template buttons) sit at the bottom: the toast, - // which takes the pointer (its text can be selected), goes to the top there - .fixed_pos(pos2(canvas.center().x, if app.ui.slideshow.is_some() { canvas.top() + 60.0 } else { canvas.bottom() - 60.0 })) + // the toast takes the pointer (its text can be selected), so it must not sit on buttons: a + // slideshow's playback controls and a dialog's buttons are at the bottom, so it goes to the top there + .fixed_pos(pos2( + canvas.center().x, + if app.ui.slideshow.is_some() || app.ui.dialog.is_some() { canvas.top() + 60.0 } else { canvas.bottom() - 60.0 }, + )) .show(ctx, |ui| { ui.set_max_width(wrap); egui::Frame::NONE.fill(background).corner_radius(egui::CornerRadius::same(6)).inner_margin(egui::Margin::symmetric(20, 11)).show(