mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-25 02:30:15 -06:00
Found while testing crates/slideshow/src/settings.rs:296-300 (parse_color); same pattern in crates/engine/src/slideshow.rs:117-122 (fadeColor) Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
922 lines
38 KiB
Rust
922 lines
38 KiB
Rust
//! 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), between an
|
||
//! optional intro and ending screen (the Titles panel, [`crate::slide_title`]).
|
||
//!
|
||
//! 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.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||
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()),
|
||
}
|
||
}
|
||
|
||
/// A title screen (Lightroom Classic's Titles panel): the intro before the first slide, the
|
||
/// ending after the last one.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||
pub enum Title {
|
||
Intro,
|
||
Ending,
|
||
}
|
||
|
||
impl Title {
|
||
pub const ALL: [Title; 2] = [Title::Intro, Title::Ending];
|
||
|
||
pub fn id(self) -> &'static str {
|
||
match self {
|
||
Title::Intro => "intro",
|
||
Title::Ending => "ending",
|
||
}
|
||
}
|
||
|
||
pub fn parse(s: &str) -> Option<Title> {
|
||
Title::ALL.into_iter().find(|t| t.id().eq_ignore_ascii_case(s.trim()))
|
||
}
|
||
}
|
||
|
||
/// One slide of the timeline: a photo, or a title screen (not a photo: it shows no photo, never
|
||
/// moves the selection and isn't counted in "n of N").
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||
pub enum Item {
|
||
Title(Title),
|
||
Photo(PhotoId),
|
||
}
|
||
|
||
impl From<PhotoId> for Item {
|
||
fn from(p: PhotoId) -> Item {
|
||
Item::Photo(p)
|
||
}
|
||
}
|
||
|
||
impl Item {
|
||
pub const INTRO: Item = Item::Title(Title::Intro);
|
||
pub const ENDING: Item = Item::Title(Title::Ending);
|
||
|
||
/// The photo this slide shows (`None` for a title screen).
|
||
pub fn photo(self) -> Option<PhotoId> {
|
||
match self {
|
||
Item::Photo(p) => Some(p),
|
||
Item::Title(_) => None,
|
||
}
|
||
}
|
||
|
||
pub fn title(self) -> Option<Title> {
|
||
match self {
|
||
Item::Title(t) => Some(t),
|
||
Item::Photo(_) => None,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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(Item),
|
||
/// A new slide came up (its transition starts now).
|
||
Advanced(Item),
|
||
/// 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: Item,
|
||
/// The slide being faded out, while a transition runs.
|
||
pub from: Option<Item>,
|
||
/// How far the transition is (0–1; 1 when there is none).
|
||
pub progress: f32,
|
||
pub fade_color: Option<[u8; 3]>,
|
||
}
|
||
|
||
/// Where the timeline is: the intro, the photos, or the ending.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
enum Phase {
|
||
Intro,
|
||
Photos,
|
||
Ending,
|
||
}
|
||
|
||
/// One run of a slideshow over a fixed list of photos, with an optional intro and ending.
|
||
///
|
||
/// **Titles:** the intro plays once, before the first photo; the ending once, after the last photo
|
||
/// when repeat is off (with repeat on the show only ends when it is stopped, so the ending never
|
||
/// comes up; passes follow each other without titles between them). Random order shuffles the
|
||
/// photos only: titles keep their places.
|
||
#[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 photo up now (the last one while the ending is up).
|
||
pos: usize,
|
||
phase: Phase,
|
||
pass: u64,
|
||
seed: u64,
|
||
/// How long the intro and the ending stay up (`None`: no such screen).
|
||
intro: Option<f64>,
|
||
ending: Option<f64>,
|
||
/// When the current slide came up (its transition started).
|
||
started: f64,
|
||
/// The slide fading out (until the transition ends).
|
||
from: Option<Item>,
|
||
/// Paused at this time.
|
||
paused_at: Option<f64>,
|
||
ended: bool,
|
||
/// The next slide was due but not ready at the last tick.
|
||
held: Option<Item>,
|
||
}
|
||
|
||
/// 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,
|
||
phase: Phase::Photos,
|
||
pass: 0,
|
||
seed,
|
||
intro: None,
|
||
ending: None,
|
||
started: now,
|
||
from: None,
|
||
paused_at: None,
|
||
ended: false,
|
||
held: None,
|
||
})
|
||
}
|
||
|
||
/// With an intro and / or an ending screen, each up for its duration in seconds (clamped like
|
||
/// a slide's; a non-finite one takes the slide duration). Call it right after [`Self::new`]:
|
||
/// the show then starts at the intro.
|
||
pub fn with_titles(mut self, intro: Option<f64>, ending: Option<f64>) -> Self {
|
||
let slide = self.settings.slide_duration;
|
||
let dur = |d: f64| if d.is_finite() { d.clamp(SLIDE_MIN, SLIDE_MAX) } else { slide };
|
||
self.intro = intro.map(dur);
|
||
self.ending = ending.map(dur);
|
||
if self.intro.is_some() && self.pass == 0 && self.pos == 0 && self.phase == Phase::Photos {
|
||
self.phase = Phase::Intro;
|
||
}
|
||
self
|
||
}
|
||
|
||
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()
|
||
}
|
||
|
||
fn item_at(&self, phase: Phase, order: &[usize], pos: usize) -> Option<Item> {
|
||
match phase {
|
||
Phase::Intro => Some(Item::INTRO),
|
||
Phase::Ending => Some(Item::ENDING),
|
||
Phase::Photos => self.photo_at(order, pos).map(Item::Photo),
|
||
}
|
||
}
|
||
|
||
/// The place after (`phase`, `pos`, `pass`); `order` is that pass's order and becomes the next
|
||
/// pass's when it wraps. `None`: the show ends there.
|
||
fn following(&self, phase: Phase, pos: usize, pass: u64, order: &mut Vec<usize>) -> Option<(Phase, usize, u64)> {
|
||
match phase {
|
||
Phase::Intro => Some((Phase::Photos, 0, pass)),
|
||
Phase::Photos if pos + 1 < order.len() => Some((Phase::Photos, pos + 1, pass)),
|
||
Phase::Photos if self.settings.repeat => {
|
||
*order = self.order_after(pass + 1, order);
|
||
Some((Phase::Photos, 0, pass + 1))
|
||
}
|
||
Phase::Photos if self.ending.is_some() => Some((Phase::Ending, pos, pass)),
|
||
Phase::Photos | Phase::Ending => None,
|
||
}
|
||
}
|
||
|
||
/// The slide up now.
|
||
pub fn current(&self) -> Item {
|
||
self.item_at(self.phase, &self.order, self.pos).or_else(|| self.photos.first().copied().map(Item::Photo)).unwrap_or(Item::Photo(PhotoId(0)))
|
||
}
|
||
|
||
/// The photo up now (`None` while a title screen is up).
|
||
pub fn current_photo(&self) -> Option<PhotoId> {
|
||
self.current().photo()
|
||
}
|
||
|
||
/// (photo number from 1, photos per pass). Titles aren't photos: (0, N) while the intro is up,
|
||
/// (N, N) while the ending is; [`Self::title`] says which.
|
||
pub fn position(&self) -> (usize, usize) {
|
||
let n = self.order.len();
|
||
match self.phase {
|
||
Phase::Intro => (0, n),
|
||
Phase::Photos => (self.pos + 1, n),
|
||
Phase::Ending => (n, n),
|
||
}
|
||
}
|
||
|
||
/// The title screen up now, if one is.
|
||
pub fn title(&self) -> Option<Title> {
|
||
self.current().title()
|
||
}
|
||
|
||
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<Item> {
|
||
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))
|
||
}
|
||
|
||
/// How long `item` stays up (seconds).
|
||
pub fn duration(&self, item: Item) -> f64 {
|
||
match item {
|
||
Item::Title(Title::Intro) => self.intro.unwrap_or(self.settings.slide_duration),
|
||
Item::Title(Title::Ending) => self.ending.unwrap_or(self.settings.slide_duration),
|
||
Item::Photo(_) => self.settings.slide_duration,
|
||
}
|
||
}
|
||
|
||
/// How long the transition to `item` takes: the fade, never longer than the slide.
|
||
fn fade_to(&self, item: Item) -> f64 {
|
||
self.settings.fade_duration.min(self.duration(item))
|
||
}
|
||
|
||
/// When the next slide is due.
|
||
pub fn due(&self) -> f64 {
|
||
self.started + self.duration(self.current())
|
||
}
|
||
|
||
/// 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 (titles included; into the next pass when repeating;
|
||
/// never the one up now twice).
|
||
pub fn upcoming(&self, n: usize) -> Vec<Item> {
|
||
let mut out = Vec::new();
|
||
let mut order = self.order.clone();
|
||
let (mut phase, mut pos, mut pass) = (self.phase, self.pos, self.pass);
|
||
let current = self.current();
|
||
while out.len() < n {
|
||
let Some(next) = self.following(phase, pos, pass, &mut order) else { break };
|
||
(phase, pos, pass) = next;
|
||
match self.item_at(phase, &order, pos) {
|
||
Some(it) if it != current && !out.contains(&it) => out.push(it),
|
||
_ => break,
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Move one slide on (`Advanced`), or end (`Ended`) past the last one without repeat.
|
||
fn advance(&mut self) -> Tick {
|
||
let mut order = std::mem::take(&mut self.order);
|
||
let next = self.following(self.phase, self.pos, self.pass, &mut order);
|
||
self.order = order;
|
||
match next {
|
||
Some((phase, pos, pass)) => {
|
||
(self.phase, self.pos, self.pass) = (phase, pos, pass);
|
||
Tick::Advanced(self.current())
|
||
}
|
||
None => {
|
||
self.ended = true;
|
||
Tick::Ended
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Advance the timeline to `now`. `ready(slide)` 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(Item) -> bool) -> Tick {
|
||
if self.ended {
|
||
return Tick::Ended;
|
||
}
|
||
let now = self.clock(now);
|
||
if self.from.is_some() && now >= self.started + self.fade_to(self.current()) {
|
||
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(new) = t {
|
||
self.started = now;
|
||
self.from = (self.fade_to(new) > 0.0).then_some(old);
|
||
}
|
||
t
|
||
}
|
||
|
||
/// What to draw at `now`.
|
||
pub fn frame(&self, now: f64) -> Frame {
|
||
let now = self.clock(now);
|
||
let current = self.current();
|
||
let fade = self.fade_to(current);
|
||
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, 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 (the ending, when there is one) without repeat
|
||
/// playback ends; before the first it stays there (the intro, when there is one: in the first
|
||
/// pass ← from the first photo goes back to it).
|
||
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 slides = self.order.len().saturating_add(2).min(u32::MAX as usize) as u32;
|
||
let steps = delta.unsigned_abs().min(slides);
|
||
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 {
|
||
match self.phase {
|
||
// (the ending keeps the last photo's place)
|
||
Phase::Ending => self.phase = Phase::Photos,
|
||
Phase::Photos if self.pos > 0 => self.pos -= 1,
|
||
Phase::Photos if self.intro.is_some() && self.pass == 0 => self.phase = Phase::Intro,
|
||
Phase::Photos if self.settings.repeat => self.pos = self.order.len().saturating_sub(1),
|
||
Phase::Photos | Phase::Intro => {}
|
||
}
|
||
}
|
||
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 ph(n: u64) -> Item {
|
||
Item::Photo(PhotoId(n))
|
||
}
|
||
|
||
fn all_ready(_: Item) -> 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(), ph(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(ph(3)));
|
||
let f = p.frame(14.25);
|
||
assert_eq!((f.current, f.from), (ph(3), Some(ph(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(ph(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(ph(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(ph(3)));
|
||
assert_eq!(p.frame(4.5), Frame { current: ph(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(ph(2)));
|
||
assert_eq!(p.step(-1, 6.0), Tick::Advanced(ph(1)));
|
||
// repeat on: before the first is the last
|
||
assert_eq!(p.step(-1, 6.0), Tick::Advanced(ph(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_photo().unwrap()];
|
||
for k in 1..60 {
|
||
p.tick(k as f64, all_ready);
|
||
seen.push(p.current_photo().unwrap());
|
||
}
|
||
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(ph(3)));
|
||
assert_eq!(p.tick(2.0, all_ready), Tick::Advanced(ph(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(ph(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(ph(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![ph(2), ph(3)], "what to prepare");
|
||
let not_two = |id: Item| id != ph(2);
|
||
assert_eq!(p.tick(2.0, not_two), Tick::Waiting(ph(2)));
|
||
assert_eq!(p.frame(3.0), Frame { current: ph(1), from: None, progress: 1.0, fade_color: None }, "held, not flashed");
|
||
assert_eq!(p.waiting_for(), Some(ph(2)));
|
||
assert_eq!(p.tick(3.5, all_ready), Tick::Advanced(ph(2)));
|
||
assert_eq!(p.frame(3.5).from, Some(ph(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 = |_: Item| 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(ph(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": "#+f+f+f"}),
|
||
json!({"fadeColor": "-12345"}),
|
||
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);
|
||
}
|
||
|
||
fn titled(n: u64, s: PlaybackSettings, intro: Option<f64>, ending: Option<f64>) -> Playback {
|
||
Playback::new(ids(n), None, s, 1, 0.0).unwrap().with_titles(intro, ending)
|
||
}
|
||
|
||
#[test]
|
||
fn titles_play_once_around_the_photos_for_their_own_durations() {
|
||
let s = PlaybackSettings { repeat: false, ..settings(4.0, 1.0) };
|
||
let mut p = titled(3, s, Some(2.0), Some(3.0));
|
||
assert_eq!(p.current(), Item::INTRO, "the intro comes first");
|
||
assert_eq!((p.position(), p.title(), p.current_photo()), ((0, 3), Some(Title::Intro), None), "titles aren't photos");
|
||
assert_eq!(p.upcoming(2), vec![ph(1), ph(2)]);
|
||
assert_eq!(p.tick(1.99, all_ready), Tick::Idle);
|
||
assert_eq!(p.tick(2.0, all_ready), Tick::Advanced(ph(1)), "the intro's own 2 s");
|
||
let f = p.frame(2.5);
|
||
assert_eq!((f.current, f.from), (ph(1), Some(Item::INTRO)), "the intro fades out like a slide");
|
||
assert!((f.progress - 0.5).abs() < 1e-6);
|
||
assert_eq!(p.position(), (1, 3));
|
||
assert_eq!(p.tick(6.0, all_ready), Tick::Advanced(ph(2)));
|
||
assert_eq!(p.tick(10.0, all_ready), Tick::Advanced(ph(3)));
|
||
assert_eq!(p.upcoming(2), vec![Item::ENDING], "the ending is prepared like a slide");
|
||
assert_eq!(p.tick(14.0, all_ready), Tick::Advanced(Item::ENDING));
|
||
assert_eq!((p.position(), p.title()), ((3, 3), Some(Title::Ending)));
|
||
assert_eq!(p.frame(14.5).from, Some(ph(3)), "the ending fades in");
|
||
assert_eq!(p.tick(16.99, all_ready), Tick::Idle);
|
||
assert_eq!(p.tick(17.0, all_ready), Tick::Ended, "the ending's own 3 s, then the show ends");
|
||
// without titles nothing changes
|
||
let mut p = titled(2, PlaybackSettings { repeat: false, ..settings(1.0, 0.0) }, None, None);
|
||
assert_eq!(p.current(), ph(1));
|
||
assert_eq!(p.tick(1.0, all_ready), Tick::Advanced(ph(2)));
|
||
assert_eq!(p.tick(2.0, all_ready), Tick::Ended);
|
||
}
|
||
|
||
#[test]
|
||
fn a_fade_never_outlasts_a_short_title() {
|
||
let s = PlaybackSettings { repeat: false, ..settings(4.0, 2.0) };
|
||
let mut p = titled(1, s, Some(0.5), Some(0.5));
|
||
assert_eq!(p.tick(0.5, all_ready), Tick::Advanced(ph(1)));
|
||
assert!((p.frame(1.5).progress - 0.5).abs() < 1e-6, "the photo fades in over the full 2 s");
|
||
assert_eq!(p.tick(4.5, all_ready), Tick::Advanced(Item::ENDING));
|
||
assert!((p.frame(4.75).progress - 0.5).abs() < 1e-6, "into the 0.5 s ending: a 0.5 s fade");
|
||
assert_eq!(p.frame(5.0).from, None);
|
||
assert_eq!(p.tick(5.0, all_ready), Tick::Ended);
|
||
}
|
||
|
||
#[test]
|
||
fn with_repeat_the_intro_plays_once_and_the_ending_never() {
|
||
let mut p = titled(2, settings(1.0, 0.0), Some(1.0), Some(1.0));
|
||
let mut seen = vec![p.current()];
|
||
for k in 1..12 {
|
||
p.tick(k as f64, all_ready);
|
||
seen.push(p.current());
|
||
assert!(!p.upcoming(3).contains(&Item::ENDING), "no ending while repeating");
|
||
}
|
||
assert_eq!(seen[0], Item::INTRO);
|
||
assert!(seen[1..].iter().all(|i| i.photo().is_some()), "passes follow each other without titles: {seen:?}");
|
||
assert_eq!(seen[1..5], [ph(1), ph(2), ph(1), ph(2)]);
|
||
}
|
||
|
||
#[test]
|
||
fn random_order_shuffles_the_photos_and_keeps_the_titles_in_place() {
|
||
for seed in [1, 2, 3] {
|
||
let s = PlaybackSettings { random_order: true, repeat: false, ..settings(1.0, 0.0) };
|
||
let mut p = Playback::new(ids(10), None, s, seed, 0.0).unwrap().with_titles(Some(1.0), Some(1.0));
|
||
let mut seen = vec![p.current()];
|
||
let mut k = 1.0;
|
||
while p.tick(k, all_ready) != Tick::Ended {
|
||
seen.push(p.current());
|
||
k += 1.0;
|
||
}
|
||
assert_eq!(seen.len(), 12, "{seen:?}");
|
||
assert_eq!((seen[0], seen[11]), (Item::INTRO, Item::ENDING), "seed {seed}");
|
||
let mut photos: Vec<PhotoId> = seen[1..11].iter().filter_map(|i| i.photo()).collect();
|
||
assert_ne!(photos, ids(10), "shuffled");
|
||
photos.sort();
|
||
assert_eq!(photos, ids(10), "every photo once, between the titles");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn arrows_step_across_the_titles() {
|
||
let s = PlaybackSettings { repeat: false, ..settings(4.0, 1.0) };
|
||
let mut p = titled(3, s, Some(2.0), Some(2.0));
|
||
assert_eq!(p.step(-1, 0.5), Tick::Advanced(Item::INTRO), "← on the intro stays there");
|
||
assert_eq!(p.step(1, 0.5), Tick::Advanced(ph(1)));
|
||
assert_eq!(p.step(-1, 0.6), Tick::Advanced(Item::INTRO), "← from the first photo: back to the intro");
|
||
assert_eq!(p.remaining(0.6), 2.0, "with its full time");
|
||
assert_eq!(p.step(3, 1.0), Tick::Advanced(ph(3)));
|
||
assert_eq!(p.step(1, 1.0), Tick::Advanced(Item::ENDING));
|
||
assert_eq!(p.position(), (3, 3));
|
||
assert_eq!(p.step(-1, 1.0), Tick::Advanced(ph(3)), "← from the ending: the last photo");
|
||
assert_eq!(p.step(1, 1.0), Tick::Advanced(Item::ENDING));
|
||
assert_eq!(p.step(1, 1.0), Tick::Ended, "→ past the ending ends the show");
|
||
assert_eq!(p.step(1, 2.0), Tick::Ended);
|
||
// repeating: ← from the first photo of a later pass wraps to the last, not the intro
|
||
let mut p = titled(3, settings(4.0, 0.0), Some(2.0), None);
|
||
assert_eq!(p.step(4, 0.0), Tick::Advanced(ph(1)), "intro, 1, 2, 3, then 1 again");
|
||
assert_eq!(p.step(-1, 0.0), Tick::Advanced(ph(3)));
|
||
assert_eq!(p.step(i32::MAX, 0.0), Tick::Advanced(p.current()));
|
||
assert_eq!(p.step(i32::MIN, f64::NAN), Tick::Advanced(p.current()));
|
||
}
|
||
|
||
#[test]
|
||
fn a_title_pauses_and_waits_like_a_slide() {
|
||
let mut p = titled(2, settings(4.0, 1.0), Some(3.0), None);
|
||
let not_intro = |i: Item| i != Item::INTRO;
|
||
assert_eq!(p.tick(5.0, not_intro), Tick::Idle, "the intro isn't rendered yet: its clock waits");
|
||
assert_eq!(p.remaining(5.0), 3.0);
|
||
assert!(p.toggle_pause(6.0));
|
||
assert_eq!(p.tick(100.0, all_ready), Tick::Idle);
|
||
assert_eq!(p.remaining(100.0), 2.0, "paused on the intro");
|
||
assert!(!p.toggle_pause(100.0));
|
||
assert_eq!(p.tick(101.9, all_ready), Tick::Idle);
|
||
let not_one = |i: Item| i != ph(1);
|
||
assert_eq!(p.tick(102.0, not_one), Tick::Waiting(ph(1)), "the first photo isn't ready: the intro is held");
|
||
assert_eq!(p.frame(102.5).current, Item::INTRO);
|
||
assert_eq!(p.tick(103.0, all_ready), Tick::Advanced(ph(1)));
|
||
}
|
||
|
||
#[test]
|
||
fn hostile_title_durations_are_clamped() {
|
||
let p = titled(2, settings(4.0, 1.0), Some(f64::NAN), Some(1e300));
|
||
assert_eq!((p.duration(Item::INTRO), p.duration(Item::ENDING)), (4.0, SLIDE_MAX));
|
||
let p = titled(2, settings(4.0, 1.0), Some(-1.0), Some(f64::NEG_INFINITY));
|
||
assert_eq!((p.duration(Item::INTRO), p.duration(Item::ENDING)), (SLIDE_MIN, 4.0));
|
||
// a single photo between titles, repeating: the photo holds after the intro
|
||
let mut p = titled(1, settings(1.0, 0.0), Some(1.0), Some(1.0));
|
||
assert_eq!(p.upcoming(3), vec![ph(1)]);
|
||
assert_eq!(p.tick(1.0, all_ready), Tick::Advanced(ph(1)));
|
||
assert_eq!(p.upcoming(3), vec![]);
|
||
assert_eq!(p.tick(9.0, all_ready), Tick::Idle);
|
||
assert_eq!(Title::parse(" Ending "), Some(Title::Ending));
|
||
assert_eq!(Title::parse("credits"), None);
|
||
}
|
||
}
|