Files
lightcraft/crates/slideshow/src/settings.rs
T
Jason Baldwin ae033aaf32 LRC-SS-LAYOUT: slideshow layout, options and backdrop: slides composed with margins, stroke, shadow and backdrop
- New crate lightcraft-slideshow (L1, no UI): SlideSettings (margins linked or
  independent, guides, aspect preview, zoom to fill, stroke border, cast shadow,
  backdrop colour, colour wash, background image; lengths relative to the slide's
  shorter side; validated and clamped) and a CPU compose() used by playback, the
  Settings preview and slideshow.preview.
- Engine: slideshow.layout (partial merge, saved in prefs.json) and
  slideshow.preview {id?, width?, height?, path?} (PNG to path or base64);
  RenderJob::with_slide composes on the render worker; the background photo is
  rendered once and shared.
- Playback: with a non-default layout each slide is the composed screen-sized
  slide, prepared ahead like before; the default layout plays as before.
- Settings > Slideshow: Layout, Options and Backdrop sections and a debounced
  live preview of the active photo's slide (with margin guides).
- Tests: composer geometry/pixels/hostile input, command get/set/persist/preview,
  headless playback of a composed slide and of the default layout.
2026-10-10 21:47:42 +02:00

402 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! [`SlideSettings`]: Layout (margins, guides, aspect preview), Options (zoom to fill, stroke
//! border, cast shadow) and Backdrop (colour, colour wash, background image).
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
/// Largest margin (fraction of the slide's shorter side).
pub const MARGIN_MAX: f64 = 0.45;
/// Widest stroke border (fraction of the slide's shorter side).
pub const STROKE_MAX: f64 = 0.05;
/// Longest shadow offset (fraction of the slide's shorter side).
pub const SHADOW_OFFSET_MAX: f64 = 0.1;
/// Largest shadow radius (fraction of the slide's shorter side).
pub const SHADOW_RADIUS_MAX: f64 = 0.1;
/// Largest slide side [`crate::compose`] draws (bigger requests are scaled down to it).
pub const MAX_SIDE: usize = 8192;
/// The aspect the slide is previewed at (Lightroom Classic's Aspect Preview). Playback always
/// fills the screen; the settings preview and `slideshow.preview` use this.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum Aspect {
/// The screen's own aspect.
#[default]
#[serde(rename = "screen")]
Screen,
#[serde(rename = "16:9")]
Wide,
#[serde(rename = "4:3")]
Standard,
}
impl Aspect {
pub const ALL: [Aspect; 3] = [Aspect::Screen, Aspect::Wide, Aspect::Standard];
pub fn id(self) -> &'static str {
match self {
Aspect::Screen => "screen",
Aspect::Wide => "16:9",
Aspect::Standard => "4:3",
}
}
pub fn parse(s: &str) -> Option<Aspect> {
Aspect::ALL.into_iter().find(|a| a.id().eq_ignore_ascii_case(s.trim()))
}
/// Width / height, given the screen's (used for [`Aspect::Screen`]; a bad one gives 16:10).
pub fn ratio(self, screen: f64) -> f64 {
match self {
Aspect::Screen if screen.is_finite() && screen > 0.05 && screen < 20.0 => screen,
Aspect::Screen => 1.6,
Aspect::Wide => 16.0 / 9.0,
Aspect::Standard => 4.0 / 3.0,
}
}
}
/// How a slide looks (everything but the photo). Lengths are fractions of the slide's shorter
/// side, angles degrees counter-clockwise from the right, opacities 0–1.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct SlideSettings {
// Layout
pub margin_left: f64,
pub margin_right: f64,
pub margin_top: f64,
pub margin_bottom: f64,
/// Changing one margin changes all four.
pub link_margins: bool,
/// Show the margin guides in the slide editor (never in playback or export).
pub show_guides: bool,
pub aspect: Aspect,
// Options
/// Crop the photo to fill its cell (off: the whole photo fits inside it).
pub zoom_to_fill: bool,
pub stroke_border: bool,
pub stroke_color: [u8; 3],
pub stroke_width: f64,
pub cast_shadow: bool,
pub shadow_opacity: f64,
pub shadow_offset: f64,
pub shadow_radius: f64,
pub shadow_angle: f64,
// Backdrop
pub backdrop_color: [u8; 3],
/// A gradient from `wash_color` (full at the side `wash_angle` points to) to the backdrop.
pub color_wash: bool,
pub wash_color: [u8; 3],
pub wash_opacity: f64,
pub wash_angle: f64,
/// A library photo (its id) drawn over the backdrop colour, filling the slide.
pub background_image: Option<u64>,
pub background_opacity: f64,
}
impl Default for SlideSettings {
fn default() -> Self {
SlideSettings {
margin_left: 0.0,
margin_right: 0.0,
margin_top: 0.0,
margin_bottom: 0.0,
link_margins: true,
show_guides: true,
aspect: Aspect::Screen,
zoom_to_fill: false,
stroke_border: false,
stroke_color: [255, 255, 255],
stroke_width: 0.004,
cast_shadow: false,
shadow_opacity: 0.4,
shadow_offset: 0.012,
shadow_radius: 0.02,
shadow_angle: -45.0,
backdrop_color: [0, 0, 0],
color_wash: false,
wash_color: [96, 96, 96],
wash_opacity: 0.6,
wash_angle: 90.0,
background_image: None,
background_opacity: 0.5,
}
}
}
/// The keys [`SlideSettings::merged`] takes (for error messages and docs).
const KEYS: &str = "margins, marginLeft, marginRight, marginTop, marginBottom, linkMargins, showGuides, aspect, zoomToFill, strokeBorder, strokeColor, strokeWidth, castShadow, shadowOpacity, shadowOffset, shadowRadius, shadowAngle, backdropColor, colorWash, washColor, washOpacity, washAngle, backgroundImage, backgroundOpacity";
fn clamp_or(v: f64, lo: f64, hi: f64, fallback: f64) -> f64 {
if v.is_finite() { v.clamp(lo, hi) } else { fallback }
}
/// An angle in degrees into (−180, 180]; non-finite → `fallback`.
fn wrap_angle(a: f64, fallback: f64) -> f64 {
if !a.is_finite() {
return fallback;
}
let w = (a + 180.0).rem_euclid(360.0) - 180.0;
if w <= -180.0 { 180.0 } else { w }
}
impl SlideSettings {
/// The settings with every value in range: lengths and opacities clamped, angles wrapped,
/// non-finite numbers replaced by the defaults.
pub fn sanitized(mut self) -> Self {
let d = SlideSettings::default();
for m in [&mut self.margin_left, &mut self.margin_right, &mut self.margin_top, &mut self.margin_bottom] {
*m = clamp_or(*m, 0.0, MARGIN_MAX, 0.0);
}
self.stroke_width = clamp_or(self.stroke_width, 0.0, STROKE_MAX, d.stroke_width);
self.shadow_opacity = clamp_or(self.shadow_opacity, 0.0, 1.0, d.shadow_opacity);
self.shadow_offset = clamp_or(self.shadow_offset, 0.0, SHADOW_OFFSET_MAX, d.shadow_offset);
self.shadow_radius = clamp_or(self.shadow_radius, 0.0, SHADOW_RADIUS_MAX, d.shadow_radius);
self.shadow_angle = wrap_angle(self.shadow_angle, d.shadow_angle);
self.wash_opacity = clamp_or(self.wash_opacity, 0.0, 1.0, d.wash_opacity);
self.wash_angle = wrap_angle(self.wash_angle, d.wash_angle);
self.background_opacity = clamp_or(self.background_opacity, 0.0, 1.0, d.background_opacity);
self
}
/// Does a slide with these settings show nothing but the photo fitted on black (the plain
/// slideshow)? Settings that are switched on but draw nothing (a 0-wide stroke, a shadow at
/// 0 % opacity) count as plain.
pub fn is_plain(&self) -> bool {
let s = self.clone().sanitized();
s.margin_left == 0.0
&& s.margin_right == 0.0
&& s.margin_top == 0.0
&& s.margin_bottom == 0.0
&& !s.zoom_to_fill
&& !(s.stroke_border && s.stroke_width > 0.0)
&& !(s.cast_shadow && s.shadow_opacity > 0.0)
&& s.backdrop_color == [0, 0, 0]
&& !(s.color_wash && s.wash_opacity > 0.0)
&& !(s.background_image.is_some() && s.background_opacity > 0.0)
}
/// A hash of what changes the slide's pixels (guides and the aspect preview don't), for
/// render keys. Stable within a run.
pub fn pixel_key(&self) -> u64 {
use std::hash::{Hash, Hasher};
let mut s = self.clone().sanitized();
s.show_guides = false;
s.aspect = Aspect::Screen;
s.link_margins = false;
let mut h = std::collections::hash_map::DefaultHasher::new();
s.to_json().to_string().hash(&mut h);
h.finish()
}
pub fn to_json(&self) -> Value {
json!({
"marginLeft": self.margin_left,
"marginRight": self.margin_right,
"marginTop": self.margin_top,
"marginBottom": self.margin_bottom,
"linkMargins": self.link_margins,
"showGuides": self.show_guides,
"aspect": self.aspect.id(),
"zoomToFill": self.zoom_to_fill,
"strokeBorder": self.stroke_border,
"strokeColor": hex(self.stroke_color),
"strokeWidth": self.stroke_width,
"castShadow": self.cast_shadow,
"shadowOpacity": self.shadow_opacity,
"shadowOffset": self.shadow_offset,
"shadowRadius": self.shadow_radius,
"shadowAngle": self.shadow_angle,
"backdropColor": hex(self.backdrop_color),
"colorWash": self.color_wash,
"washColor": hex(self.wash_color),
"washOpacity": self.wash_opacity,
"washAngle": self.wash_angle,
"backgroundImage": self.background_image,
"backgroundOpacity": self.background_opacity,
})
}
/// These settings with the keys `p` names changed (the others kept), validated and clamped.
/// A wrong type, an unknown key or a non-finite number is an error, and nothing changes.
/// With `linkMargins` on (already, or set in `p`), a margin given sets all four; `margins`
/// sets all four in any case.
pub fn merged(&self, p: &Value) -> Result<Self, String> {
let Some(obj) = p.as_object() else { return Err("parameters must be an object".into()) };
let mut s = self.clone();
let mut margin: Option<f64> = None;
for (k, v) in obj {
match k.as_str() {
"margins" => {
let m = number(k, v)?;
(s.margin_left, s.margin_right, s.margin_top, s.margin_bottom) = (m, m, m, m);
}
"marginLeft" => s.margin_left = *margin.insert(number(k, v)?),
"marginRight" => s.margin_right = *margin.insert(number(k, v)?),
"marginTop" => s.margin_top = *margin.insert(number(k, v)?),
"marginBottom" => s.margin_bottom = *margin.insert(number(k, v)?),
"linkMargins" => s.link_margins = boolean(k, v)?,
"showGuides" => s.show_guides = boolean(k, v)?,
"aspect" => {
s.aspect =
v.as_str().and_then(Aspect::parse).ok_or_else(|| format!("`aspect` must be \"screen\", \"16:9\" or \"4:3\", not {v}"))?
}
"zoomToFill" => s.zoom_to_fill = boolean(k, v)?,
"strokeBorder" => s.stroke_border = boolean(k, v)?,
"strokeColor" => s.stroke_color = color(k, v)?,
"strokeWidth" => s.stroke_width = number(k, v)?,
"castShadow" => s.cast_shadow = boolean(k, v)?,
"shadowOpacity" => s.shadow_opacity = number(k, v)?,
"shadowOffset" => s.shadow_offset = number(k, v)?,
"shadowRadius" => s.shadow_radius = number(k, v)?,
"shadowAngle" => s.shadow_angle = number(k, v)?,
"backdropColor" => s.backdrop_color = color(k, v)?,
"colorWash" => s.color_wash = boolean(k, v)?,
"washColor" => s.wash_color = color(k, v)?,
"washOpacity" => s.wash_opacity = number(k, v)?,
"washAngle" => s.wash_angle = number(k, v)?,
"backgroundImage" => {
s.background_image = match v {
Value::Null => None,
_ => Some(v.as_u64().ok_or_else(|| format!("`backgroundImage` must be a photo id or null, not {v}"))?),
}
}
"backgroundOpacity" => s.background_opacity = number(k, v)?,
other => return Err(format!("unknown setting `{other}` ({KEYS})")),
}
}
if s.link_margins
&& let Some(m) = margin
{
(s.margin_left, s.margin_right, s.margin_top, s.margin_bottom) = (m, m, m, m);
}
Ok(s.sanitized())
}
}
fn number(k: &str, v: &Value) -> Result<f64, String> {
match v.as_f64() {
Some(x) if x.is_finite() => Ok(x),
_ => Err(format!("`{k}` must be a finite number, not {v}")),
}
}
fn boolean(k: &str, v: &Value) -> Result<bool, String> {
v.as_bool().ok_or_else(|| format!("`{k}` must be true or false, not {v}"))
}
fn color(k: &str, v: &Value) -> Result<[u8; 3], String> {
parse_color(v).ok_or_else(|| format!("`{k}` must be \"#rrggbb\" or [r, g, b] (0–255), not {v}"))
}
/// `"#rrggbb"` (the `#` optional) or `[r, g, b]` with 0–255 components.
pub fn parse_color(v: &Value) -> Option<[u8; 3]> {
match v {
Value::String(s) => {
let h = s.trim().strip_prefix('#').unwrap_or(s.trim());
if h.len() != 6 || !h.is_ascii() {
return None;
}
let byte = |i: usize| h.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok());
Some([byte(0)?, byte(2)?, byte(4)?])
}
Value::Array(a) if a.len() == 3 => {
let mut c = [0u8; 3];
for (o, x) in c.iter_mut().zip(a) {
*o = u8::try_from(x.as_u64()?).ok()?;
}
Some(c)
}
_ => None,
}
}
/// `#rrggbb`.
pub fn hex(c: [u8; 3]) -> String {
format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn merge_validates_links_margins_and_round_trips() {
let d = SlideSettings::default();
assert!(d.is_plain(), "the default slide is the plain slideshow");
let s = d.merged(&json!({"marginLeft": 0.1})).unwrap();
assert_eq!((s.margin_left, s.margin_right, s.margin_top, s.margin_bottom), (0.1, 0.1, 0.1, 0.1), "linked: all four");
let s = d.merged(&json!({"linkMargins": false, "marginTop": 0.2})).unwrap();
assert_eq!((s.margin_left, s.margin_top), (0.0, 0.2), "unlinked: one");
let s = s.merged(&json!({"margins": 0.05})).unwrap();
assert_eq!((s.margin_left, s.margin_bottom), (0.05, 0.05));
let s = d
.merged(&json!({"strokeBorder": true, "strokeColor": "#FF8000", "washColor": [1, 2, 3], "aspect": "16:9", "backgroundImage": 7}))
.unwrap();
assert_eq!(s.stroke_color, [255, 128, 0]);
assert_eq!(s.wash_color, [1, 2, 3]);
assert_eq!(s.aspect, Aspect::Wide);
assert_eq!(s.background_image, Some(7));
assert!(!s.is_plain());
// the JSON form reads back as the same settings
assert_eq!(d.merged(&s.to_json()).unwrap(), s);
// serde (prefs.json) round trip, and missing keys take the defaults
let back: SlideSettings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
assert_eq!(back, s);
let partial: SlideSettings = serde_json::from_str(r#"{"marginTop": 0.3}"#).unwrap();
assert_eq!(partial, SlideSettings { margin_top: 0.3, ..d.clone() });
assert_eq!(d.merged(&json!({"backgroundImage": null})).unwrap().background_image, None);
}
#[test]
fn hostile_settings_are_rejected_or_clamped() {
let d = SlideSettings::default();
for bad in [
json!({"marginLeft": "wide"}),
json!({"margins": null}),
json!({"zoomToFill": 1}),
json!({"strokeColor": "#12345"}),
json!({"strokeColor": "#ééééé"}),
json!({"washColor": [256, 0, 0]}),
json!({"washColor": [-1, 0, 0]}),
json!({"backdropColor": [1, 2]}),
json!({"aspect": "21:9"}),
json!({"backgroundImage": -3}),
json!({"backgroundImage": "photo"}),
json!({"sparkle": true}),
json!([1]),
] {
assert!(d.merged(&bad).is_err(), "{bad}");
}
let s = d
.merged(&json!({"margins": 1e300, "strokeWidth": -2, "shadowOpacity": 7, "shadowOffset": 1e9, "shadowRadius": -1, "shadowAngle": 540, "washAngle": -190, "washOpacity": -0.5}))
.unwrap();
assert_eq!((s.margin_left, s.margin_top), (MARGIN_MAX, MARGIN_MAX));
assert_eq!((s.stroke_width, s.shadow_opacity, s.shadow_offset, s.shadow_radius), (0.0, 1.0, SHADOW_OFFSET_MAX, 0.0));
assert_eq!((s.shadow_angle, s.wash_angle, s.wash_opacity), (180.0, 170.0, 0.0));
// NaN / ±∞ (a hand-edited prefs file, an in-process caller) take the defaults
let s = SlideSettings {
margin_left: f64::NAN,
stroke_width: f64::INFINITY,
shadow_angle: f64::NEG_INFINITY,
background_opacity: f64::NAN,
..d.clone()
}
.sanitized();
assert_eq!(
(s.margin_left, s.stroke_width, s.shadow_angle, s.background_opacity),
(0.0, d.stroke_width, d.shadow_angle, d.background_opacity)
);
assert_eq!(Aspect::Screen.ratio(f64::NAN), 1.6);
assert_eq!(Aspect::Screen.ratio(1.5), 1.5);
assert!((Aspect::Wide.ratio(1.0) - 16.0 / 9.0).abs() < 1e-12);
}
#[test]
fn pixel_key_ignores_what_does_not_change_pixels() {
let d = SlideSettings::default();
let guides = SlideSettings { show_guides: false, aspect: Aspect::Standard, ..d.clone() };
assert_eq!(d.pixel_key(), guides.pixel_key());
assert_ne!(d.pixel_key(), SlideSettings { backdrop_color: [9, 9, 9], ..d.clone() }.pixel_key());
}
}