mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
@@ -4,7 +4,9 @@
|
||||
//! colour, colour wash, background image). Both are library preferences, saved in the library's
|
||||
//! `prefs.json`; the app's slideshow (View ▸ Slideshow) plays with them. `slideshow.overlays`:
|
||||
//! the identity plate, rating stars, text overlays with tokens and the watermark drawn on every
|
||||
//! slide (also a library preference). `slideshow.preview` composes one slide as a PNG.
|
||||
//! slide (also a library preference). `slideshow.titles`: the intro and ending screens (colour,
|
||||
//! identity plate or text, scale, duration; a library preference). `slideshow.preview` composes
|
||||
//! one slide, or a title screen, as a PNG.
|
||||
|
||||
use lightcraft_catalog::PhotoId;
|
||||
use serde_json::{Value, json};
|
||||
@@ -53,6 +55,20 @@ fn overlays(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn titles(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let changes = p.as_object().is_some_and(|o| !o.is_empty());
|
||||
if changes {
|
||||
let next = s.slide_titles.merged(p).map_err(|e| bad("slideshow.titles", e))?;
|
||||
if next != s.slide_titles {
|
||||
s.slide_titles = next;
|
||||
s.save_prefs()?;
|
||||
}
|
||||
}
|
||||
let mut out = s.slide_titles.to_json();
|
||||
out["warnings"] = json!(s.slide_titles.warnings(&s.slide_overlays));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A slide size from the request: both sides given, one (the other from the aspect preview), or
|
||||
/// neither (1280 wide). Each side 16–[`PREVIEW_MAX`].
|
||||
fn preview_size(s: &Session, p: &Value) -> Result<(usize, usize)> {
|
||||
@@ -76,8 +92,58 @@ fn preview_size(s: &Session, p: &Value) -> Result<(usize, usize)> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Write `png` to `path`, or put it in the reply as base64.
|
||||
fn deliver(png: &[u8], path: Option<&str>, out: &mut Value) -> Result<()> {
|
||||
match path {
|
||||
Some(path) => {
|
||||
crate::export::write_file(path, png).map_err(crate::EngineError::Other)?;
|
||||
out["path"] = json!(path);
|
||||
}
|
||||
None => out["png"] = json!(base64(png)),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn encode_png(img: &lightcraft_raster::Rgba8) -> Result<Vec<u8>> {
|
||||
lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default())
|
||||
.map_err(|e| crate::EngineError::Other(format!("PNG: {e}")))
|
||||
}
|
||||
|
||||
/// `slideshow.preview {title}`: a title screen (whether or not it is switched on).
|
||||
fn preview_title(s: &mut Session, p: &Value, title: &Value) -> Result<Value> {
|
||||
const C: &str = "slideshow.preview";
|
||||
let t = title
|
||||
.as_str()
|
||||
.and_then(crate::slideshow::Title::parse)
|
||||
.ok_or_else(|| bad(C, format!("`title` must be \"intro\" or \"ending\", not {title}")))?;
|
||||
if p.get("id").is_some_and(|v| !v.is_null()) {
|
||||
return Err(bad(C, "pass `id` or `title`, not both"));
|
||||
}
|
||||
let (w, h) = preview_size(s, p)?;
|
||||
let path = str_param(p, "path").map(str::trim).filter(|x| !x.is_empty());
|
||||
if let Some(path) = path {
|
||||
s.check_write_target(path).map_err(|e| bad(C, e))?;
|
||||
}
|
||||
let c = s.title_compose(t, w, h);
|
||||
let img = c.compose();
|
||||
let png = encode_png(&img)?;
|
||||
let rect = |r: lightcraft_slideshow::Rect| json!([r.x0, r.y0, r.x1, r.y1]);
|
||||
let mut out = json!({
|
||||
"title": t.id(),
|
||||
"enabled": s.slide_titles.screen(t).enabled,
|
||||
"width": img.width,
|
||||
"height": img.height,
|
||||
"plate": c.plate_box().map(rect),
|
||||
});
|
||||
deliver(&png, path, &mut out)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn preview(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "slideshow.preview";
|
||||
if let Some(t) = p.get("title").filter(|v| !v.is_null()) {
|
||||
return preview_title(s, p, t);
|
||||
}
|
||||
let id = match p.get("id") {
|
||||
None | Some(Value::Null) => s.active().ok_or_else(|| bad(C, "no photo selected (select one or pass `id`)"))?,
|
||||
Some(v) => PhotoId(v.as_u64().ok_or_else(|| bad(C, format!("`id` must be a photo id, not {v}")))?),
|
||||
@@ -92,8 +158,7 @@ fn preview(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let settings = s.slide_layout.clone();
|
||||
let img = s.slide_now(&settings, id, w, h).map_err(crate::EngineError::Other)?;
|
||||
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default())
|
||||
.map_err(|e| crate::EngineError::Other(format!("PNG: {e}")))?;
|
||||
let png = encode_png(&img)?;
|
||||
let aspect = s.slide_photo_aspect(id).unwrap_or(1.5);
|
||||
let l = lightcraft_slideshow::layout(&settings, aspect, img.width, img.height);
|
||||
let rect = |r: lightcraft_slideshow::Rect| json!([r.x0, r.y0, r.x1, r.y1]);
|
||||
@@ -107,13 +172,7 @@ fn preview(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let photo = s.catalog.photo(id).cloned();
|
||||
let o = crate::slide_overlay::SlideOverlays::resolve(&s.slide_overlays, photo.as_deref());
|
||||
out["overlays"] = o.boxes(&l).into_iter().map(|(k, r)| json!({"kind": k.id(), "box": rect(r)})).collect();
|
||||
match path {
|
||||
Some(path) => {
|
||||
crate::export::write_file(path, &png).map_err(crate::EngineError::Other)?;
|
||||
out["path"] = json!(path);
|
||||
}
|
||||
None => out["png"] = json!(base64(&png)),
|
||||
}
|
||||
deliver(&png, path, &mut out)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -162,12 +221,21 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
always,
|
||||
overlays
|
||||
),
|
||||
cmd!(
|
||||
"slideshow.titles",
|
||||
"Slideshow Title Settings",
|
||||
[],
|
||||
None,
|
||||
"{intro?: screen, ending?: screen} where a screen is {enabled?: bool, color?: background \"#rrggbb\"|[r,g,b], content?: \"none\"|\"identityPlate\" (the Overlays identity plate, its text or image)|\"text\", text?: the text shown with content \"text\", overrideColor?: bool (draw the plate or text in textColor instead of the identity plate's colour; an image plate keeps its colours), textColor?: colour, scale?: height of the plate or text line as a fraction of the slide's shorter side (0.005–0.5; text wider than the slide shrinks to fit), duration?: seconds (0.5–120) | null (the slide duration)} — the intro plays once before the first slide, the ending after the last one when repeat is off; both fade with the playback fade; random order never moves them. Omitted keys keep their value, {} reads them; out-of-range numbers are clamped, wrong types refused; saved with the library → the settings and warnings (an unreadable identity plate image, a screen with nothing to show)",
|
||||
always,
|
||||
titles
|
||||
),
|
||||
cmd!(
|
||||
query "slideshow.preview",
|
||||
"Slideshow Slide Preview",
|
||||
[],
|
||||
None,
|
||||
"{id?: photo (default: the active one), width?, height?: pixels (16–4096; one given: the other from the aspect preview; neither: 1280 wide), path?: write the PNG here} — the photo's slide composed with the slideshow layout and overlays → {id, width, height, cell: [x0,y0,x1,y1], photo: [x0,y0,x1,y1] (the visible photo, slide pixels), overlays: [{kind: identityPlate|ratingStars|textN|watermark, box: [x0,y0,x1,y1]}], path | png (base64 PNG)}",
|
||||
"{id?: photo (default: the active one) | title?: \"intro\"|\"ending\", width?, height?: pixels (16–4096; one given: the other from the aspect preview; neither: 1280 wide), path?: write the PNG here} — the photo's slide composed with the slideshow layout and overlays → {id, width, height, cell: [x0,y0,x1,y1], photo: [x0,y0,x1,y1] (the visible photo, slide pixels), overlays: [{kind: identityPlate|ratingStars|textN|watermark, box: [x0,y0,x1,y1]}], path | png (base64 PNG)}; with `title`, that title screen (`slideshow.titles`; drawn even while it is off) → {title, enabled, width, height, plate: [x0,y0,x1,y1] | null (the plate or text), path | png}",
|
||||
always,
|
||||
preview
|
||||
),
|
||||
|
||||
@@ -64,6 +64,7 @@ pub mod segment;
|
||||
pub mod sidecar;
|
||||
pub mod slide;
|
||||
pub mod slide_overlay;
|
||||
pub mod slide_title;
|
||||
pub mod slideshow;
|
||||
pub mod smart;
|
||||
pub mod sync;
|
||||
@@ -313,6 +314,8 @@ pub struct Session {
|
||||
pub slide_layout: slide::SlideSettings,
|
||||
/// Slideshow overlays (identity plate, rating stars, text overlays, watermark).
|
||||
pub slide_overlays: slide_overlay::OverlaySettings,
|
||||
/// Slideshow intro and ending screens (`slideshow.titles`; persisted in prefs.json).
|
||||
pub slide_titles: slide_title::TitleSettings,
|
||||
/// The background image the slides last rendered (shared by slide jobs: rendered once).
|
||||
pub slide_backdrop: std::sync::Arc<slide::BackdropCache>,
|
||||
/// Where smart previews are kept when not in the library folder (persisted in prefs.json).
|
||||
@@ -416,6 +419,7 @@ impl Session {
|
||||
slideshow: slideshow::PlaybackSettings::default(),
|
||||
slide_layout: slide::SlideSettings::default(),
|
||||
slide_overlays: slide_overlay::OverlaySettings::default(),
|
||||
slide_titles: slide_title::TitleSettings::default(),
|
||||
slide_backdrop: Default::default(),
|
||||
smart_previews_dir: None,
|
||||
forget_local_days: lightcraft_catalog::DEFAULT_FORGET_DAYS,
|
||||
|
||||
@@ -238,6 +238,8 @@ struct PrefsFile {
|
||||
slide_layout: crate::slide::SlideSettings,
|
||||
/// Slideshow overlays.
|
||||
slide_overlays: crate::slide_overlay::OverlaySettings,
|
||||
/// Slideshow intro and ending screens.
|
||||
slide_titles: crate::slide_title::TitleSettings,
|
||||
}
|
||||
|
||||
fn presets_json(s: &Session) -> String {
|
||||
@@ -463,6 +465,7 @@ impl Session {
|
||||
self.slideshow = prefs.slideshow.sanitized();
|
||||
self.slide_layout = prefs.slide_layout.sanitized();
|
||||
self.slide_overlays = prefs.slide_overlays.sanitized();
|
||||
self.slide_titles = prefs.slide_titles.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 {
|
||||
@@ -711,6 +714,7 @@ impl Session {
|
||||
slideshow: self.slideshow.clone(),
|
||||
slide_layout: self.slide_layout.clone(),
|
||||
slide_overlays: self.slide_overlays.clone(),
|
||||
slide_titles: self.slide_titles.clone(),
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let Some(lib) = self.library.as_mut() else { return Ok(()) };
|
||||
|
||||
@@ -0,0 +1,527 @@
|
||||
//! Slideshow title screens (Lightroom Classic's Slideshow ▸ Titles panel): an intro shown before
|
||||
//! the first slide and an ending shown after the last one, each switched on on its own, with a
|
||||
//! background colour and, centred on it, the identity plate (the one of the Overlays panel: its
|
||||
//! text or image) or a line of text, at a scale. [`TitleSettings`] is a library preference
|
||||
//! (`prefs.json`), edited with the `slideshow.titles` command; [`TitleCompose`] draws one screen.
|
||||
//!
|
||||
//! **One composer:** a title screen is a slide with no photo: `lightcraft_slideshow::compose`
|
||||
//! fills it with the title's colour, then the overlay code ([`crate::slide_overlay`]) draws the
|
||||
//! plate or text with the shared text renderer ([`crate::text`]). Playback, the Settings preview
|
||||
//! and `slideshow.preview {title}` all go through [`TitleCompose::compose`], so they draw the same
|
||||
//! pixels.
|
||||
//!
|
||||
//! **Timing** is in [`crate::slideshow::Playback`]: each screen stays up for its own duration
|
||||
//! (default: the slide duration) and fades in and out with the playback fade.
|
||||
//!
|
||||
//! **Resolution independence:** the scale is the height of the plate (or of a text line) as a
|
||||
//! fraction of the slide's shorter side; text wider than the slide is scaled down to fit.
|
||||
//!
|
||||
//! **Never crash:** settings are validated by [`TitleSettings::merged`] and clamped by
|
||||
//! [`TitleSettings::sanitized`]; an unreadable plate image draws the colour alone (and
|
||||
//! `slideshow.titles` reports it).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use lightcraft_raster::Rgba8;
|
||||
use lightcraft_slideshow::{Layout, Rect, SlideSettings, hex, parse_color};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::export::Anchor;
|
||||
use crate::slide_overlay::{IdentityPlate, OverlaySettings, PLATE_SIZE_MAX, Placement, RelativeTo, SIZE_MIN, SlideOverlays, TEXT_MAX_CHARS};
|
||||
pub use crate::slideshow::Title;
|
||||
|
||||
/// Widest a title's plate or text may be (fraction of the slide's width): wider is scaled down.
|
||||
pub const FIT_WIDTH: f64 = 0.9;
|
||||
|
||||
/// What a title screen shows on its colour.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum TitleContent {
|
||||
/// The colour alone.
|
||||
None,
|
||||
/// The identity plate of the Overlays panel (its text or image).
|
||||
#[default]
|
||||
IdentityPlate,
|
||||
/// This screen's own text.
|
||||
Text,
|
||||
}
|
||||
|
||||
impl TitleContent {
|
||||
pub const ALL: [TitleContent; 3] = [TitleContent::None, TitleContent::IdentityPlate, TitleContent::Text];
|
||||
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
TitleContent::None => "none",
|
||||
TitleContent::IdentityPlate => "identityPlate",
|
||||
TitleContent::Text => "text",
|
||||
}
|
||||
}
|
||||
|
||||
fn parse(s: &str) -> Option<TitleContent> {
|
||||
TitleContent::ALL.into_iter().find(|c| c.id().eq_ignore_ascii_case(s.trim()))
|
||||
}
|
||||
}
|
||||
|
||||
/// One title screen.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct TitleScreen {
|
||||
pub enabled: bool,
|
||||
/// The screen's background colour.
|
||||
pub color: [u8; 3],
|
||||
pub content: TitleContent,
|
||||
/// The text drawn with [`TitleContent::Text`].
|
||||
pub text: String,
|
||||
/// Draw the plate or text in [`Self::text_color`] (off: in the identity plate's colour).
|
||||
/// An image plate keeps its own colours.
|
||||
pub override_color: bool,
|
||||
pub text_color: [u8; 3],
|
||||
/// Height of the plate (or of a text line), fraction of the slide's shorter side.
|
||||
pub scale: f64,
|
||||
/// Seconds the screen stays up (`None`: the slide duration).
|
||||
pub duration: Option<f64>,
|
||||
}
|
||||
|
||||
impl TitleScreen {
|
||||
fn new(text: &str) -> Self {
|
||||
TitleScreen {
|
||||
enabled: false,
|
||||
color: [0, 0, 0],
|
||||
content: TitleContent::IdentityPlate,
|
||||
text: text.into(),
|
||||
override_color: false,
|
||||
text_color: [255, 255, 255],
|
||||
scale: 0.1,
|
||||
duration: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The intro and ending screens.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct TitleSettings {
|
||||
pub intro: TitleScreen,
|
||||
pub ending: TitleScreen,
|
||||
}
|
||||
|
||||
impl Default for TitleSettings {
|
||||
fn default() -> Self {
|
||||
TitleSettings { intro: TitleScreen::new("Welcome"), ending: TitleScreen::new("The End") }
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TitleScreen {
|
||||
fn default() -> Self {
|
||||
TitleScreen::new("")
|
||||
}
|
||||
}
|
||||
|
||||
fn cut(s: &mut String) {
|
||||
if let Some((i, _)) = s.char_indices().nth(TEXT_MAX_CHARS) {
|
||||
s.truncate(i);
|
||||
}
|
||||
}
|
||||
|
||||
const SCREEN_KEYS: &str = "enabled, color, content, text, overrideColor, textColor, scale, duration";
|
||||
|
||||
impl TitleScreen {
|
||||
fn sanitized(mut self) -> Self {
|
||||
cut(&mut self.text);
|
||||
self.scale = if self.scale.is_finite() { self.scale.clamp(SIZE_MIN, PLATE_SIZE_MAX) } else { 0.1 };
|
||||
self.duration = self.duration.filter(|d| d.is_finite()).map(|d| d.clamp(crate::slideshow::SLIDE_MIN, crate::slideshow::SLIDE_MAX));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn to_json(&self) -> Value {
|
||||
let mut o = Map::new();
|
||||
o.insert("enabled".into(), json!(self.enabled));
|
||||
o.insert("color".into(), json!(hex(self.color)));
|
||||
o.insert("content".into(), json!(self.content.id()));
|
||||
o.insert("text".into(), json!(self.text));
|
||||
o.insert("overrideColor".into(), json!(self.override_color));
|
||||
o.insert("textColor".into(), json!(hex(self.text_color)));
|
||||
o.insert("scale".into(), json!(self.scale));
|
||||
o.insert("duration".into(), json!(self.duration));
|
||||
Value::Object(o)
|
||||
}
|
||||
|
||||
fn merge(&mut self, ctx: &str, v: &Value) -> Result<(), String> {
|
||||
let obj = v.as_object().ok_or_else(|| format!("`{ctx}` must be an object, not {v}"))?;
|
||||
let num = |k: &str, v: &Value| match v.as_f64() {
|
||||
Some(x) if x.is_finite() => Ok(x),
|
||||
_ => Err(format!("`{ctx}.{k}` must be a finite number, not {v}")),
|
||||
};
|
||||
let boolean = |k: &str, v: &Value| v.as_bool().ok_or_else(|| format!("`{ctx}.{k}` must be true or false, not {v}"));
|
||||
let colour = |k: &str, v: &Value| parse_color(v).ok_or_else(|| format!("`{ctx}.{k}` must be \"#rrggbb\" or [r, g, b] (0–255), not {v}"));
|
||||
for (k, v) in obj {
|
||||
match k.as_str() {
|
||||
"enabled" => self.enabled = boolean(k, v)?,
|
||||
"color" => self.color = colour(k, v)?,
|
||||
"content" => {
|
||||
self.content = v
|
||||
.as_str()
|
||||
.and_then(TitleContent::parse)
|
||||
.ok_or_else(|| format!("`{ctx}.content` must be \"none\", \"identityPlate\" or \"text\", not {v}"))?
|
||||
}
|
||||
"text" => {
|
||||
let mut s = v.as_str().ok_or_else(|| format!("`{ctx}.text` must be text, not {v}"))?.to_string();
|
||||
cut(&mut s);
|
||||
self.text = s;
|
||||
}
|
||||
"overrideColor" => self.override_color = boolean(k, v)?,
|
||||
"textColor" => self.text_color = colour(k, v)?,
|
||||
"scale" => self.scale = num(k, v)?,
|
||||
"duration" => {
|
||||
self.duration = match v {
|
||||
Value::Null => None,
|
||||
_ => Some(num(k, v).map_err(|_| format!("`{ctx}.duration` must be seconds or null (the slide duration), not {v}"))?),
|
||||
}
|
||||
}
|
||||
_ => return Err(format!("unknown key `{ctx}.{k}` ({SCREEN_KEYS})")),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl TitleSettings {
|
||||
pub fn screen(&self, t: Title) -> &TitleScreen {
|
||||
match t {
|
||||
Title::Intro => &self.intro,
|
||||
Title::Ending => &self.ending,
|
||||
}
|
||||
}
|
||||
|
||||
/// Every value in range (non-finite numbers take the defaults).
|
||||
pub fn sanitized(mut self) -> Self {
|
||||
self.intro = self.intro.sanitized();
|
||||
self.ending = self.ending.sanitized();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn to_json(&self) -> Value {
|
||||
json!({"intro": self.intro.to_json(), "ending": self.ending.to_json()})
|
||||
}
|
||||
|
||||
/// These settings with the keys `p` names changed, validated and clamped; a wrong type or an
|
||||
/// unknown key is an error and nothing changes.
|
||||
pub fn merged(&self, p: &Value) -> 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() {
|
||||
"intro" => s.intro.merge(k, v)?,
|
||||
"ending" => s.ending.merge(k, v)?,
|
||||
_ => return Err(format!("unknown setting `{k}` (intro, ending)")),
|
||||
}
|
||||
}
|
||||
Ok(s.sanitized())
|
||||
}
|
||||
|
||||
/// How long each screen that is on stays up, given the slide duration (for
|
||||
/// [`crate::slideshow::Playback::with_titles`]): (intro, ending).
|
||||
pub fn durations(&self, slide: f64) -> (Option<f64>, Option<f64>) {
|
||||
let d = |s: &TitleScreen| s.enabled.then(|| s.duration.unwrap_or(slide));
|
||||
(d(&self.intro), d(&self.ending))
|
||||
}
|
||||
|
||||
/// Problems worth telling the user about: a screen showing an identity plate image that
|
||||
/// can't be read, or one with nothing to show.
|
||||
pub fn warnings(&self, overlays: &OverlaySettings) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let plate = &overlays.identity_plate;
|
||||
for t in Title::ALL {
|
||||
let s = self.screen(t);
|
||||
if !s.enabled {
|
||||
continue;
|
||||
}
|
||||
match s.content {
|
||||
TitleContent::IdentityPlate if !plate.image.trim().is_empty() && crate::export::watermark_logo(plate.image.trim()).is_none() => {
|
||||
out.push(format!("{} screen: identity plate image {} can't be read; only the colour shows", t.id(), plate.image.trim()))
|
||||
}
|
||||
TitleContent::IdentityPlate if plate.image.trim().is_empty() && plate.text.trim().is_empty() => {
|
||||
out.push(format!("{} screen: the identity plate has no text or image; only the colour shows", t.id()))
|
||||
}
|
||||
TitleContent::Text if s.text.trim().is_empty() => out.push(format!("{} screen: no text; only the colour shows", t.id())),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// How to draw one title screen at a size: its colour and its plate or text, resolved from the
|
||||
/// library's settings (no session needed, so it runs on a render worker).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TitleCompose {
|
||||
pub title: Title,
|
||||
/// The screen as a slide: its colour as the backdrop, nothing else.
|
||||
pub settings: SlideSettings,
|
||||
/// The plate or text, as a centred identity plate (`None`: the colour alone).
|
||||
pub overlays: Option<Arc<SlideOverlays>>,
|
||||
pub width: usize,
|
||||
pub height: usize,
|
||||
}
|
||||
|
||||
impl TitleCompose {
|
||||
/// Screen `title` of `titles` at `width × height` (a bigger one is drawn at the composer's
|
||||
/// [`lightcraft_slideshow::MAX_SIDE`]),
|
||||
/// with `overlays`' identity plate when the screen shows it.
|
||||
pub fn new(titles: &TitleSettings, overlays: &OverlaySettings, title: Title, width: usize, height: usize) -> Self {
|
||||
let s = titles.screen(title).clone().sanitized();
|
||||
let settings = SlideSettings { backdrop_color: s.color, ..SlideSettings::default() }.sanitized();
|
||||
let plate = &overlays.identity_plate;
|
||||
let centre = Placement { anchor: Anchor::Center, offset_x: 0.0, offset_y: 0.0, relative_to: RelativeTo::Slide };
|
||||
let colour = |own: [u8; 3]| if s.override_color { s.text_color } else { own };
|
||||
let shown = match s.content {
|
||||
TitleContent::None => None,
|
||||
TitleContent::IdentityPlate => Some(IdentityPlate {
|
||||
enabled: true,
|
||||
text: plate.text.clone(),
|
||||
image: plate.image.clone(),
|
||||
size: s.scale,
|
||||
color: colour(plate.color),
|
||||
opacity: plate.opacity,
|
||||
place: centre,
|
||||
behind: false,
|
||||
}),
|
||||
TitleContent::Text => Some(IdentityPlate {
|
||||
enabled: true,
|
||||
text: s.text.clone(),
|
||||
image: String::new(),
|
||||
size: s.scale,
|
||||
color: colour(plate.color),
|
||||
opacity: 1.0,
|
||||
place: centre,
|
||||
behind: false,
|
||||
}),
|
||||
};
|
||||
let mut compose = TitleCompose { title, settings, overlays: None, width, height };
|
||||
if let Some(p) = shown.filter(|p| !p.text.trim().is_empty() || !p.image.trim().is_empty()) {
|
||||
let resolved =
|
||||
|p: IdentityPlate| Arc::new(SlideOverlays::resolve(&OverlaySettings { identity_plate: p, ..OverlaySettings::default() }, None));
|
||||
compose.overlays = Some(resolved(p.clone()));
|
||||
// a long text (or a wide logo) at a large scale: shrink it to fit the slide's width
|
||||
if let Some(b) = compose.plate_box() {
|
||||
let max = FIT_WIDTH * compose.layout().slide.width();
|
||||
if b.width() > max && b.width() > 0.0 {
|
||||
let size = (p.size * max / b.width()).max(SIZE_MIN);
|
||||
compose.overlays = Some(resolved(IdentityPlate { size, ..p }));
|
||||
}
|
||||
}
|
||||
}
|
||||
compose
|
||||
}
|
||||
|
||||
fn layout(&self) -> Layout {
|
||||
lightcraft_slideshow::layout(&self.settings, 1.0, self.width, self.height)
|
||||
}
|
||||
|
||||
/// Where the plate or text is on the screen (`None`: nothing is drawn but the colour).
|
||||
pub fn plate_box(&self) -> Option<Rect> {
|
||||
let o = self.overlays.as_ref()?;
|
||||
o.boxes(&self.layout()).into_iter().next().map(|(_, r)| r)
|
||||
}
|
||||
|
||||
/// What makes the screen's pixels differ (for render keys).
|
||||
pub fn key(&self) -> u64 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
"title".hash(&mut h);
|
||||
self.title.hash(&mut h);
|
||||
self.settings.pixel_key().hash(&mut h);
|
||||
self.overlays.as_ref().map(|o| o.key()).hash(&mut h);
|
||||
(self.width, self.height).hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// The screen's pixels.
|
||||
pub fn compose(&self) -> Rgba8 {
|
||||
let empty = Rgba8::new(0, 0);
|
||||
let (mut img, l) = lightcraft_slideshow::compose_with(&empty, None, &self.settings, self.width, self.height, &mut |_, _| {});
|
||||
if let Some(o) = &self.overlays {
|
||||
o.draw_front(&mut img, &l);
|
||||
}
|
||||
img
|
||||
}
|
||||
|
||||
/// [`Self::compose`] as a render result (for a frontend's render queue): request `request_id`,
|
||||
/// under this screen's key. Title screens show no photo (`photo` is 0).
|
||||
pub fn result(&self, request_id: u64) -> crate::media::RenderResult {
|
||||
let image = self.compose();
|
||||
let histogram = lightcraft_raster::Histogram::of_srgb8(&image);
|
||||
crate::media::RenderResult {
|
||||
request_id,
|
||||
source_key: None,
|
||||
photo: lightcraft_catalog::PhotoId(0),
|
||||
level: crate::media::SourceLevel::Preview,
|
||||
key: self.key(),
|
||||
rendered: Ok(lightcraft_pipeline::Rendered { image, histogram, deep: None }),
|
||||
loaded: None,
|
||||
quick: None,
|
||||
display: None,
|
||||
gpu_fallback: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::Session {
|
||||
/// Title screen `title` at `width × height` with the library's titles and identity plate.
|
||||
pub fn title_compose(&self, title: Title, width: usize, height: usize) -> Arc<TitleCompose> {
|
||||
Arc::new(TitleCompose::new(&self.slide_titles, &self.slide_overlays, title, width, height))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn at(img: &Rgba8, x: usize, y: usize) -> [u8; 3] {
|
||||
let p = img.get(x, y);
|
||||
[p[0], p[1], p[2]]
|
||||
}
|
||||
|
||||
/// The bounding box of the pixels that differ from `bg` (x0, y0, x1, y1).
|
||||
fn ink(img: &Rgba8, bg: [u8; 3]) -> Option<(usize, usize, usize, usize)> {
|
||||
let mut b: Option<(usize, usize, usize, usize)> = None;
|
||||
for y in 0..img.height {
|
||||
for x in 0..img.width {
|
||||
let c = at(img, x, y);
|
||||
if c.iter().zip(bg).any(|(a, b)| a.abs_diff(b) > 40) {
|
||||
b = Some(match b {
|
||||
None => (x, y, x + 1, y + 1),
|
||||
Some((x0, y0, x1, y1)) => (x0.min(x), y0.min(y), x1.max(x + 1), y1.max(y + 1)),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
fn text_titles(scale: f64) -> TitleSettings {
|
||||
TitleSettings::default()
|
||||
.merged(&json!({"intro": {"enabled": true, "color": "#203040", "content": "text", "text": "Holiday", "overrideColor": true, "textColor": "#ffcc00", "scale": scale}}))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_text_title_is_centred_in_its_colours_and_scales_with_the_slide() {
|
||||
let o = OverlaySettings::default();
|
||||
let t = text_titles(0.2);
|
||||
let mut boxes = Vec::new();
|
||||
for (w, h) in [(400, 250), (800, 500)] {
|
||||
let c = TitleCompose::new(&t, &o, Title::Intro, w, h);
|
||||
let img = c.compose();
|
||||
assert_eq!((img.width, img.height), (w, h));
|
||||
assert_eq!(at(&img, 2, 2), [0x20, 0x30, 0x40], "the title's colour");
|
||||
let (x0, y0, x1, y1) = ink(&img, [0x20, 0x30, 0x40]).expect("the text is drawn");
|
||||
// centred (glyph ink sits inside the line box, so allow some slack vertically)
|
||||
let (cx, cy) = ((x0 + x1) as f64 / 2.0, (y0 + y1) as f64 / 2.0);
|
||||
assert!((cx - w as f64 / 2.0).abs() < w as f64 * 0.03, "centred across: {cx} of {w}");
|
||||
assert!((cy - h as f64 / 2.0).abs() < h as f64 * 0.06, "centred down: {cy} of {h}");
|
||||
let mid = at(&img, (x0..x1).find(|x| at(&img, *x, (y0 + y1) / 2) != [0x20, 0x30, 0x40]).unwrap_or(x0), (y0 + y1) / 2);
|
||||
assert!(mid[0] > 150 && mid[2] < 120, "drawn in the text colour: {mid:?}");
|
||||
boxes.push(((x1 - x0) as f64 / w as f64, (y1 - y0) as f64 / h as f64));
|
||||
let b = c.plate_box().unwrap();
|
||||
assert!((b.height() - 0.2 * h as f64).abs() < 2.0, "the scale is the line height: {b:?}");
|
||||
}
|
||||
let ((w1, h1), (w2, h2)) = (boxes[0], boxes[1]);
|
||||
assert!((w1 - w2).abs() < 0.02 && (h1 - h2).abs() < 0.02, "proportional at two sizes: {boxes:?}");
|
||||
// a bigger scale draws bigger text
|
||||
let big = ink(&TitleCompose::new(&text_titles(0.3), &o, Title::Intro, 800, 500).compose(), [0x20, 0x30, 0x40]).unwrap();
|
||||
assert!((big.2 - big.0) as f64 / 800.0 > w2 * 1.3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_identity_plate_shows_in_its_own_colour_unless_overridden() {
|
||||
let o = OverlaySettings::default()
|
||||
.merged(&json!({"identityPlate": {"text": "Studio", "color": "#00ff00", "opacity": 1, "anchor": "topLeft"}}))
|
||||
.unwrap();
|
||||
assert!(!o.identity_plate.enabled, "the overlay plate itself may be off");
|
||||
let t = TitleSettings::default().merged(&json!({"ending": {"enabled": true, "color": "#000000", "scale": 0.15}})).unwrap();
|
||||
let img = TitleCompose::new(&t, &o, Title::Ending, 600, 400).compose();
|
||||
let (x0, y0, x1, y1) = ink(&img, [0, 0, 0]).expect("the plate is drawn");
|
||||
assert!(x0 > 100 && x1 < 500 && y0 > 120 && y1 < 280, "centred, not at the overlay's anchor: {:?}", (x0, y0, x1, y1));
|
||||
let greens = (y0..y1).flat_map(|y| (x0..x1).map(move |x| (x, y))).filter(|&(x, y)| {
|
||||
let c = at(&img, x, y);
|
||||
c[1] > 200 && c[0] < 60
|
||||
});
|
||||
assert!(greens.count() > 100, "in the plate's colour");
|
||||
let over = t.merged(&json!({"ending": {"overrideColor": true, "textColor": "#ff0000"}})).unwrap();
|
||||
let img = TitleCompose::new(&over, &o, Title::Ending, 600, 400).compose();
|
||||
let reds = img.data.iter().filter(|p| p[0] > 200 && p[1] < 60).count();
|
||||
assert!(reds > 100, "overridden");
|
||||
// the colour alone
|
||||
let none = t.merged(&json!({"ending": {"content": "none", "color": "#123456"}})).unwrap();
|
||||
let c = TitleCompose::new(&none, &o, Title::Ending, 64, 40);
|
||||
assert!(c.plate_box().is_none());
|
||||
assert!(c.compose().data.iter().all(|p| p[..3] == [0x12, 0x34, 0x56]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_plate_image_draws_the_colour_and_is_reported() {
|
||||
let o = OverlaySettings::default().merged(&json!({"identityPlate": {"image": "/no/such/logo.png"}})).unwrap();
|
||||
let t = TitleSettings::default().merged(&json!({"intro": {"enabled": true, "color": "#336699"}})).unwrap();
|
||||
let c = TitleCompose::new(&t, &o, Title::Intro, 120, 80);
|
||||
assert!(c.compose().data.iter().all(|p| p[..3] == [0x33, 0x66, 0x99]));
|
||||
let w = t.warnings(&o);
|
||||
assert!(w.iter().any(|w| w.contains("intro") && w.contains("can't be read")), "{w:?}");
|
||||
let t = t.merged(&json!({"intro": {"content": "text", "text": " "}})).unwrap();
|
||||
assert!(t.warnings(&o).iter().any(|w| w.contains("no text")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_text_is_scaled_down_to_fit_the_width() {
|
||||
let t = TitleSettings::default()
|
||||
.merged(&json!({"intro": {"enabled": true, "content": "text", "text": "A very long title that would never fit across", "scale": 0.5}}))
|
||||
.unwrap();
|
||||
let c = TitleCompose::new(&t, &OverlaySettings::default(), Title::Intro, 800, 450);
|
||||
let b = c.plate_box().unwrap();
|
||||
assert!(b.width() <= 800.0 * FIT_WIDTH + 2.0 && b.x0 >= 0.0, "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_are_validated_clamped_and_round_trip() {
|
||||
let d = TitleSettings::default();
|
||||
assert!(!d.intro.enabled && !d.ending.enabled, "off by default");
|
||||
assert_eq!(d.durations(4.0), (None, None));
|
||||
for bad in [
|
||||
json!({"intro": {"enabled": 1}}),
|
||||
json!({"intro": {"color": "#12"}}),
|
||||
json!({"ending": {"content": "video"}}),
|
||||
json!({"ending": {"scale": "big"}}),
|
||||
json!({"ending": {"duration": "long"}}),
|
||||
json!({"intro": {"sparkle": true}}),
|
||||
json!({"intro": []}),
|
||||
json!({"credits": {}}),
|
||||
json!({"INTRO": {}}),
|
||||
json!([1]),
|
||||
] {
|
||||
assert!(d.merged(&bad).is_err(), "{bad}");
|
||||
}
|
||||
let s = d
|
||||
.merged(&json!({"intro": {"enabled": true, "scale": 9, "duration": 1e9}, "ending": {"enabled": true, "scale": -1, "duration": 0}}))
|
||||
.unwrap();
|
||||
assert_eq!((s.intro.scale, s.ending.scale), (PLATE_SIZE_MAX, SIZE_MIN));
|
||||
assert_eq!((s.intro.duration, s.ending.duration), (Some(crate::slideshow::SLIDE_MAX), Some(crate::slideshow::SLIDE_MIN)));
|
||||
assert_eq!(s.durations(4.0), (Some(crate::slideshow::SLIDE_MAX), Some(crate::slideshow::SLIDE_MIN)));
|
||||
let s = s.merged(&json!({"intro": {"duration": null}})).unwrap();
|
||||
assert_eq!(s.durations(4.0).0, Some(4.0), "null: the slide duration");
|
||||
assert_eq!(d.merged(&s.to_json()).unwrap(), s, "the JSON form reads back");
|
||||
let back: TitleSettings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
|
||||
assert_eq!(back, s);
|
||||
let partial: TitleSettings = serde_json::from_str(r#"{"ending": {"enabled": true}}"#).unwrap();
|
||||
assert!(partial.ending.enabled && !partial.intro.enabled);
|
||||
// NaN / ∞ from a hand-edited file
|
||||
let s = TitleSettings { intro: TitleScreen { scale: f64::NAN, duration: Some(f64::INFINITY), ..d.intro.clone() }, ..d.clone() }.sanitized();
|
||||
assert_eq!((s.intro.scale, s.intro.duration), (0.1, None));
|
||||
// any size composes
|
||||
for (w, h) in [(0, 0), (1, 100_000), (usize::MAX, 3)] {
|
||||
let c = TitleCompose::new(&text_titles(0.5), &OverlaySettings::default(), Title::Intro, w, h);
|
||||
let img = c.compose();
|
||||
assert!(img.width >= 1 && img.height >= 1 && img.width <= lightcraft_slideshow::MAX_SIDE);
|
||||
}
|
||||
}
|
||||
}
|
||||
+379
-82
@@ -1,5 +1,6 @@
|
||||
//! 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).
|
||||
//! 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.
|
||||
@@ -131,15 +132,72 @@ fn color(v: &Value) -> std::result::Result<Option<[u8; 3]>, String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(PhotoId),
|
||||
Waiting(Item),
|
||||
/// A new slide came up (its transition starts now).
|
||||
Advanced(PhotoId),
|
||||
Advanced(Item),
|
||||
/// The last slide was shown and repeat is off.
|
||||
Ended,
|
||||
}
|
||||
@@ -147,34 +205,51 @@ pub enum Tick {
|
||||
/// What to draw at a moment of playback.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Frame {
|
||||
pub current: PhotoId,
|
||||
pub current: Item,
|
||||
/// The slide being faded out, while a transition runs.
|
||||
pub from: Option<PhotoId>,
|
||||
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]>,
|
||||
}
|
||||
|
||||
/// One run of a slideshow over a fixed list of photos.
|
||||
/// 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 slide up now.
|
||||
/// 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<PhotoId>,
|
||||
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<PhotoId>,
|
||||
held: Option<Item>,
|
||||
}
|
||||
|
||||
/// SplitMix64: a small, well-mixed generator for the shuffle (deterministic per seed).
|
||||
@@ -216,7 +291,36 @@ impl Playback {
|
||||
(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 })
|
||||
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 {
|
||||
@@ -241,14 +345,53 @@ impl Playback {
|
||||
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))
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
/// (slide number from 1, slides per pass).
|
||||
/// 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) {
|
||||
(self.pos + 1, self.order.len())
|
||||
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 {
|
||||
@@ -260,7 +403,7 @@ impl Playback {
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
pub fn waiting_for(&self) -> Option<Item> {
|
||||
self.held
|
||||
}
|
||||
|
||||
@@ -270,9 +413,23 @@ impl Playback {
|
||||
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.settings.slide_duration
|
||||
self.started + self.duration(self.current())
|
||||
}
|
||||
|
||||
/// Seconds until the next slide is due (as of `now`; frozen while paused).
|
||||
@@ -280,24 +437,18 @@ impl Playback {
|
||||
(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> {
|
||||
/// 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 pos, mut pass) = (self.pos, self.pass);
|
||||
let (mut phase, mut pos, mut pass) = (self.phase, self.pos, self.pass);
|
||||
let current = self.current();
|
||||
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),
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -306,27 +457,29 @@ impl Playback {
|
||||
|
||||
/// 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;
|
||||
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
|
||||
}
|
||||
}
|
||||
Tick::Advanced(self.current())
|
||||
}
|
||||
|
||||
/// Advance the timeline to `now`. `ready(photo)` says whether a slide can be shown: the
|
||||
/// 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(PhotoId) -> bool) -> Tick {
|
||||
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.settings.fade_duration {
|
||||
if self.from.is_some() && now >= self.started + self.fade_to(self.current()) {
|
||||
self.from = None;
|
||||
}
|
||||
if self.paused_at.is_some() {
|
||||
@@ -356,9 +509,9 @@ impl Playback {
|
||||
self.held = None;
|
||||
let old = self.current();
|
||||
let t = self.advance();
|
||||
if let Tick::Advanced(_) = t {
|
||||
if let Tick::Advanced(new) = t {
|
||||
self.started = now;
|
||||
self.from = (self.settings.fade_duration > 0.0).then_some(old);
|
||||
self.from = (self.fade_to(new) > 0.0).then_some(old);
|
||||
}
|
||||
t
|
||||
}
|
||||
@@ -366,12 +519,13 @@ impl Playback {
|
||||
/// What to draw at `now`.
|
||||
pub fn frame(&self, now: f64) -> Frame {
|
||||
let now = self.clock(now);
|
||||
let fade = self.settings.fade_duration;
|
||||
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: self.current(), from, progress, fade_color: self.settings.fade_color }
|
||||
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
|
||||
@@ -389,15 +543,17 @@ impl Playback {
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// 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 steps = delta.unsigned_abs().min(self.order.len().min(u32::MAX as usize) as u32);
|
||||
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 {
|
||||
@@ -408,10 +564,13 @@ impl Playback {
|
||||
}
|
||||
} 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);
|
||||
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());
|
||||
@@ -438,34 +597,38 @@ mod tests {
|
||||
PlaybackSettings { slide_duration: slide, fade_duration: fade, ..Default::default() }
|
||||
}
|
||||
|
||||
fn all_ready(_: PhotoId) -> bool {
|
||||
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(), PhotoId(2), "starts at the selected photo");
|
||||
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(PhotoId(3)));
|
||||
assert_eq!(p.tick(14.0, all_ready), Tick::Advanced(ph(3)));
|
||||
let f = p.frame(14.25);
|
||||
assert_eq!((f.current, f.from), (PhotoId(3), Some(PhotoId(2))));
|
||||
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(PhotoId(1)), "wraps to the photos before the start");
|
||||
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(PhotoId(2)));
|
||||
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();
|
||||
@@ -478,13 +641,13 @@ mod tests {
|
||||
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.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(PhotoId(2)));
|
||||
assert_eq!(p.step(-1, 6.0), Tick::Advanced(PhotoId(1)));
|
||||
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(PhotoId(4)));
|
||||
assert_eq!(p.step(-1, 6.0), Tick::Advanced(ph(4)));
|
||||
assert_eq!(p.remaining(6.0), 4.0);
|
||||
}
|
||||
|
||||
@@ -494,10 +657,10 @@ mod tests {
|
||||
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()];
|
||||
let mut seen = vec![p.current_photo().unwrap()];
|
||||
for k in 1..60 {
|
||||
p.tick(k as f64, all_ready);
|
||||
seen.push(p.current());
|
||||
seen.push(p.current_photo().unwrap());
|
||||
}
|
||||
seen
|
||||
};
|
||||
@@ -519,14 +682,14 @@ mod tests {
|
||||
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.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(PhotoId(2)));
|
||||
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();
|
||||
@@ -543,7 +706,7 @@ mod tests {
|
||||
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");
|
||||
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);
|
||||
@@ -554,25 +717,25 @@ mod tests {
|
||||
#[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_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 = |_: PhotoId| false;
|
||||
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(PhotoId(2)), "2 s from when it could be shown");
|
||||
assert_eq!(p.tick(7.0, all_ready), Tick::Advanced(ph(2)), "2 s from when it could be shown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -619,4 +782,138 @@ mod tests {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,3 +227,86 @@ fn slide_preview_draws_the_overlays() {
|
||||
assert_eq!(count(&plain, b, want), 0, "{kind}: not there without overlays");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slide_titles_are_validated_persist_and_preview() {
|
||||
let lib = temp_dir("titles");
|
||||
{
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
let r = s.execute("slideshow.titles", &json!({})).unwrap();
|
||||
assert_eq!((r["intro"]["enabled"].as_bool(), r["ending"]["enabled"].as_bool()), (Some(false), Some(false)), "off by default: {r}");
|
||||
assert_eq!(r["intro"]["content"], "identityPlate");
|
||||
assert_eq!(r["intro"]["duration"], serde_json::Value::Null, "the slide duration");
|
||||
let r = s
|
||||
.execute(
|
||||
"slideshow.titles",
|
||||
&json!({"intro": {"enabled": true, "color": "#102030", "content": "text", "text": "Iceland 2026", "scale": 0.12, "duration": 3}, "ending": {"enabled": true, "color": [255, 255, 255]}}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!((r["intro"]["color"].as_str(), r["intro"]["duration"].as_f64()), (Some("#102030"), Some(3.0)));
|
||||
assert_eq!(r["ending"]["color"], "#ffffff");
|
||||
// a bad value changes nothing, the good keys beside it included
|
||||
for bad in [
|
||||
json!({"intro": {"enabled": false, "scale": "huge"}}),
|
||||
json!({"ending": {"content": "credits"}}),
|
||||
json!({"intro": {"duration": -1, "color": "blue"}}),
|
||||
json!({"middle": {}}),
|
||||
] {
|
||||
assert!(s.execute("slideshow.titles", &bad).is_err(), "{bad}");
|
||||
}
|
||||
assert!(s.slide_titles.intro.enabled);
|
||||
// out of range: clamped
|
||||
let r = s.execute("slideshow.titles", &json!({"ending": {"scale": 4, "duration": 1e6}})).unwrap();
|
||||
assert_eq!((r["ending"]["scale"].as_f64(), r["ending"]["duration"].as_f64()), (Some(0.5), Some(crate::slideshow::SLIDE_MAX)));
|
||||
// a plate image that can't be read is reported
|
||||
s.execute("slideshow.overlays", &json!({"identityPlate": {"image": "/no/such/plate.png"}})).unwrap();
|
||||
let r = s.execute("slideshow.titles", &json!({})).unwrap();
|
||||
let w = r["warnings"].as_array().unwrap();
|
||||
assert!(w.iter().any(|w| w.as_str().unwrap().contains("ending screen")), "{r}");
|
||||
drop(s);
|
||||
}
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
assert_eq!(s.slide_titles.intro.text, "Iceland 2026", "saved with the library");
|
||||
assert_eq!(s.slide_titles.durations(4.0), (Some(3.0), Some(crate::slideshow::SLIDE_MAX)));
|
||||
drop(s);
|
||||
// hand-edited values out of range are clamped when the library opens
|
||||
std::fs::write(lib.join("prefs.json"), r#"{"slideTitles": {"intro": {"enabled": true, "scale": -4, "duration": 1e99}}}"#).unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
assert!(s.slide_titles.intro.enabled);
|
||||
assert_eq!((s.slide_titles.intro.scale, s.slide_titles.intro.duration), (crate::slide_overlay::SIZE_MIN, Some(crate::slideshow::SLIDE_MAX)));
|
||||
drop(s);
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slide_preview_renders_a_title_screen() {
|
||||
let mut s = Session::with_demo();
|
||||
s.execute(
|
||||
"slideshow.titles",
|
||||
&json!({"ending": {"color": "#00ff00", "content": "text", "text": "Fin", "overrideColor": true, "textColor": "#ff0000", "scale": 0.2}}),
|
||||
)
|
||||
.unwrap();
|
||||
let r = s.execute("slideshow.preview", &json!({"title": "ending", "width": 400, "height": 250})).unwrap();
|
||||
assert_eq!(
|
||||
(r["title"].as_str(), r["enabled"].as_bool(), r["width"].as_u64(), r["height"].as_u64()),
|
||||
(Some("ending"), Some(false), Some(400), Some(250))
|
||||
);
|
||||
let png = base64_decode(r["png"].as_str().unwrap());
|
||||
assert_eq!(png_size(&png), (400, 250));
|
||||
let b: Vec<f64> = r["plate"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
|
||||
assert!((b[3] - b[1] - 50.0).abs() < 1.5, "20 % of the 250 px short side: {b:?}");
|
||||
assert!(((b[0] + b[2]) / 2.0 - 200.0).abs() < 1.0 && ((b[1] + b[3]) / 2.0 - 125.0).abs() < 1.0, "centred: {b:?}");
|
||||
// the same pixels as playback composes
|
||||
let img = s.title_compose(crate::slideshow::Title::Ending, 400, 250).compose();
|
||||
assert_eq!(img.get(3, 3), [0, 255, 0, 255]);
|
||||
assert!(img.data.iter().any(|p| p[0] > 200 && p[1] < 60), "the red text");
|
||||
// no photo needed; refused: an unknown title, an id with it, bad sizes
|
||||
s.execute("library.select", &json!({"ids": []})).unwrap();
|
||||
assert!(s.execute("slideshow.preview", &json!({"title": "intro"})).is_ok());
|
||||
for bad in [json!({"title": "credits"}), json!({"title": 1}), json!({"title": "intro", "id": 1}), json!({"title": "intro", "width": 3})] {
|
||||
assert!(s.execute("slideshow.preview", &bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2161,5 +2161,21 @@
|
||||
"Add text overlay": "Textüberlagerung hinzufügen",
|
||||
"Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "Token: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Anderer Text erscheint wie eingegeben.",
|
||||
"Use export watermark": "Export-Wasserzeichen verwenden",
|
||||
"The export watermark, drawn on the photo and sized relative to it.": "Das Export-Wasserzeichen, auf das Foto gezeichnet und relativ zu ihm skaliert."
|
||||
"The export watermark, drawn on the photo and sized relative to it.": "Das Export-Wasserzeichen, auf das Foto gezeichnet und relativ zu ihm skaliert.",
|
||||
"Titles": "Titel",
|
||||
"Intro screen": "Intro-Bildschirm",
|
||||
"Ending screen": "Abschlussbildschirm",
|
||||
"Shows": "Zeigt",
|
||||
"Colour only": "Nur Farbe",
|
||||
"Override colour": "Farbe überschreiben",
|
||||
"Own duration (off: the slide duration)": "Eigene Dauer (aus: die Dia-Dauer)",
|
||||
"Duration": "Dauer",
|
||||
"Title text": "Titeltext",
|
||||
"Intro": "Intro",
|
||||
"Ending": "Abschluss",
|
||||
"End": "Ende",
|
||||
"The intro plays once before the first photo; the ending after the last photo when Repeat is off. The identity plate is the one set under Overlays. Titles fade like slides and random order never moves them.": "Das Intro läuft einmal vor dem ersten Foto, der Abschluss nach dem letzten Foto, wenn Wiederholen aus ist. Die Erkennungstafel ist die unter Überlagerungen eingestellte. Titel werden wie Dias überblendet, und die zufällige Reihenfolge verschiebt sie nie.",
|
||||
"This screen is off: turn it on under Titles to play it.": "Dieser Bildschirm ist aus: Schalten Sie ihn unter Titel ein, um ihn abzuspielen.",
|
||||
"The intro screen, before the first photo.": "Der Intro-Bildschirm, vor dem ersten Foto.",
|
||||
"The ending screen, after the last photo.": "Der Abschlussbildschirm, nach dem letzten Foto."
|
||||
}
|
||||
|
||||
@@ -2038,5 +2038,21 @@
|
||||
"Add text overlay": "Añadir superposición de texto",
|
||||
"Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. El resto del texto se muestra tal cual.",
|
||||
"Use export watermark": "Usar la marca de agua de exportación",
|
||||
"The export watermark, drawn on the photo and sized relative to it.": "La marca de agua de exportación, dibujada sobre la foto y escalada respecto a ella."
|
||||
"The export watermark, drawn on the photo and sized relative to it.": "La marca de agua de exportación, dibujada sobre la foto y escalada respecto a ella.",
|
||||
"Titles": "Títulos",
|
||||
"Intro screen": "Pantalla de introducción",
|
||||
"Ending screen": "Pantalla final",
|
||||
"Shows": "Muestra",
|
||||
"Colour only": "Solo color",
|
||||
"Override colour": "Reemplazar color",
|
||||
"Own duration (off: the slide duration)": "Duración propia (desactivada: la de la diapositiva)",
|
||||
"Duration": "Duración",
|
||||
"Title text": "Texto del título",
|
||||
"Intro": "Introducción",
|
||||
"Ending": "Final",
|
||||
"End": "Fin",
|
||||
"The intro plays once before the first photo; the ending after the last photo when Repeat is off. The identity plate is the one set under Overlays. Titles fade like slides and random order never moves them.": "La introducción se reproduce una vez antes de la primera foto; el final, después de la última foto cuando Repetir está desactivado. La placa de identidad es la definida en Superposiciones. Los títulos se funden como las diapositivas y el orden aleatorio nunca los mueve.",
|
||||
"This screen is off: turn it on under Titles to play it.": "Esta pantalla está desactivada: actívela en Títulos para reproducirla.",
|
||||
"The intro screen, before the first photo.": "La pantalla de introducción, antes de la primera foto.",
|
||||
"The ending screen, after the last photo.": "La pantalla final, después de la última foto."
|
||||
}
|
||||
|
||||
@@ -2000,5 +2000,21 @@
|
||||
"Add text overlay": "Ajouter une incrustation de texte",
|
||||
"Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "Jetons : {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Le reste du texte s'affiche tel quel.",
|
||||
"Use export watermark": "Utiliser le filigrane d'exportation",
|
||||
"The export watermark, drawn on the photo and sized relative to it.": "Le filigrane d'exportation, dessiné sur la photo et dimensionné par rapport à elle."
|
||||
"The export watermark, drawn on the photo and sized relative to it.": "Le filigrane d'exportation, dessiné sur la photo et dimensionné par rapport à elle.",
|
||||
"Titles": "Titres",
|
||||
"Intro screen": "Écran d'introduction",
|
||||
"Ending screen": "Écran de fin",
|
||||
"Shows": "Affiche",
|
||||
"Colour only": "Couleur seule",
|
||||
"Override colour": "Remplacer la couleur",
|
||||
"Own duration (off: the slide duration)": "Durée propre (désactivée : celle des diapositives)",
|
||||
"Duration": "Durée",
|
||||
"Title text": "Texte du titre",
|
||||
"Intro": "Intro",
|
||||
"Ending": "Fin",
|
||||
"End": "Fin",
|
||||
"The intro plays once before the first photo; the ending after the last photo when Repeat is off. The identity plate is the one set under Overlays. Titles fade like slides and random order never moves them.": "L'introduction est lue une fois avant la première photo ; l'écran de fin après la dernière photo lorsque Répéter est désactivé. La plaque d'identification est celle définie sous Incrustations. Les titres apparaissent en fondu comme les diapositives et l'ordre aléatoire ne les déplace jamais.",
|
||||
"This screen is off: turn it on under Titles to play it.": "Cet écran est désactivé : activez-le sous Titres pour le lire.",
|
||||
"The intro screen, before the first photo.": "L'écran d'introduction, avant la première photo.",
|
||||
"The ending screen, after the last photo.": "L'écran de fin, après la dernière photo."
|
||||
}
|
||||
|
||||
@@ -2004,5 +2004,21 @@
|
||||
"Add text overlay": "テキストオーバーレイを追加",
|
||||
"Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "トークン: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}。その他の文字は入力どおりに表示されます。",
|
||||
"Use export watermark": "書き出しの透かしを使用",
|
||||
"The export watermark, drawn on the photo and sized relative to it.": "書き出しの透かし。写真の上に、写真に対する比率で描かれます。"
|
||||
"The export watermark, drawn on the photo and sized relative to it.": "書き出しの透かし。写真の上に、写真に対する比率で描かれます。",
|
||||
"Titles": "タイトル",
|
||||
"Intro screen": "イントロ画面",
|
||||
"Ending screen": "エンディング画面",
|
||||
"Shows": "表示内容",
|
||||
"Colour only": "カラーのみ",
|
||||
"Override colour": "カラーを上書き",
|
||||
"Own duration (off: the slide duration)": "個別の再生時間(オフ:スライドの再生時間)",
|
||||
"Duration": "再生時間",
|
||||
"Title text": "タイトルテキスト",
|
||||
"Intro": "イントロ",
|
||||
"Ending": "エンディング",
|
||||
"End": "終了",
|
||||
"The intro plays once before the first photo; the ending after the last photo when Repeat is off. The identity plate is the one set under Overlays. Titles fade like slides and random order never moves them.": "イントロは最初の写真の前に 1 回再生され、エンディングは繰り返しがオフのとき最後の写真の後に再生されます。ID プレートはオーバーレイで設定したものです。タイトルはスライドと同様にフェードし、ランダム順でも位置は変わりません。",
|
||||
"This screen is off: turn it on under Titles to play it.": "この画面はオフです。再生するにはタイトルでオンにしてください。",
|
||||
"The intro screen, before the first photo.": "イントロ画面(最初の写真の前)。",
|
||||
"The ending screen, after the last photo.": "エンディング画面(最後の写真の後)。"
|
||||
}
|
||||
|
||||
@@ -2041,5 +2041,21 @@
|
||||
"Add text overlay": "Adicionar sobreposição de texto",
|
||||
"Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. O restante do texto aparece como digitado.",
|
||||
"Use export watermark": "Usar a marca d'água de exportação",
|
||||
"The export watermark, drawn on the photo and sized relative to it.": "A marca d'água de exportação, desenhada sobre a foto e dimensionada em relação a ela."
|
||||
"The export watermark, drawn on the photo and sized relative to it.": "A marca d'água de exportação, desenhada sobre a foto e dimensionada em relação a ela.",
|
||||
"Titles": "Títulos",
|
||||
"Intro screen": "Tela de abertura",
|
||||
"Ending screen": "Tela de encerramento",
|
||||
"Shows": "Mostra",
|
||||
"Colour only": "Somente cor",
|
||||
"Override colour": "Substituir cor",
|
||||
"Own duration (off: the slide duration)": "Duração própria (desativada: a duração do slide)",
|
||||
"Duration": "Duração",
|
||||
"Title text": "Texto do título",
|
||||
"Intro": "Abertura",
|
||||
"Ending": "Encerramento",
|
||||
"End": "Fim",
|
||||
"The intro plays once before the first photo; the ending after the last photo when Repeat is off. The identity plate is the one set under Overlays. Titles fade like slides and random order never moves them.": "A abertura é exibida uma vez antes da primeira foto; o encerramento, após a última foto quando Repetir está desativado. A placa de identidade é a definida em Sobreposições. Os títulos fazem transição como os slides e a ordem aleatória nunca os move.",
|
||||
"This screen is off: turn it on under Titles to play it.": "Esta tela está desativada: ative-a em Títulos para exibi-la.",
|
||||
"The intro screen, before the first photo.": "A tela de abertura, antes da primeira foto.",
|
||||
"The ending screen, after the last photo.": "A tela de encerramento, após a última foto."
|
||||
}
|
||||
|
||||
@@ -2194,5 +2194,21 @@
|
||||
"Add text overlay": "Добавить текстовое наложение",
|
||||
"Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "Токены: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Остальной текст выводится как написан.",
|
||||
"Use export watermark": "Использовать водяной знак экспорта",
|
||||
"The export watermark, drawn on the photo and sized relative to it.": "Водяной знак экспорта, рисуется на фото и масштабируется относительно него."
|
||||
"The export watermark, drawn on the photo and sized relative to it.": "Водяной знак экспорта, рисуется на фото и масштабируется относительно него.",
|
||||
"Titles": "Титры",
|
||||
"Intro screen": "Вступительный экран",
|
||||
"Ending screen": "Заключительный экран",
|
||||
"Shows": "Показывает",
|
||||
"Colour only": "Только цвет",
|
||||
"Override colour": "Заменить цвет",
|
||||
"Own duration (off: the slide duration)": "Своя длительность (выкл.: длительность слайда)",
|
||||
"Duration": "Длительность",
|
||||
"Title text": "Текст титра",
|
||||
"Intro": "Вступление",
|
||||
"Ending": "Финал",
|
||||
"End": "Конец",
|
||||
"The intro plays once before the first photo; the ending after the last photo when Repeat is off. The identity plate is the one set under Overlays. Titles fade like slides and random order never moves them.": "Вступление показывается один раз перед первым фото, финал — после последнего фото, если повтор выключен. Фирменная табличка — та, что задана в разделе «Наложения». Титры появляются и исчезают как слайды, а случайный порядок их не перемещает.",
|
||||
"This screen is off: turn it on under Titles to play it.": "Этот экран выключен: включите его в разделе «Титры», чтобы показывать.",
|
||||
"The intro screen, before the first photo.": "Вступительный экран, перед первым фото.",
|
||||
"The ending screen, after the last photo.": "Заключительный экран, после последнего фото."
|
||||
}
|
||||
|
||||
@@ -2194,5 +2194,21 @@
|
||||
"Add text overlay": "Додати текстове накладання",
|
||||
"Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "Токени: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Решта тексту виводиться як написано.",
|
||||
"Use export watermark": "Використати водяний знак експорту",
|
||||
"The export watermark, drawn on the photo and sized relative to it.": "Водяний знак експорту, малюється на фото й масштабується відносно нього."
|
||||
"The export watermark, drawn on the photo and sized relative to it.": "Водяний знак експорту, малюється на фото й масштабується відносно нього.",
|
||||
"Titles": "Титри",
|
||||
"Intro screen": "Вступний екран",
|
||||
"Ending screen": "Завершальний екран",
|
||||
"Shows": "Показує",
|
||||
"Colour only": "Лише колір",
|
||||
"Override colour": "Замінити колір",
|
||||
"Own duration (off: the slide duration)": "Власна тривалість (вимк.: тривалість слайда)",
|
||||
"Duration": "Тривалість",
|
||||
"Title text": "Текст титру",
|
||||
"Intro": "Вступ",
|
||||
"Ending": "Фінал",
|
||||
"End": "Кінець",
|
||||
"The intro plays once before the first photo; the ending after the last photo when Repeat is off. The identity plate is the one set under Overlays. Titles fade like slides and random order never moves them.": "Вступ показується один раз перед першим фото, фінал — після останнього фото, якщо повтор вимкнено. Фірмова табличка — та, що задана в розділі «Накладання». Титри з'являються й зникають як слайди, а випадковий порядок їх не переміщує.",
|
||||
"This screen is off: turn it on under Titles to play it.": "Цей екран вимкнено: увімкніть його в розділі «Титри», щоб показувати.",
|
||||
"The intro screen, before the first photo.": "Вступний екран, перед першим фото.",
|
||||
"The ending screen, after the last photo.": "Завершальний екран, після останнього фото."
|
||||
}
|
||||
|
||||
@@ -2040,5 +2040,21 @@
|
||||
"Add text overlay": "添加文本叠加",
|
||||
"Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "标记:{file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}。其他文字按原样显示。",
|
||||
"Use export watermark": "使用导出水印",
|
||||
"The export watermark, drawn on the photo and sized relative to it.": "导出水印,绘制在照片上,大小相对于照片。"
|
||||
"The export watermark, drawn on the photo and sized relative to it.": "导出水印,绘制在照片上,大小相对于照片。",
|
||||
"Titles": "标题",
|
||||
"Intro screen": "片头画面",
|
||||
"Ending screen": "片尾画面",
|
||||
"Shows": "显示",
|
||||
"Colour only": "仅颜色",
|
||||
"Override colour": "覆盖颜色",
|
||||
"Own duration (off: the slide duration)": "单独时长(关闭:使用幻灯片时长)",
|
||||
"Duration": "时长",
|
||||
"Title text": "标题文字",
|
||||
"Intro": "片头",
|
||||
"Ending": "片尾",
|
||||
"End": "结束",
|
||||
"The intro plays once before the first photo; the ending after the last photo when Repeat is off. The identity plate is the one set under Overlays. Titles fade like slides and random order never moves them.": "片头在第一张照片之前播放一次;关闭“重复”时,片尾在最后一张照片之后播放。身份标识即“叠加”中设置的那个。标题与幻灯片一样淡入淡出,随机顺序不会移动它们。",
|
||||
"This screen is off: turn it on under Titles to play it.": "此画面已关闭:在“标题”中打开即可播放。",
|
||||
"The intro screen, before the first photo.": "片头画面,在第一张照片之前。",
|
||||
"The ending screen, after the last photo.": "片尾画面,在最后一张照片之后。"
|
||||
}
|
||||
|
||||
@@ -2002,5 +2002,21 @@
|
||||
"Add text overlay": "新增文字疊加",
|
||||
"Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "標記:{file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}。其他文字按原樣顯示。",
|
||||
"Use export watermark": "使用匯出浮水印",
|
||||
"The export watermark, drawn on the photo and sized relative to it.": "匯出浮水印,繪製在照片上,大小相對於照片。"
|
||||
"The export watermark, drawn on the photo and sized relative to it.": "匯出浮水印,繪製在照片上,大小相對於照片。",
|
||||
"Titles": "標題",
|
||||
"Intro screen": "片頭畫面",
|
||||
"Ending screen": "片尾畫面",
|
||||
"Shows": "顯示",
|
||||
"Colour only": "僅顏色",
|
||||
"Override colour": "覆寫顏色",
|
||||
"Own duration (off: the slide duration)": "個別時長(關閉:使用幻燈片時長)",
|
||||
"Duration": "時長",
|
||||
"Title text": "標題文字",
|
||||
"Intro": "片頭",
|
||||
"Ending": "片尾",
|
||||
"End": "結束",
|
||||
"The intro plays once before the first photo; the ending after the last photo when Repeat is off. The identity plate is the one set under Overlays. Titles fade like slides and random order never moves them.": "片頭在第一張照片之前播放一次;關閉「重複」時,片尾在最後一張照片之後播放。身分標識即「疊加」中設定的那個。標題與幻燈片一樣淡入淡出,隨機順序不會移動它們。",
|
||||
"This screen is off: turn it on under Titles to play it.": "此畫面已關閉:在「標題」中開啟即可播放。",
|
||||
"The intro screen, before the first photo.": "片頭畫面,在第一張照片之前。",
|
||||
"The ending screen, after the last photo.": "片尾畫面,在最後一張照片之後。"
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ pub mod second;
|
||||
pub mod settings;
|
||||
pub mod slide_layout;
|
||||
pub mod slide_overlays;
|
||||
pub mod slide_titles;
|
||||
pub mod slideshow;
|
||||
pub mod strip;
|
||||
pub mod topbar;
|
||||
|
||||
@@ -702,6 +702,8 @@ fn slideshow_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
super::slide_layout::sections(app, ui, t);
|
||||
ui.add_space(6.0);
|
||||
super::slide_overlays::sections(app, ui, t);
|
||||
ui.add_space(6.0);
|
||||
super::slide_titles::sections(app, ui, t);
|
||||
});
|
||||
});
|
||||
ui.add_space(12.0);
|
||||
|
||||
@@ -171,6 +171,11 @@ pub fn preview(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
register(ui.ctx(), "slideshow:preview", rect);
|
||||
let p = ui.painter_at(rect.expand(1.0));
|
||||
p.rect_stroke(rect, 0.0, Stroke::new(1.0, t.text_dim.gamma_multiply(0.5)), egui::StrokeKind::Outside);
|
||||
super::slide_titles::preview_choice(ui);
|
||||
if let Some(title) = super::slide_titles::preview_shows(ui.ctx()) {
|
||||
title_preview(app, ui, t, rect, title);
|
||||
return;
|
||||
}
|
||||
let Some(id) = app.session.active() else {
|
||||
p.rect_filled(rect, 0.0, Color32::from_rgb(s.backdrop_color[0], s.backdrop_color[1], s.backdrop_color[2]));
|
||||
p.text(rect.center(), Align2::CENTER_CENTER, crate::i18n::tr("Select a photo to preview its slide"), t.font(11.5), Color32::from_gray(200));
|
||||
@@ -231,6 +236,47 @@ pub fn preview(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
hint(ui, t, "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.");
|
||||
}
|
||||
|
||||
/// The preview showing title screen `title` (composed off the UI thread like the photo's slide,
|
||||
/// once its settings have been still for [`DEBOUNCE`] seconds).
|
||||
fn title_preview(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, rect: Rect, title: lightcraft_engine::slideshow::Title) {
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
let (pw, ph) = (((rect.width() * ppp).round() as usize).max(16), ((rect.height() * ppp).round() as usize).max(16));
|
||||
let compose = app.session.title_compose(title, pw, ph);
|
||||
let key = compose.key();
|
||||
let now = ui.input(|i| i.time);
|
||||
let mem = egui::Id::new("settingsSlideshowTitlePreviewKey");
|
||||
let (last, since) = ui.ctx().data(|d| d.get_temp::<(u64, f64)>(mem)).unwrap_or((0, f64::NEG_INFINITY));
|
||||
if last != key {
|
||||
ui.ctx().data_mut(|d| d.insert_temp(mem, (key, now)));
|
||||
}
|
||||
let still = if last == key { now - since } else { 0.0 };
|
||||
// a title render in the slot (a photo's has its id): shown until the new one lands
|
||||
let current = app.renderer.textures.get(&Slot::SlidePreview).filter(|tex| tex.photo == lightcraft_catalog::PhotoId(0));
|
||||
if current.is_none() || still >= DEBOUNCE {
|
||||
app.renderer.request_title(Slot::SlidePreview, compose, 90);
|
||||
} else {
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64((DEBOUNCE - still).clamp(0.01, DEBOUNCE)));
|
||||
}
|
||||
let p = ui.painter_at(rect.expand(1.0));
|
||||
match app.renderer.textures.get(&Slot::SlidePreview).filter(|tex| tex.photo == lightcraft_catalog::PhotoId(0)) {
|
||||
Some(tex) => {
|
||||
let drawn = super::detail::fit_texture_rect(rect, tex.size);
|
||||
p.image(tex.tex.id(), drawn, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
}
|
||||
None => {
|
||||
let c = app.session.slide_titles.screen(title).color;
|
||||
p.rect_filled(rect, 0.0, Color32::from_rgb(c[0], c[1], c[2]));
|
||||
p.text(rect.center(), Align2::CENTER_CENTER, crate::i18n::tr("Rendering…"), t.font(11.5), Color32::from_gray(200));
|
||||
}
|
||||
}
|
||||
let hint_text = match (title, app.session.slide_titles.screen(title).enabled) {
|
||||
(_, false) => "This screen is off: turn it on under Titles to play it.",
|
||||
(lightcraft_engine::slideshow::Title::Intro, true) => "The intro screen, before the first photo.",
|
||||
(lightcraft_engine::slideshow::Title::Ending, true) => "The ending screen, after the last photo.",
|
||||
};
|
||||
hint(ui, t, hint_text);
|
||||
}
|
||||
|
||||
/// The default layout (for the Reset button).
|
||||
pub fn reset(app: &mut LightcraftApp) {
|
||||
let d = SlideSettings::default().to_json();
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
//! Settings ▸ Slideshow ▸ Titles (Lightroom Classic's Slideshow Titles panel): the intro and
|
||||
//! ending screens, each on or off, with a background colour and, centred on it, the identity plate
|
||||
//! of the Overlays section or a line of text, at a scale, for its own duration.
|
||||
//!
|
||||
//! Every control goes through `slideshow.titles` (validated there, saved with the library), like
|
||||
//! the Layout and Overlays sections beside it. Changing a screen's settings switches the live
|
||||
//! preview ([`super::slide_layout::preview`]) to that screen; its Photo / Intro / Ending buttons
|
||||
//! pick what it shows ([`preview_shows`]).
|
||||
|
||||
use lightcraft_engine::slide_overlay::{PLATE_SIZE_MAX, SIZE_MIN};
|
||||
use lightcraft_engine::slide_title::{TitleContent, TitleScreen};
|
||||
use lightcraft_engine::slideshow::{SLIDE_MAX, SLIDE_MIN, Title};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::settings::{check, choices, heading, hint, row};
|
||||
use crate::LightcraftApp;
|
||||
use crate::text_field::TextField;
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::register;
|
||||
|
||||
/// Where the preview's choice lives (egui memory): `None` = the photo's slide.
|
||||
fn preview_key() -> egui::Id {
|
||||
egui::Id::new("settingsSlideshowPreviewShows")
|
||||
}
|
||||
|
||||
/// What the Settings preview shows: a title screen, or (`None`) the active photo's slide.
|
||||
pub fn preview_shows(ctx: &egui::Context) -> Option<Title> {
|
||||
ctx.data(|d| d.get_temp::<Option<Title>>(preview_key())).flatten()
|
||||
}
|
||||
|
||||
pub fn set_preview_shows(ctx: &egui::Context, t: Option<Title>) {
|
||||
ctx.data_mut(|d| d.insert_temp(preview_key(), t));
|
||||
}
|
||||
|
||||
/// The Photo / Intro / Ending buttons over the preview (`button:settingsSlideshowPreviewShows-{0,1,2}`).
|
||||
pub fn preview_choice(ui: &mut egui::Ui) {
|
||||
let mut v = preview_shows(ui.ctx());
|
||||
let opts = [(None, "Photo"), (Some(Title::Intro), "Intro"), (Some(Title::Ending), "Ending")];
|
||||
ui.horizontal(|ui| {
|
||||
if choices(ui, "settingsSlideshowPreviewShows", &opts, &mut v) {
|
||||
set_preview_shows(ui.ctx(), v);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Change `key` of screen `t` (and show that screen in the preview).
|
||||
fn set(app: &mut LightcraftApp, ctx: &egui::Context, t: Title, key: &str, v: Value) {
|
||||
let _ = app.run("slideshow.titles", json!({ t.id(): { key: v } }));
|
||||
set_preview_shows(ctx, Some(t));
|
||||
}
|
||||
|
||||
/// A slider (`slider:{id}`) for `key` of screen `t`; `k` scales the value for display.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn slider(
|
||||
app: &mut LightcraftApp,
|
||||
ui: &mut egui::Ui,
|
||||
id: &str,
|
||||
t: Title,
|
||||
key: &str,
|
||||
value: f64,
|
||||
range: std::ops::RangeInclusive<f64>,
|
||||
k: f64,
|
||||
suffix: &str,
|
||||
) {
|
||||
let mut v = value * k;
|
||||
let r = ui.add(egui::Slider::new(&mut v, (range.start() * k)..=(range.end() * k)).suffix(suffix).max_decimals(1));
|
||||
register(ui.ctx(), format!("slider:{id}"), r.rect);
|
||||
let p = json!({ t.id(): { key: v / k } });
|
||||
if r.changed() {
|
||||
if let Ok(next) = app.session.slide_titles.merged(&p) {
|
||||
app.session.slide_titles = next;
|
||||
}
|
||||
set_preview_shows(ui.ctx(), Some(t));
|
||||
}
|
||||
if r.drag_stopped() || (r.changed() && !r.dragged()) {
|
||||
let _ = app.run("slideshow.titles", p);
|
||||
}
|
||||
}
|
||||
|
||||
/// A colour button (`button:{id}`) for `key` of screen `t`.
|
||||
fn colour(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, t: Title, key: &str, value: [u8; 3]) {
|
||||
let mut c = value;
|
||||
let r = egui::color_picker::color_edit_button_srgb(ui, &mut c);
|
||||
register(ui.ctx(), format!("button:{id}"), r.rect);
|
||||
if r.changed() {
|
||||
set(app, ui.ctx(), t, key, json!(c));
|
||||
}
|
||||
}
|
||||
|
||||
/// The screen's text field (`field:{id}`), saved when the edit ends.
|
||||
fn text(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, t: Title, value: &str) {
|
||||
let mem = egui::Id::new(("slideshow-title-field", id));
|
||||
let mut s: String = ui.data(|d| d.get_temp(mem)).unwrap_or_else(|| value.to_string());
|
||||
let widget = format!("field:{id}");
|
||||
let r = TextField::singleline(&widget, &mut s).hint(crate::i18n::tr("Title text")).width(ui.available_width().clamp(80.0, 260.0)).show(ui);
|
||||
if r.editing {
|
||||
ui.data_mut(|d| d.insert_temp(mem, s.clone()));
|
||||
} else {
|
||||
ui.data_mut(|d| d.remove::<String>(mem));
|
||||
}
|
||||
if r.committed() && s != value {
|
||||
set(app, ui.ctx(), t, "text", json!(s));
|
||||
}
|
||||
}
|
||||
|
||||
/// One screen's controls; `name` prefixes the widget ids (`slideshowIntro…`).
|
||||
fn screen(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, title: Title, s: &TitleScreen) {
|
||||
let (name, content_id, label) = match title {
|
||||
Title::Intro => ("slideshowIntro", "settingsSlideshowIntroContent", "Intro screen"),
|
||||
Title::Ending => ("slideshowEnding", "settingsSlideshowEndingContent", "Ending screen"),
|
||||
};
|
||||
let mut on = s.enabled;
|
||||
if check(ui, &format!("settings.{name}"), &mut on, label) {
|
||||
set(app, ui.ctx(), title, "enabled", json!(on));
|
||||
}
|
||||
if !on {
|
||||
return;
|
||||
}
|
||||
row(ui, t, "Colour", |ui| colour(app, ui, &format!("{name}Color"), title, "color", s.color));
|
||||
row(ui, t, "Shows", |ui| {
|
||||
let mut c = s.content;
|
||||
let opts = [(TitleContent::IdentityPlate, "Identity plate"), (TitleContent::Text, "Text"), (TitleContent::None, "Colour only")];
|
||||
if choices(ui, content_id, &opts, &mut c) {
|
||||
set(app, ui.ctx(), title, "content", json!(c.id()));
|
||||
}
|
||||
});
|
||||
if s.content == TitleContent::None {
|
||||
return;
|
||||
}
|
||||
if s.content == TitleContent::Text {
|
||||
row(ui, t, "Text", |ui| text(app, ui, &format!("{name}Text"), title, &s.text));
|
||||
}
|
||||
row(ui, t, "Scale", |ui| slider(app, ui, &format!("{name}Scale"), title, "scale", s.scale, SIZE_MIN..=PLATE_SIZE_MAX, 100.0, " %"));
|
||||
ui.horizontal(|ui| {
|
||||
let mut over = s.override_color;
|
||||
if check(ui, &format!("settings.{name}Override"), &mut over, "Override colour") {
|
||||
set(app, ui.ctx(), title, "overrideColor", json!(over));
|
||||
}
|
||||
if over {
|
||||
colour(app, ui, &format!("{name}TextColor"), title, "textColor", s.text_color);
|
||||
}
|
||||
});
|
||||
let mut own = s.duration.is_some();
|
||||
if check(ui, &format!("settings.{name}OwnDuration"), &mut own, "Own duration (off: the slide duration)") {
|
||||
let d = if own { json!(app.session.slideshow.slide_duration) } else { Value::Null };
|
||||
set(app, ui.ctx(), title, "duration", d);
|
||||
}
|
||||
if let Some(d) = s.duration {
|
||||
row(ui, t, "Duration", |ui| slider(app, ui, &format!("{name}Duration"), title, "duration", d, SLIDE_MIN..=SLIDE_MAX, 1.0, " s"));
|
||||
}
|
||||
}
|
||||
|
||||
/// The Titles section.
|
||||
pub fn sections(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
let s = app.session.slide_titles.clone();
|
||||
heading(ui, t, "Titles");
|
||||
screen(app, ui, t, Title::Intro, &s.intro);
|
||||
screen(app, ui, t, Title::Ending, &s.ending);
|
||||
hint(
|
||||
ui,
|
||||
t,
|
||||
"The intro plays once before the first photo; the ending after the last photo when Repeat is off. The identity plate is the one set under Overlays. Titles fade like slides and random order never moves them.",
|
||||
);
|
||||
let warnings = s.warnings(&app.session.slide_overlays);
|
||||
for w in warnings {
|
||||
ui.label(egui::RichText::new(w).size(11.0).color(t.caution));
|
||||
}
|
||||
}
|
||||
@@ -16,10 +16,16 @@
|
||||
//! plain layout, else the slide composed with the library's layout (`slideshow.layout`: margins,
|
||||
//! zoom to fill, stroke, shadow, backdrop) by `lightcraft-slideshow` on the render worker. A
|
||||
//! composed slide is a screen-sized texture; the playback (fades, controls) is the same.
|
||||
//!
|
||||
//! Title screens (`slideshow.titles`: the intro before the first photo, the ending after the last
|
||||
//! one) are slides of the same timeline ([`Item::Title`]): composed at the screen's pixels on a
|
||||
//! render worker ([`crate::render::Renderer::request_title`]), prepared ahead, waited for and
|
||||
//! faded like photo slides. They never move the selection, and the controls show "Intro" / "End"
|
||||
//! in place of "n / N" while one is up (titles aren't photos, so N stays the number of photos).
|
||||
|
||||
use egui::{Align2, Color32, Pos2, Rect, pos2, vec2};
|
||||
use lightcraft_catalog::PhotoId;
|
||||
use lightcraft_engine::slideshow::{Playback, Tick};
|
||||
use lightcraft_engine::slideshow::{Item, Playback, Tick, Title};
|
||||
use lightcraft_pipeline::geometry::Frame;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -43,13 +49,15 @@ pub struct Run {
|
||||
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>,
|
||||
pub unrenderable: Vec<Item>,
|
||||
}
|
||||
|
||||
/// `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).
|
||||
/// `seed` fixes the random order). The intro and ending screens that are on (`slideshow.titles`)
|
||||
/// play before the first photo and after the last one. With no photos in view there is nothing
|
||||
/// to show (titles alone don't make a slideshow).
|
||||
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") {
|
||||
@@ -61,11 +69,13 @@ pub fn start(app: &mut LightcraftApp, p: &Value, now: f64) -> Result<Value, Stri
|
||||
};
|
||||
let settings = app.session.slideshow.merged(&Value::Object(overrides)).map_err(|e| format!("view.slideshow: {e}"))?;
|
||||
let photos = app.session.visible_cloned();
|
||||
let (intro, ending) = app.session.slide_titles.durations(settings.slide_duration);
|
||||
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 play = play.with_titles(intro, ending);
|
||||
let first = play.current_photo().or_else(|| play.upcoming(1).first().and_then(|i| i.photo()));
|
||||
if let Some(first) = first.filter(|f| app.session.active() != Some(*f)) {
|
||||
let _ = app.run("library.select", json!({"ids": [first.0]}));
|
||||
}
|
||||
let (_, slides) = play.position();
|
||||
@@ -109,13 +119,14 @@ pub fn state(play: &Playback, now: f64) -> Value {
|
||||
let (n, of) = play.position();
|
||||
let f = play.frame(now);
|
||||
json!({
|
||||
"current": play.current().0,
|
||||
"current": play.current_photo().map(|p| p.0),
|
||||
"title": play.title().map(Title::id),
|
||||
"slide": n,
|
||||
"of": of,
|
||||
"paused": play.is_paused(),
|
||||
"fading": f.from.is_some(),
|
||||
"progress": f.progress,
|
||||
"waitingFor": play.waiting_for().map(|p| p.0),
|
||||
"waitingFor": play.waiting_for().map(|i| i.photo().map_or_else(|| json!(i.title().map(Title::id)), |p| json!(p.0))),
|
||||
"remaining": play.remaining(now),
|
||||
})
|
||||
}
|
||||
@@ -123,7 +134,7 @@ pub fn state(play: &Playback, now: f64) -> Value {
|
||||
/// 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) => {
|
||||
Tick::Advanced(Item::Photo(id)) if app.session.active() != Some(id) => {
|
||||
let _ = app.run("library.select", json!({"ids": [id.0]}));
|
||||
}
|
||||
Tick::Ended => end(app),
|
||||
@@ -131,13 +142,15 @@ fn after(app: &mut LightcraftApp, t: Tick) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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))
|
||||
/// Can `item` 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, item: Item) -> bool {
|
||||
let slot = Slot::Slide(item);
|
||||
let rendered = match item {
|
||||
Item::Photo(id) => app.renderer.textures.get(&slot).is_some_and(|t| t.photo == id) || app.session.catalog.photo(id).is_none(),
|
||||
Item::Title(_) => app.renderer.textures.contains_key(&slot),
|
||||
};
|
||||
rendered || app.renderer.failure(slot).is_some() || app.ui.slideshow.as_ref().is_some_and(|r| r.unrenderable.contains(&item))
|
||||
}
|
||||
|
||||
/// Advance a running slideshow (every frame, before drawing).
|
||||
@@ -211,7 +224,7 @@ pub fn slide_px(area: Rect, ppp: f32, texture_side: usize) -> (usize, usize) {
|
||||
}
|
||||
|
||||
/// The slides to keep rendered now, most wanted first, with their render priority.
|
||||
fn wanted(play: &Playback, now: f64) -> Vec<(PhotoId, u32)> {
|
||||
fn wanted(play: &Playback, now: f64) -> Vec<(Item, u32)> {
|
||||
let f = play.frame(now);
|
||||
let mut v = vec![(f.current, 100)];
|
||||
if let Some(from) = f.from {
|
||||
@@ -234,12 +247,14 @@ fn wanted(play: &Playback, now: f64) -> Vec<(PhotoId, u32)> {
|
||||
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> {
|
||||
/// The texture to draw for slide `item`: its own render, else (a photo) the loupe's or a
|
||||
/// thumbnail (a slide stepped to with ←/→ shows at once, sharp when its render lands). A title
|
||||
/// screen has nothing to stand in for it: the black canvas shows until it lands.
|
||||
fn texture(app: &LightcraftApp, item: Item) -> Option<&crate::render::Tex> {
|
||||
let Item::Photo(id) = item else { return app.renderer.textures.get(&Slot::Slide(item)) };
|
||||
app.renderer
|
||||
.textures
|
||||
.get(&Slot::Slide(id))
|
||||
.get(&Slot::Slide(item))
|
||||
.filter(|t| t.photo == id)
|
||||
.or_else(|| app.renderer.textures.get(&Slot::Main).filter(|t| t.photo == id))
|
||||
.or_else(|| app.renderer.thumb(id))
|
||||
@@ -284,10 +299,17 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
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),
|
||||
for (item, priority) in &want {
|
||||
match *item {
|
||||
Item::Title(t) => {
|
||||
let (w, h) = slide_px(canvas, ppp, side);
|
||||
let compose = app.session.title_compose(t, w, h);
|
||||
app.renderer.request_title(Slot::Slide(*item), compose, *priority);
|
||||
}
|
||||
Item::Photo(id) => match slide_job(app, id, canvas, ppp, side) {
|
||||
Some(job) => app.renderer.request(Slot::Slide(*item), job, *priority),
|
||||
None => unrenderable.push(*item),
|
||||
},
|
||||
}
|
||||
}
|
||||
if let Some(run) = app.ui.slideshow.as_mut() {
|
||||
@@ -297,12 +319,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
}
|
||||
let keep: Vec<PhotoId> = want.iter().map(|(id, _)| *id).collect();
|
||||
let keep: Vec<Item> = 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 draw = |id: Item, 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)));
|
||||
@@ -369,6 +391,7 @@ fn controls(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect, now: f64)
|
||||
}
|
||||
let paused = run.play.is_paused();
|
||||
let (n, of) = run.play.position();
|
||||
let title = run.play.title();
|
||||
// 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);
|
||||
@@ -390,7 +413,11 @@ fn controls(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect, now: f64)
|
||||
fire = Some("slideshow.next");
|
||||
}
|
||||
let (r, _) = child.allocate_exact_size(vec2(118.0, 36.0), egui::Sense::hover());
|
||||
let label = format!("{n} / {of}");
|
||||
let label = match title {
|
||||
Some(Title::Intro) => crate::i18n::tr("Intro").to_string(),
|
||||
Some(Title::Ending) => crate::i18n::tr("End").to_string(),
|
||||
None => 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");
|
||||
|
||||
@@ -56,8 +56,8 @@ pub enum Slot {
|
||||
/// 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),
|
||||
/// [`crate::panels::slideshow`]): a photo's, or a title screen.
|
||||
Slide(lightcraft_engine::slideshow::Item),
|
||||
/// The small composed slide in Settings ▸ Slideshow (the active photo with the layout being
|
||||
/// edited).
|
||||
SlidePreview,
|
||||
@@ -488,6 +488,24 @@ impl Renderer {
|
||||
self.pool.submit(slot, key, priority, Box::new(move || Self::run_for(|| job.run(), &display)));
|
||||
}
|
||||
|
||||
/// Render a slideshow title screen into `slot` (once per screen key, like [`Self::request`]):
|
||||
/// a slide with no photo, composed on a worker (on wasm: the job pool's inline runs, not the
|
||||
/// offload, which only knows photo renders).
|
||||
pub fn request_title(&mut self, slot: Slot, compose: Arc<lightcraft_engine::slide_title::TitleCompose>, priority: u32) {
|
||||
if matches!(slot, Slot::Slide(_)) {
|
||||
self.slides_live = true;
|
||||
}
|
||||
let key = compose.key();
|
||||
if !self.request_needed(slot, key, priority) {
|
||||
return;
|
||||
}
|
||||
self.pending.insert(slot, (key, priority));
|
||||
let request_id = lightcraft_preview::next_tick();
|
||||
self.request_ids.insert(slot, request_id);
|
||||
let display = self.display.clone();
|
||||
self.pool.submit(slot, key, priority, Box::new(move || Self::run_for(|| compose.result(request_id), &display)));
|
||||
}
|
||||
|
||||
/// Prepare a photo in the background (a [`Slot::Prefetch`] job runs once per key; a newer one
|
||||
/// replaces it while queued). Memory stays bounded by the engine's source caches.
|
||||
pub fn prefetch(&mut self, slot: Slot, mut job: RenderJob, priority: u32) {
|
||||
@@ -504,7 +522,7 @@ impl Renderer {
|
||||
|
||||
/// 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]) {
|
||||
pub fn release_slides(&mut self, keep: &[lightcraft_engine::slideshow::Item]) {
|
||||
if !self.slides_live {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use lightcraft_catalog::PhotoId;
|
||||
use lightcraft_engine::slideshow::Item;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::headless::Headless;
|
||||
@@ -32,8 +33,18 @@ 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 has_slide(h: &Headless, item: impl Into<Item>) -> bool {
|
||||
let item = item.into();
|
||||
let tex = h.app.renderer.textures.get(&Slot::Slide(item));
|
||||
match item {
|
||||
Item::Photo(id) => tex.is_some_and(|t| t.photo == id),
|
||||
Item::Title(_) => tex.is_some(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The photo up now (none while a title screen is).
|
||||
fn photo_up(h: &Headless) -> PhotoId {
|
||||
play(h).current_photo().expect("a photo is up")
|
||||
}
|
||||
|
||||
fn slides_held(h: &Headless) -> usize {
|
||||
@@ -75,20 +86,20 @@ fn next_slides_are_prepared_before_they_are_due() {
|
||||
assert_eq!(ahead.len(), 2);
|
||||
let start = h.time();
|
||||
// the slide up and the next two; their renders can finish in any order
|
||||
let current = play(&h).current();
|
||||
let current = photo_up(&h);
|
||||
assert!(
|
||||
h.step_until(SETTLE, |h| has_slide(h, current) && ahead.iter().all(|id| has_slide(h, *id))),
|
||||
"the slide up and the next two slides are rendered"
|
||||
);
|
||||
assert!(h.time() - start < 60.0, "before the first is due");
|
||||
assert_eq!(play(&h).current(), current, "still on the first slide");
|
||||
assert_eq!(photo_up(&h), 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();
|
||||
let current = photo_up(&h);
|
||||
assert!(h.step_until(SETTLE, |h| has_slide(h, current)));
|
||||
for _ in 0..30 {
|
||||
h.step();
|
||||
@@ -124,12 +135,12 @@ fn arrows_cut_and_repeat_off_ends_after_the_last_slide() {
|
||||
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!(photo_up(&h), 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]);
|
||||
assert_eq!(photo_up(&h), photos[0]);
|
||||
for _ in 1..photos.len() {
|
||||
run(&mut h, "slideshow.next", json!({}));
|
||||
}
|
||||
@@ -146,10 +157,10 @@ fn random_order_starts_at_the_selection_and_is_fixed_by_the_seed() {
|
||||
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()];
|
||||
let mut seen = vec![photo_up(&h)];
|
||||
for _ in 1..photos.len() {
|
||||
run(&mut h, "slideshow.next", json!({}));
|
||||
seen.push(play(&h).current());
|
||||
seen.push(photo_up(&h));
|
||||
}
|
||||
h.settle(SETTLE);
|
||||
(photos, seen)
|
||||
@@ -236,6 +247,7 @@ fn a_photo_deleted_while_it_plays_does_not_stall_the_slideshow() {
|
||||
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];
|
||||
let next = next.photo().unwrap();
|
||||
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());
|
||||
@@ -244,6 +256,30 @@ fn a_photo_deleted_while_it_plays_does_not_stall_the_slideshow() {
|
||||
h.settle(SETTLE);
|
||||
}
|
||||
|
||||
/// Scroll the Settings ▸ Slideshow column until widget `id` is in view (near its top): the
|
||||
/// sections are taller than the dialog.
|
||||
fn reveal(h: &mut Headless, id: &str) {
|
||||
let find = |h: &Headless, w: &str| h.app.widgets.iter().find(|(i, _)| i == w).map(|(_, r)| *r);
|
||||
for _ in 0..40 {
|
||||
h.step();
|
||||
let col = find(h, "slideshow:preview").expect("the Slideshow settings are open");
|
||||
let dy = match find(h, id) {
|
||||
Some(w) if w.center().y >= col.top() + 10.0 && w.center().y <= col.top() + 340.0 => return,
|
||||
Some(w) => (col.top() + 80.0 - w.center().y).clamp(-500.0, 500.0),
|
||||
None => -300.0,
|
||||
};
|
||||
// (the pointer over the settings column, left of the preview)
|
||||
let r = h.request("ui.hoverWidget", json!({"id": "slideshow:preview", "fx": -1.0, "fy": 0.5}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
let r = h.request("ui.scroll", json!({"dy": dy}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
for _ in 0..8 {
|
||||
h.step();
|
||||
}
|
||||
}
|
||||
panic!("{id} never came into view");
|
||||
}
|
||||
|
||||
fn painted(img: &egui::ColorImage, x: usize, y: usize) -> [u8; 3] {
|
||||
let c = img.pixels[y * img.size[0] + x];
|
||||
[c.r(), c.g(), c.b()]
|
||||
@@ -255,10 +291,10 @@ fn a_composed_slide_shows_during_playback() {
|
||||
let r = run(&mut h, "slideshow.layout", json!({"margins": 0.1, "backdropColor": "#ff0000"}));
|
||||
assert_eq!(r["marginTop"], 0.1);
|
||||
run(&mut h, "view.slideshow", json!({"interval": 60, "fadeDuration": 0}));
|
||||
let current = play(&h).current();
|
||||
let current = photo_up(&h);
|
||||
assert!(h.step_until(SETTLE, |h| has_slide(h, current)), "the slide is composed and ready");
|
||||
h.step();
|
||||
let tex = h.app.renderer.textures.get(&Slot::Slide(current)).unwrap();
|
||||
let tex = h.app.renderer.textures.get(&Slot::Slide(current.into())).unwrap();
|
||||
assert_eq!(tex.size, [1000, 700], "a composed slide fills the screen");
|
||||
let img = h.paint();
|
||||
assert_eq!(img.size, [1000, 700]);
|
||||
@@ -282,11 +318,11 @@ fn the_default_layout_plays_the_photo_as_before() {
|
||||
let mut h = grid();
|
||||
assert!(h.app.session.slide_layout.is_plain());
|
||||
run(&mut h, "view.slideshow", json!({"interval": 60, "fadeDuration": 0}));
|
||||
let current = play(&h).current();
|
||||
let current = photo_up(&h);
|
||||
assert!(h.step_until(SETTLE, |h| has_slide(h, current)));
|
||||
h.step();
|
||||
// the photo's own render (its aspect, not the screen's), fitted on black, as before
|
||||
let tex = h.app.renderer.textures.get(&Slot::Slide(current)).unwrap();
|
||||
let tex = h.app.renderer.textures.get(&Slot::Slide(current.into())).unwrap();
|
||||
let aspect = h.app.session.slide_photo_aspect(current).unwrap();
|
||||
let tex_aspect = tex.size[0] as f64 / tex.size[1] as f64;
|
||||
assert!((tex_aspect - aspect).abs() < 0.02, "{tex_aspect} vs {aspect}");
|
||||
@@ -324,13 +360,7 @@ fn settings_shows_the_slide_layout_and_a_live_preview() {
|
||||
// the preview is the composed slide, rendered off the UI thread
|
||||
assert!(h.step_until(SETTLE, |h| h.app.renderer.textures.get(&Slot::SlidePreview).is_some_and(|t| t.photo == first)));
|
||||
// a checkbox goes through slideshow.layout, and its controls appear (scrolled into view)
|
||||
let r = h.request("ui.hoverWidget", json!({"id": "check:settings.slideshowRandom"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
let r = h.request("ui.scroll", json!({"dy": -600}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
for _ in 0..30 {
|
||||
h.step();
|
||||
}
|
||||
reveal(&mut h, "check:settings.slideshowShadow");
|
||||
let r = h.request("ui.clickWidget", json!({"id": "check:settings.slideshowShadow"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.step();
|
||||
@@ -360,10 +390,10 @@ fn overlays_show_during_playback() {
|
||||
);
|
||||
assert!(!h.app.session.slides_plain());
|
||||
run(&mut h, "view.slideshow", json!({"interval": 60, "fadeDuration": 0}));
|
||||
let current = play(&h).current();
|
||||
let current = photo_up(&h);
|
||||
assert!(h.step_until(SETTLE, |h| has_slide(h, current)), "the slide is composed and ready");
|
||||
h.step();
|
||||
assert_eq!(h.app.renderer.textures.get(&Slot::Slide(current)).unwrap().size, [1000, 700]);
|
||||
assert_eq!(h.app.renderer.textures.get(&Slot::Slide(current.into())).unwrap().size, [1000, 700]);
|
||||
let img = h.paint();
|
||||
// the plate's box: 14 px in from the top-left corner, 84 px high
|
||||
let green = (14..98).flat_map(|y| (14..120).map(move |x| (x, y))).filter(|&(x, y)| {
|
||||
@@ -383,15 +413,7 @@ fn settings_shows_the_overlays_and_the_preview_follows_them() {
|
||||
assert!(h.step_until(SETTLE, |h| h.app.renderer.textures.get(&Slot::SlidePreview).is_some_and(|t| t.photo == first)));
|
||||
let has = |h: &Headless, w: &str| h.app.widgets.iter().any(|(id, _)| id == w);
|
||||
// scroll down to the Overlays section
|
||||
let r = h.request("ui.hoverWidget", json!({"id": "check:settings.slideshowRandom"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
for _ in 0..4 {
|
||||
let r = h.request("ui.scroll", json!({"dy": -800}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
for _ in 0..10 {
|
||||
h.step();
|
||||
}
|
||||
}
|
||||
reveal(&mut h, "button:slideshowAddText");
|
||||
for w in ["check:settings.slideshowPlate", "check:settings.slideshowStars", "button:slideshowAddText", "check:settings.slideshowWatermark"] {
|
||||
assert!(has(&h, w), "{w}");
|
||||
}
|
||||
@@ -402,24 +424,20 @@ fn settings_shows_the_overlays_and_the_preview_follows_them() {
|
||||
h.step();
|
||||
assert!(has(&h, "field:slideshowText0"), "the text overlay's template field");
|
||||
// its rows are below the fold: scroll on down
|
||||
for _ in 0..4 {
|
||||
let r = h.request("ui.scroll", json!({"dy": -800}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
for _ in 0..10 {
|
||||
h.step();
|
||||
}
|
||||
}
|
||||
reveal(&mut h, "button:slideshowText0Anchor-0");
|
||||
assert!(has(&h, "button:slideshowText0Anchor-4"), "its anchor grid");
|
||||
let r = h.request("ui.clickWidget", json!({"id": "button:slideshowText0Anchor-0"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.step();
|
||||
assert_eq!(h.app.session.slide_overlays.texts[0].place.anchor, lightcraft_engine::export::Anchor::TopLeft, "picked in the grid");
|
||||
// removed from its row the text overlay from its row
|
||||
reveal(&mut h, "button:slideshowText0Remove");
|
||||
let r = h.request("ui.clickWidget", json!({"id": "button:slideshowText0Remove"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.step();
|
||||
assert!(h.app.session.slide_overlays.texts.is_empty());
|
||||
h.step();
|
||||
reveal(&mut h, "check:settings.slideshowPlate");
|
||||
let r = h.request("ui.clickWidget", json!({"id": "check:settings.slideshowPlate"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.step();
|
||||
@@ -430,3 +448,164 @@ fn settings_shows_the_overlays_and_the_preview_follows_them() {
|
||||
assert!(h.step_until(SETTLE, |h| h.app.renderer.textures.get(&Slot::SlidePreview).map(|t| t.key) != before));
|
||||
h.settle(SETTLE);
|
||||
}
|
||||
|
||||
/// Shows the playback controls (the pointer moves over the canvas).
|
||||
fn show_controls(h: &mut Headless) {
|
||||
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();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_intro_plays_before_the_first_photo_and_the_ending_closes_the_show() {
|
||||
let mut h = grid();
|
||||
let photos = h.app.session.visible_cloned();
|
||||
let n = photos.len();
|
||||
run(
|
||||
&mut h,
|
||||
"slideshow.titles",
|
||||
json!({
|
||||
"intro": {"enabled": true, "color": "#ff00ff", "content": "text", "text": "Hello", "overrideColor": true, "textColor": "#00ff00", "scale": 0.15, "duration": 0.5},
|
||||
"ending": {"enabled": true, "color": "#00ffff", "content": "none"}
|
||||
}),
|
||||
);
|
||||
run(&mut h, "view.slideshow", json!({"interval": 60, "fadeDuration": 0, "repeat": false}));
|
||||
assert_eq!(play(&h).current(), Item::INTRO, "the intro comes first");
|
||||
assert_eq!(h.app.session.active(), Some(photos[0]), "the selection is the first photo");
|
||||
let st = run(&mut h, "slideshow.togglePause", json!({}));
|
||||
assert_eq!(
|
||||
(st["title"].as_str(), st["current"].as_u64(), st["slide"].as_u64(), st["of"].as_u64()),
|
||||
(Some("intro"), None, Some(0), Some(n as u64)),
|
||||
"{st}"
|
||||
);
|
||||
run(&mut h, "slideshow.togglePause", json!({}));
|
||||
// the intro screen is composed off the UI thread at the screen's size, then shown
|
||||
assert!(h.step_until(SETTLE, |h| has_slide(h, Item::INTRO)), "the intro is composed");
|
||||
assert_eq!(h.app.renderer.textures.get(&Slot::Slide(Item::INTRO)).unwrap().size, [1000, 700]);
|
||||
h.step();
|
||||
let img = h.paint();
|
||||
assert_eq!(painted(&img, 5, 5), [255, 0, 255], "the intro's colour");
|
||||
let green = (280..420).flat_map(|y| (300..700).map(move |x| (x, y))).filter(|&(x, y)| {
|
||||
let c = painted(&img, x, y);
|
||||
c[1] > 200 && c[0] < 80 && c[2] < 80
|
||||
});
|
||||
assert!(green.count() > 300, "its text, centred");
|
||||
show_controls(&mut h);
|
||||
let label = h.painted_text();
|
||||
assert!(label.iter().any(|t| t == "Intro"), "the controls say Intro, not a photo number: {label:?}");
|
||||
assert!(!label.iter().any(|t| t.contains(&format!("0 / {n}"))));
|
||||
// after its own 0.5 s, the first photo (plain layout: the photo on black)
|
||||
assert!(h.step_until(SETTLE, |h| play(h).current_photo() == Some(photos[0]) && has_slide(h, photos[0])), "the first photo follows");
|
||||
h.step();
|
||||
let img = h.paint();
|
||||
for (x, y) in [(5, 5), (500, 350)] {
|
||||
assert_ne!(painted(&img, x, y), [255, 0, 255], "the intro is gone at ({x}, {y})");
|
||||
}
|
||||
show_controls(&mut h);
|
||||
assert!(h.painted_text().iter().any(|t| t == &format!("1 / {n}")), "titles aren't counted: photo 1 of {n}");
|
||||
// to the last photo, then the ending
|
||||
for _ in 1..n {
|
||||
run(&mut h, "slideshow.next", json!({}));
|
||||
}
|
||||
let st = run(&mut h, "slideshow.next", json!({}));
|
||||
assert_eq!((st["title"].as_str(), st["slide"].as_u64()), (Some("ending"), Some(n as u64)), "{st}");
|
||||
assert_eq!(h.app.session.active(), Some(photos[n - 1]), "the selection stays on the last photo");
|
||||
assert!(h.step_until(SETTLE, |h| has_slide(h, Item::ENDING)));
|
||||
h.step();
|
||||
assert_eq!(painted(&h.paint(), 500, 350), [0, 255, 255], "the ending's colour alone");
|
||||
show_controls(&mut h);
|
||||
assert!(h.painted_text().iter().any(|t| t == "End"));
|
||||
// ← goes back to the last photo, → past the ending ends the show
|
||||
let st = run(&mut h, "slideshow.previous", json!({}));
|
||||
assert_eq!(st["current"].as_u64(), Some(photos[n - 1].0));
|
||||
run(&mut h, "slideshow.next", json!({}));
|
||||
run(&mut h, "slideshow.next", json!({}));
|
||||
assert!(h.app.ui.slideshow.is_none() && !h.app.ui.fullscreen, "ended after the ending");
|
||||
h.settle(SETTLE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn titles_survive_deleting_the_only_photo_and_need_photos_to_start() {
|
||||
let mut h = grid();
|
||||
let photos = h.app.session.visible_cloned();
|
||||
let rest: Vec<u64> = photos[1..].iter().map(|p| p.0).collect();
|
||||
run(&mut h, "photo.delete", json!({"ids": rest}));
|
||||
assert_eq!(h.app.session.visible_cloned(), vec![photos[0]]);
|
||||
run(&mut h, "slideshow.titles", json!({"intro": {"enabled": true, "duration": 0.5}, "ending": {"enabled": true, "duration": 0.5}}));
|
||||
run(&mut h, "slideshow.overlays", json!({"identityPlate": {"image": "/no/such/plate.png"}}));
|
||||
run(&mut h, "view.slideshow", json!({"interval": 0.5, "fadeDuration": 0.2, "repeat": false}));
|
||||
assert_eq!(play(&h).current(), Item::INTRO);
|
||||
// the only photo goes away while the intro is up
|
||||
assert_eq!(h.app.session.active(), Some(photos[0]));
|
||||
run(&mut h, "photo.deletePermanently", json!({"ids": [photos[0].0]}));
|
||||
assert!(h.app.session.catalog.photo(photos[0]).is_none());
|
||||
let mut seen = Vec::new();
|
||||
let t0 = std::time::Instant::now();
|
||||
while let Some(r) = h.app.ui.slideshow.as_ref() {
|
||||
if !seen.contains(&r.play.current()) {
|
||||
seen.push(r.play.current());
|
||||
}
|
||||
assert!(t0.elapsed() < SETTLE, "the show plays on to its end: {seen:?}");
|
||||
h.step();
|
||||
}
|
||||
assert_eq!(seen, vec![Item::INTRO, Item::Photo(photos[0]), Item::ENDING], "intro, the (gone) photo, ending");
|
||||
// no photos in view: nothing to show, titles or not (as before)
|
||||
let r = h.request("engine.execute", json!({"command": "view.slideshow", "params": {}}), T);
|
||||
assert_ne!(r["ok"], true, "{r}");
|
||||
assert!(r.to_string().contains("no photos to show"), "{r}");
|
||||
assert!(h.app.ui.slideshow.is_none());
|
||||
h.settle(SETTLE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_shows_the_titles_and_the_preview_shows_a_title_screen() {
|
||||
let mut h = grid();
|
||||
let first = h.app.session.visible_cloned()[0];
|
||||
run(&mut h, "library.select", json!({"ids": [first.0]}));
|
||||
run(&mut h, "view.slideshowOptions", json!({}));
|
||||
assert!(h.step_until(SETTLE, |h| h.app.renderer.textures.get(&Slot::SlidePreview).is_some_and(|t| t.photo == first)));
|
||||
let has = |h: &Headless, w: &str| h.app.widgets.iter().any(|(id, _)| id == w);
|
||||
for w in ["button:settingsSlideshowPreviewShows-0", "button:settingsSlideshowPreviewShows-1", "button:settingsSlideshowPreviewShows-2"] {
|
||||
assert!(has(&h, w), "{w}");
|
||||
}
|
||||
// scroll down to the Titles section, at the end
|
||||
reveal(&mut h, "check:settings.slideshowIntro");
|
||||
assert!(has(&h, "check:settings.slideshowEnding"));
|
||||
let r = h.request("ui.clickWidget", json!({"id": "check:settings.slideshowIntro"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.step();
|
||||
assert!(h.app.session.slide_titles.intro.enabled, "the checkbox goes through slideshow.titles");
|
||||
h.step();
|
||||
for w in [
|
||||
"button:slideshowIntroColor",
|
||||
"button:settingsSlideshowIntroContent-1",
|
||||
"slider:slideshowIntroScale",
|
||||
"check:settings.slideshowIntroOwnDuration",
|
||||
] {
|
||||
assert!(has(&h, w), "{w}");
|
||||
}
|
||||
// the preview switched to the intro screen
|
||||
assert_eq!(crate::panels::slide_titles::preview_shows(&h.view.ctx), Some(lightcraft_engine::slideshow::Title::Intro));
|
||||
run(&mut h, "slideshow.titles", json!({"intro": {"color": "#ff8000", "content": "none"}}));
|
||||
assert!(h.step_until(SETTLE, |h| h.app.renderer.textures.get(&Slot::SlidePreview).is_some_and(|t| t.photo == PhotoId(0))));
|
||||
assert!(h.step_until(SETTLE, |h| {
|
||||
let rect = h.app.widgets.iter().find(|(id, _)| id == "slideshow:preview").map(|(_, r)| *r).unwrap();
|
||||
let img = h.paint();
|
||||
painted(&img, rect.center().x as usize, rect.center().y as usize) == [255, 128, 0]
|
||||
}));
|
||||
// Text: its field shows
|
||||
reveal(&mut h, "button:settingsSlideshowIntroContent-1");
|
||||
let r = h.request("ui.clickWidget", json!({"id": "button:settingsSlideshowIntroContent-1"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.step();
|
||||
assert_eq!(h.app.session.slide_titles.intro.content, lightcraft_engine::slide_title::TitleContent::Text);
|
||||
h.step();
|
||||
assert!(has(&h, "field:slideshowIntroText"));
|
||||
// back to the photo's slide
|
||||
let r = h.request("ui.clickWidget", json!({"id": "button:settingsSlideshowPreviewShows-0"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert!(h.step_until(SETTLE, |h| h.app.renderer.textures.get(&Slot::SlidePreview).is_some_and(|t| t.photo == first)));
|
||||
h.settle(SETTLE);
|
||||
}
|
||||
|
||||
@@ -86,7 +86,8 @@ filmstrip is visible.
|
||||
| `ui.render` | `{id?, size?, path?}` | Render a photo (PNG to `path`), `{width, height}` |
|
||||
| `engine.execute {command: "slideshow.layout"}` | `{margins?, marginLeft?, …, zoomToFill?, strokeBorder?, castShadow?, backdropColor?, colorWash?, backgroundImage?, …}` (see `engine.commands`) | The slideshow's slide layout (Layout, Options, Backdrop), saved with the library; omitted keys keep their value, `{}` reads it; out-of-range numbers are clamped, a wrong type or unknown key is refused and nothing changes |
|
||||
| `engine.execute {command: "slideshow.overlays"}` | `{identityPlate?: {enabled?, text?, image?, size?, color?, opacity?, anchor?, offsetX?, offsetY?, relativeTo?, behind?}, ratingStars?: {enabled?, color?, size?, anchor?, …}, texts?: [{text?, size?, color?, opacity?, anchor?, offsetX?, offsetY?, relativeTo?, shadow?, shadowOpacity?, shadowOffset?, shadowRadius?, shadowAngle?}], addText?, removeText?, showWatermark?, watermark?}` (see `engine.commands`) | The overlays drawn on every slide, saved with the library: sizes and offsets are fractions of the slide's shorter side, anchors `topLeft`…`bottomRight` of the `slide` or the `photo`; text overlays expand tokens (`{caption}`, `{exposure}`, `{date}`, the file-naming tokens…; unknown tags show as typed); `watermark` takes the export watermark's keys. Replies with the settings and `warnings` |
|
||||
| `engine.execute {command: "slideshow.preview"}` | `{id?, width?, height?, path?}` | One slide composed with the layout and overlays, as the slideshow plays it: a PNG written to `path`, or returned base64 in `png` without one; `{id, width, height, cell, photo, overlays}` (`cell` and `photo` are `[x0, y0, x1, y1]` in slide pixels; `overlays` lists `{kind, box}` for each drawn overlay). Sides 16–4096; one side given takes the other from the aspect preview |
|
||||
| `engine.execute {command: "slideshow.titles"}` | `{intro?: screen, ending?: screen}`, a screen being `{enabled?, color?, content?: "none"\|"identityPlate"\|"text", text?, overrideColor?, textColor?, scale?, duration?: seconds\|null}` | The intro and ending screens, saved with the library: a background colour and, centred, the Overlays identity plate or the screen's own text, in the plate's colour or `textColor` when `overrideColor` is on, `scale` = its height as a fraction of the slide's shorter side; `duration` null = the slide duration. The intro plays once before the first photo, the ending after the last when repeat is off. Replies with the settings and `warnings` |
|
||||
| `engine.execute {command: "slideshow.preview"}` | `{id?, title?, width?, height?, path?}` | One slide composed with the layout and overlays, as the slideshow plays it (with `title: "intro"\|"ending"`, that title screen instead → `{title, enabled, width, height, plate}`): a PNG written to `path`, or returned base64 in `png` without one; `{id, width, height, cell, photo, overlays}` (`cell` and `photo` are `[x0, y0, x1, y1]` in slide pixels; `overlays` lists `{kind, box}` for each drawn overlay). Sides 16–4096; one side given takes the other from the aspect preview |
|
||||
| `app.quit` | — | Close the app |
|
||||
|
||||
White balance (`develop.set` with `wb.temp` / `wb.tint`, `develop.wb`, `develop.wbPick`) is in Kelvin and tint on
|
||||
|
||||
+3
-1
@@ -210,7 +210,9 @@ written as a PNG to `path`, or returned as base64 PNG in `png` — so an agent c
|
||||
before playing it. `cmd_slideshow_playback` sets the timing and fades. `cmd_slideshow_overlays` sets the
|
||||
overlays drawn on every slide (identity plate text or image, rating stars, text overlays with tokens such as
|
||||
`{caption}` or `{exposure}`, the export watermark); its reply lists `warnings` (an unreadable plate image, tags that
|
||||
aren't tokens), and `cmd_slideshow_preview` returns each overlay's box.
|
||||
aren't tokens), and `cmd_slideshow_preview` returns each overlay's box. `cmd_slideshow_titles` sets the intro and ending
|
||||
screens (colour, the identity plate or a line of text, scale, duration), and `cmd_slideshow_preview {title: "intro"}`
|
||||
renders one.
|
||||
|
||||
## Resources
|
||||
|
||||
|
||||
@@ -52,10 +52,10 @@ cited here still exists. Formerly docs/parity.md.
|
||||
| X. Cross-cutting behaviours (BEHAV) | 23 | 6 | 1 | 1 | 8/8 (100%) | 7/10 (70%) |
|
||||
| Y. Menus | 84 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) |
|
||||
| Z. Keyboard shortcuts (desktop) | 76 | 4 | 3 | 1 | 49/52 (94%) | 23/25 (92%) |
|
||||
| Lightroom Classic extras | 36 | 19 | 30 | 8 | — | 24/26 (92%) |
|
||||
| **Total** | 411 | 56 | 67 | 35 | 193/201 (96%) | 146/159 (92%) |
|
||||
| Lightroom Classic extras | 36 | 20 | 29 | 8 | — | 24/26 (92%) |
|
||||
| **Total** | 411 | 57 | 66 | 35 | 193/201 (96%) | 146/159 (92%) |
|
||||
|
||||
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **82.2%** of 534 in-scope rows — P0 98.0% of 201 · P1 95.0% of 159 · P2 52.0% of 173.
|
||||
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **82.3%** of 534 in-scope rows — P0 98.0% of 201 · P1 95.0% of 159 · P2 52.3% of 173.
|
||||
<!-- /parity:summary -->
|
||||
|
||||
## Top gaps and shortcut notes
|
||||
@@ -746,9 +746,9 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06-
|
||||
| LRC-SS-LAYOUT | Slideshow layout | P2 | 🟡 | `cmd:slideshow.layout`, `cmd:slideshow.preview`, `path:crates/ui-egui/src/panels/slide_layout.rs` | Margins left / right / top / bottom (linked or each on its own; shares of the slide's shorter side, so any screen or output size looks the same), Show Guides, Aspect Preview (Screen, 16:9, 4:3). Missing: the Slideshow module's slide editor — guides are drawn only in the Settings ▸ Slideshow preview and can't be dragged to set the margins; the aspect preview applies to that preview and `slideshow.preview` (playback always fills the screen, as in Classic) |
|
||||
| LRC-SS-OVERLAYS | Slideshow overlays | P2 | 🟡 | `cmd:slideshow.overlays`, `cmd:slideshow.preview`, `path:crates/engine/src/slide_overlay.rs`, `path:crates/ui-egui/src/panels/slide_overlays.rs` | Identity plate (text with size / colour / opacity, or a PNG / JPEG file with its transparency; render behind image), rating stars (our own vector stars, colour, size), text overlays (up to 16) with tokens (`{file}`, `{caption}`, `{title}`, `{date}`, `{exposure}`, `{shutter}`, `{aperture}`, `{focal}`, `{iso}`, `{camera}`, `{lens}`, `{rating}`, `{copyright}` and the other rename tokens; unknown tags show as typed and are reported), size / colour / opacity and a drop shadow (opacity, offset, radius, angle); each at one of nine anchors of the slide or the photo plus an offset, kept on the slide; the export watermark on the photo. Sizes relative to the slide; drawn by the engine over the composed slide (Inter + craft-fonts CJK, the export watermark's text renderer) for playback, the Settings ▸ Slideshow preview and `slideshow.preview`. Missing: dragging overlays on the slide in a Slideshow module editor, font family / style choice (Inter SemiBold only), the Identity Plate Editor's styled multi-run text, right-to-left reordering (Arabic / Hebrew draw in logical order, and without glyphs in Inter) |
|
||||
| LRC-SS-BACKDROP | Slideshow backdrop | P2 | ✅ | `cmd:slideshow.layout`, `path:crates/slideshow/src/compose.rs` | Background colour, Color Wash (a gradient from a second colour, full at the side its angle points to, at an opacity), Background Image (a library photo filling the slide at an opacity: "Use selected photo" in Settings ▸ Slideshow, `backgroundImage` in `slideshow.layout`; rendered once and shared by the slides). In the web build the composition runs on the main thread when a worker's render comes back |
|
||||
| LRC-SS-TITLES | Slideshow titles | P2 | ⬜ | | |
|
||||
| LRC-SS-TITLES | Slideshow titles | P2 | 🟡 | `cmd:slideshow.titles`, `cmd:slideshow.preview`, `path:crates/engine/src/slide_title.rs`, `path:crates/ui-egui/src/panels/slide_titles.rs` | Intro screen and ending screen, each on or off (library preference, `slideshow.titles`; Settings ▸ Slideshow ▸ Titles): a background colour and, centred on it, the identity plate of the Overlays section (its text or image; Add Identity Plate) or the screen's own line of text, in the plate's colour or an override colour, at a scale (height as a share of the slide's shorter side; text wider than the slide shrinks to fit), for its own duration (default: the slide duration). Titles are items of the playback timeline: composed off the UI thread by the same composer at the screen's size, prepared ahead, waited for and faded like slides; the intro plays once before the first photo, the ending after the last photo when repeat is off; random order never moves them; ←/→ step across them; they never change the selection and the control bar shows Intro / End instead of n / N. `slideshow.preview {title}` renders a screen; the Settings preview shows Photo / Intro / Ending. An empty view still refuses to play ("no photos to show"). Missing: Classic's exact behaviour with Repeat on is not verified (we never show the ending, and show the intro only at the start, while repeating), the Identity Plate Editor's styled text (Inter SemiBold only), override colour leaves an image plate's colours alone, the Slideshow module's Titles panel (settings live in Settings ▸ Slideshow), titles in slideshow export (LRC-SS-EXPORT) |
|
||||
| LRC-SS-MUSIC | Slideshow music | P2 | ⬜ | | |
|
||||
| LRC-SS-PLAYBACK | Slideshow playback | P2 | 🟡 | `cmd:slideshow.playback`, `cmd:view.slideshowOptions`, `cmd:slideshow.togglePause`, `cmd:slideshow.next`, `cmd:slideshow.previous`, `cmd:slideshow.end` | Playback settings (library preference, `slideshow.playback`; Settings ▸ Slideshow, View ▸ Slideshow Options…): slide duration 0.5–120 s, fade 0–10 s (≤ slide; 0 = cut) as a crossfade or through a colour, random order (seeded shuffle, every photo once per pass, a new order each pass), repeat (off: ends after the last slide), prepare previews in advance (the next 2 slides render off the UI thread; a transition never starts before its slide is rendered — the current one is held). Space / ←/→ (cut) / Esc and a control bar (previous, play/pause, next, n / N, end) that shows on pointer movement. Missing: the Slideshow module view and its "preview in the view" (phase 2 of the module plan), music and sync to music (LRC-SS-MUSIC), pan & zoom, manual-advance mode, choosing the screen to play on, titles on the slides (LRC-SS-TITLES). Slides are composed with the layout, options and backdrop (LRC-SS-LAYOUT / OPTIONS / BACKDROP) and carry the overlays (LRC-SS-OVERLAYS) |
|
||||
| LRC-SS-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. Intro and ending screens play as items of the same timeline (LRC-SS-TITLES). Slides are composed with the layout, options and backdrop (LRC-SS-LAYOUT / OPTIONS / BACKDROP) and carry the overlays (LRC-SS-OVERLAYS) |
|
||||
| LRC-SS-EXPORT | Slideshow export | P2 | ⬜ | | |
|
||||
| LRC-PRINT-LAYOUTSTYLE | Print layout styles | P2 | 🟡 | `cmd:dialog.contactSheet`, `cmd:export.contactSheet`, `crates/engine/src/contact_sheet.rs` | paginated contact sheets; single-image/custom package layouts missing |
|
||||
| LRC-PRINT-IMAGESETTINGS | Print image settings | P2 | ⬜ | | |
|
||||
|
||||
@@ -284,6 +284,18 @@
|
||||
preset pickers in Import and Settings ▸ Import scroll the same way.
|
||||
- Importing a large collection and opening its library no longer compare every preset with every other.
|
||||
|
||||
### Slideshow intro and ending screens
|
||||
- A slideshow can now open with an intro screen and close with an ending screen, like Lightroom Classic's Titles
|
||||
panel. Each is switched on on its own and has a background colour and, centred on it, your identity plate (the one
|
||||
set under Overlays: its text or image) or a line of text of its own, in the plate's colour or one you pick, at a
|
||||
scale. Each stays up for its own duration (by default the slide duration) and fades in and out like a slide.
|
||||
- The intro plays once before the first photo; the ending after the last photo when Repeat is off (while repeating
|
||||
the show runs until you stop it, so the ending doesn't come up). Random order never moves them, ←/→ step across them,
|
||||
and the control bar says Intro or End while one is up (the photo count only counts photos).
|
||||
- Set them in Settings ▸ Slideshow ▸ Titles; the preview there switches between the photo's slide, the intro and the
|
||||
ending. They are saved with the library. Agents use `slideshow.titles`, and `slideshow.preview {title: "intro"}`
|
||||
renders a screen as a PNG.
|
||||
|
||||
### Slideshow overlays
|
||||
- Slides can now carry Lightroom Classic's Slideshow overlays: an identity plate (a line of text in a colour, or an
|
||||
image file such as a PNG logo with transparency, at a size and opacity, optionally drawn behind the photo), the
|
||||
|
||||
Reference in New Issue
Block a user