mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-22 03:33:22 -05:00
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.
313 lines
16 KiB
Rust
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}");
|
|
}
|
|
}
|