Files
Jason Baldwin f98078415e LRC-SS-TITLES: slideshow intro and ending screens
Intro and ending title screens (Lightroom Classic's Titles panel): each on
or off, a background colour and, centred, the Overlays identity plate or a
line of text, in the plate's colour or an override colour, at a scale, for
its own duration (default: the slide duration).

- Playback timeline: titles are items (Item::Title) of Playback; the intro
  plays once before the first photo, the ending after the last photo when
  repeat is off; random order never moves them; arrows step across them;
  fades, pause and readiness work as for slides; n of N counts photos only.
- Engine: slide_title (TitleSettings in prefs.json, TitleCompose drawing
  through the slide composer and overlay text renderer), slideshow.titles
  get/set, slideshow.preview {title}.
- UI: titles composed on a render worker (Renderer::request_title), the
  control bar shows Intro / End, Settings > Slideshow > Titles section and
  a Photo / Intro / Ending preview choice; strings in the nine locales.
- Docs: parity row (partial), playback note, whats-new, control and MCP.
2026-10-11 08:24:54 +02:00

313 lines
16 KiB
Rust

//! Slideshow slides: `slideshow.layout` (validated, saved with the library) and
//! `slideshow.preview` (a slide composed with the layout, as a PNG).
use std::path::PathBuf;
use serde_json::json;
use crate::Session;
fn temp_dir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("lc-slide-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn png_size(bytes: &[u8]) -> (u32, u32) {
assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n", "a PNG");
let be = |o: usize| u32::from_be_bytes([bytes[o], bytes[o + 1], bytes[o + 2], bytes[o + 3]]);
(be(16), be(20))
}
fn base64_decode(text: &str) -> Vec<u8> {
const A: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let (mut out, mut acc, mut bits) = (Vec::new(), 0u32, 0u32);
for c in text.bytes().take_while(|c| *c != b'=') {
acc = (acc << 6) | A.iter().position(|a| *a == c).unwrap() as u32;
bits += 6;
if bits >= 8 {
bits -= 8;
out.push((acc >> bits) as u8);
acc &= (1 << bits) - 1;
}
}
out
}
#[test]
fn slide_layout_is_validated_and_persists_with_the_library() {
let lib = temp_dir("layout");
{
let mut s = Session::new().with_fs();
s.open_library(&lib, false).unwrap();
let r = s.execute("slideshow.layout", &json!({})).unwrap();
assert_eq!(r["marginLeft"], 0.0, "defaults: {r}");
assert_eq!(r["backdropColor"], "#000000");
let r = s
.execute(
"slideshow.layout",
&json!({"marginTop": 0.1, "strokeBorder": true, "strokeColor": [255, 255, 255], "castShadow": true, "backdropColor": "#203040", "aspect": "4:3"}),
)
.unwrap();
assert_eq!((r["marginLeft"].as_f64(), r["marginBottom"].as_f64()), (Some(0.1), Some(0.1)), "linked margins: {r}");
assert_eq!(r["strokeColor"], "#ffffff");
// a bad value changes nothing (the good keys beside it included)
for bad in [json!({"marginLeft": "wide", "zoomToFill": true}), json!({"backdropColor": "red"}), json!({"glitter": 1})] {
assert!(s.execute("slideshow.layout", &bad).is_err(), "{bad}");
}
assert!(!s.slide_layout.zoom_to_fill);
// out of range: clamped
let r = s.execute("slideshow.layout", &json!({"linkMargins": false, "marginRight": 9, "shadowOpacity": -1})).unwrap();
assert_eq!((r["marginRight"].as_f64(), r["marginLeft"].as_f64(), r["shadowOpacity"].as_f64()), (Some(0.45), Some(0.1), Some(0.0)));
drop(s);
}
let mut s = Session::new().with_fs();
s.open_library(&lib, false).unwrap();
let r = s.execute("slideshow.layout", &json!({})).unwrap();
assert_eq!((r["marginRight"].as_f64(), r["aspect"].as_str(), r["backdropColor"].as_str()), (Some(0.45), Some("4:3"), Some("#203040")));
assert_eq!(r["linkMargins"], false);
drop(s);
// hand-edited values out of range are clamped when the library opens
std::fs::write(lib.join("prefs.json"), r#"{"slideLayout": {"marginTop": 1e9, "shadowAngle": 720, "strokeWidth": -3}}"#).unwrap();
let mut s = Session::new().with_fs();
s.open_library(&lib, false).unwrap();
assert_eq!((s.slide_layout.margin_top, s.slide_layout.shadow_angle, s.slide_layout.stroke_width), (0.45, 0.0, 0.0));
// and the playback settings beside them are untouched by a layout change
let before = s.slideshow.clone();
s.execute("slideshow.layout", &json!({"zoomToFill": true})).unwrap();
assert_eq!(s.slideshow, before);
drop(s);
let _ = std::fs::remove_dir_all(&lib);
}
#[test]
fn slide_preview_returns_the_composed_slide_at_the_asked_size() {
let mut s = Session::with_demo();
let photos = s.visible_cloned();
s.execute("library.select", &json!({"ids": [photos[0].0]})).unwrap();
s.execute("slideshow.layout", &json!({"margins": 0.1, "backdropColor": "#ff0000"})).unwrap();
let r = s.execute("slideshow.preview", &json!({"width": 320, "height": 200})).unwrap();
assert_eq!((r["width"].as_u64(), r["height"].as_u64(), r["id"].as_u64()), (Some(320), Some(200), Some(photos[0].0)));
assert_eq!(r["cell"], json!([20.0, 20.0, 300.0, 180.0]), "10 % of the 200 px short side");
let png = base64_decode(r["png"].as_str().unwrap());
assert_eq!(png_size(&png), (320, 200));
// written to a file instead
let dir = temp_dir("preview");
let path = dir.join("slide.png");
let r = s.execute("slideshow.preview", &json!({"id": photos[1].0, "width": 240, "path": path.to_string_lossy()})).unwrap();
assert_eq!(r["path"], path.to_string_lossy().as_ref());
assert!(r.get("png").is_none());
assert_eq!(png_size(&std::fs::read(&path).unwrap()), (240, 150), "the height from the aspect preview (screen: 16:10)");
s.execute("slideshow.layout", &json!({"aspect": "16:9"})).unwrap();
let r = s.execute("slideshow.preview", &json!({"height": 90})).unwrap();
assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(160), Some(90)));
// the pixels: red margins, the photo inside
let img = s.slide_now(&s.slide_layout.clone(), photos[0], 320, 200).unwrap();
assert_eq!((img.width, img.height), (320, 200));
assert_eq!(img.get(3, 3), [255, 0, 0, 255]);
assert_eq!(img.get(160, 196), [255, 0, 0, 255]);
assert_ne!(img.get(160, 100), [255, 0, 0, 255], "the photo in the cell");
// refused: bad sizes, a photo that isn't there
for bad in [json!({"width": 4}), json!({"height": 100000}), json!({"width": "big"}), json!({"id": 999_999}), json!({"id": "one"})] {
assert!(s.execute("slideshow.preview", &bad).is_err(), "{bad}");
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_background_image_and_the_slide_key() {
let mut s = Session::with_demo();
let photos = s.visible_cloned();
let plain = s.slide_layout.clone();
let job = s.slide_job(photos[0], 320, 200, None).unwrap();
let loupe = s.loupe_job(photos[0], 320, 200, true).unwrap();
assert_ne!(job.key, loupe.key, "a slide is not the photo's render");
// a background image fills the slide around the photo
let bg =
s.slide_layout.merged(&json!({"margins": 0.2, "backdropColor": "#ff0000", "backgroundImage": photos[2].0, "backgroundOpacity": 1})).unwrap();
let img = s.slide_now(&bg, photos[0], 320, 200).unwrap();
assert_ne!(img.get(3, 3), [255, 0, 0, 255], "the background photo, not the colour");
// it is rendered once and reused
assert!(s.slide_backdrop.lock().unwrap().is_some());
let other = s.slide_job_with(&bg, photos[1], 320, 200, None, false).unwrap();
assert_ne!(other.key, s.slide_job_with(&plain, photos[1], 320, 200, None, false).unwrap().key);
// a background photo that isn't in the catalog: the colour alone
let gone = bg.merged(&json!({"backgroundImage": 987_654})).unwrap();
let img = s.slide_now(&gone, photos[0], 320, 200).unwrap();
assert_eq!(img.get(3, 3), [255, 0, 0, 255]);
}
#[test]
fn slide_overlays_are_validated_and_persist_with_the_library() {
let lib = temp_dir("overlays");
{
let mut s = Session::new().with_fs();
s.open_library(&lib, false).unwrap();
let r = s.execute("slideshow.overlays", &json!({})).unwrap();
assert_eq!(r["identityPlate"]["enabled"], false, "defaults: {r}");
assert_eq!(r["texts"], json!([]));
assert!(s.slides_plain());
let r = s
.execute(
"slideshow.overlays",
&json!({"identityPlate": {"enabled": true, "text": "Studio Nord"}, "ratingStars": {"enabled": true}, "addText": {"text": "{caption} {bogus}"}, "showWatermark": true, "watermark": "© Ann"}),
)
.unwrap();
assert_eq!(r["texts"].as_array().map(Vec::len), Some(1));
assert_eq!(r["watermark"]["text"], "© Ann");
assert_eq!(r["warnings"].as_array().map(Vec::len), Some(1), "the unknown token is reported: {r}");
assert!(!s.slides_plain(), "overlays make playback compose its slides");
// a bad value changes nothing (the good keys beside it included)
for bad in
[json!({"identityPlate": {"size": "big"}, "showWatermark": false}), json!({"texts": [{"anchor": "nowhere"}]}), json!({"removeText": 5})]
{
assert!(s.execute("slideshow.overlays", &bad).is_err(), "{bad}");
}
assert!(s.slide_overlays.show_watermark);
drop(s);
}
let mut s = Session::new().with_fs();
s.open_library(&lib, false).unwrap();
let r = s.execute("slideshow.overlays", &json!({})).unwrap();
assert_eq!((r["identityPlate"]["text"].as_str(), r["texts"][0]["text"].as_str()), (Some("Studio Nord"), Some("{caption} {bogus}")));
// the layout beside it is untouched by an overlay change
let before = s.slide_layout.clone();
s.execute("slideshow.overlays", &json!({"removeText": 0})).unwrap();
assert_eq!(s.slide_layout, before);
drop(s);
// hand-edited values out of range are clamped when the library opens
std::fs::write(lib.join("prefs.json"), r#"{"slideOverlays": {"identityPlate": {"size": 1e9, "opacity": -2}, "texts": [{"size": 1e300}]}}"#)
.unwrap();
let mut s = Session::new().with_fs();
s.open_library(&lib, false).unwrap();
assert_eq!((s.slide_overlays.identity_plate.size, s.slide_overlays.identity_plate.opacity), (crate::slide_overlay::PLATE_SIZE_MAX, 0.0));
assert_eq!(s.slide_overlays.texts[0].size, crate::slide_overlay::TEXT_SIZE_MAX);
drop(s);
let _ = std::fs::remove_dir_all(&lib);
}
#[test]
fn slide_preview_draws_the_overlays() {
let mut s = Session::with_demo();
let photos = s.visible_cloned();
s.execute("library.select", &json!({"ids": [photos[0].0]})).unwrap();
s.execute("photo.rate", &json!({"rating": 4, "ids": [photos[0].0]})).unwrap();
s.execute("slideshow.layout", &json!({"margins": 0.2, "backdropColor": "#000000"})).unwrap();
let before = s.slide_job(photos[0], 320, 200, None).unwrap().key;
let plain = s.slide_now(&s.slide_layout.clone(), photos[0], 640, 400).unwrap();
s.execute(
"slideshow.overlays",
&json!({
"identityPlate": {"enabled": true, "text": "LC", "size": 0.1, "color": "#00ff00", "opacity": 1, "anchor": "topLeft", "offsetX": 0.01, "offsetY": 0.01},
"ratingStars": {"enabled": true, "color": "#ffff00", "size": 0.05, "anchor": "bottomRight", "offsetX": -0.01, "offsetY": -0.01, "relativeTo": "slide"},
"addText": {"text": "{file}", "size": 0.05, "color": "#ffffff", "opacity": 1, "anchor": "bottom", "offsetX": 0, "offsetY": -0.01},
}),
)
.unwrap();
assert_ne!(s.slide_job(photos[0], 320, 200, None).unwrap().key, before, "overlays change the slide's render key");
let r = s.execute("slideshow.preview", &json!({"width": 640, "height": 400})).unwrap();
let kinds: Vec<&str> = r["overlays"].as_array().unwrap().iter().filter_map(|o| o["kind"].as_str()).collect();
assert_eq!(kinds, ["identityPlate", "ratingStars", "text0"]);
let img = s.slide_now(&s.slide_layout.clone(), photos[0], 640, 400).unwrap();
let count = |img: &lightcraft_raster::Rgba8, b: &serde_json::Value, want: fn([u8; 4]) -> bool| {
let v: Vec<usize> = (0..4).map(|i| b[i].as_f64().unwrap() as usize).collect();
(v[1]..v[3]).flat_map(|y| (v[0]..v[2]).map(move |x| (x, y))).filter(|&(x, y)| want(img.get(x, y))).count()
};
for (i, (kind, want)) in [
("identityPlate", (|p: [u8; 4]| p[1] > 200 && p[0] < 60) as fn([u8; 4]) -> bool),
("ratingStars", |p| p[0] > 200 && p[1] > 200 && p[2] < 60),
("text0", |p| p[0] > 200 && p[1] > 200 && p[2] > 200),
]
.into_iter()
.enumerate()
{
let b = &r["overlays"][i]["box"];
assert!(count(&img, b, want) > 10, "{kind} is drawn in its box {b}");
assert_eq!(count(&plain, b, want), 0, "{kind}: not there without overlays");
}
}
#[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}");
}
}