mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
LRC-SS-PLAYBACK: slideshow playback: fades, random order, repeat, previews prepared in advance, playback controls
This commit is contained in:
+1
-1
@@ -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 |
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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<Value> {
|
||||
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<CommandSpec> {
|
||||
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
|
||||
)]
|
||||
}
|
||||
@@ -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<std::path::PathBuf>,
|
||||
/// 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,
|
||||
}
|
||||
|
||||
@@ -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<u32>,
|
||||
/// 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(()) };
|
||||
|
||||
@@ -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<Self, String> {
|
||||
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<f64, String> {
|
||||
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<bool, String> {
|
||||
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<Option<[u8; 3]>, 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<PhotoId>,
|
||||
/// 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<PhotoId>,
|
||||
/// This pass's order (indices into `photos`).
|
||||
order: Vec<usize>,
|
||||
/// 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<PhotoId>,
|
||||
/// Paused at this time.
|
||||
paused_at: Option<f64>,
|
||||
ended: bool,
|
||||
/// The next slide was due but not ready at the last tick.
|
||||
held: Option<PhotoId>,
|
||||
}
|
||||
|
||||
/// 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<PhotoId>, start: Option<PhotoId>, settings: PlaybackSettings, seed: u64, now: f64) -> Option<Self> {
|
||||
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<usize> = if settings.random_order {
|
||||
// the starting photo first, the others shuffled
|
||||
let mut rest: Vec<usize> = (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<usize> {
|
||||
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<PhotoId> {
|
||||
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<PhotoId> {
|
||||
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<PhotoId> {
|
||||
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<PhotoId> {
|
||||
(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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<String> {
|
||||
self.view.painted_text()
|
||||
|
||||
@@ -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)]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
+21
-18
@@ -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<Re
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"view.slideshow" => {
|
||||
// {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()
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<f64>) {
|
||||
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`,
|
||||
|
||||
@@ -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<Pos2>,
|
||||
/// 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<PhotoId>,
|
||||
}
|
||||
|
||||
/// `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<Value, String> {
|
||||
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<Value, String> {
|
||||
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<lightcraft_engine::RenderJob> {
|
||||
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<Rect> {
|
||||
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<PhotoId> = 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<Rect> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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::<u8>().unwrap_or(0)}), adv == "1");
|
||||
|
||||
@@ -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<String>,
|
||||
/// 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<Box<crate::panels::slideshow::Run>>,
|
||||
/// Info overlay on the loupe.
|
||||
pub info_overlay: InfoOverlay,
|
||||
/// Navigator mini map in the loupe while zoomed in.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<PhotoId> = 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);
|
||||
}
|
||||
+6
-6
@@ -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.
|
||||
<!-- /parity:summary -->
|
||||
|
||||
## 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 | ⬜ | | |
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user