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.
This commit is contained in:
Jason Baldwin
2026-10-10 21:47:42 +02:00
parent 52875ec3f9
commit ae033aaf32
36 changed files with 2094 additions and 36 deletions
Generated
+11
View File
@@ -3130,6 +3130,7 @@ dependencies = [
"lightcraft-raw",
"lightcraft-scenes",
"lightcraft-segment",
"lightcraft-slideshow",
"lightcraft-tiff",
"log",
"miniz_oxide 0.8.9",
@@ -3326,6 +3327,15 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "lightcraft-slideshow"
version = "0.5.0"
dependencies = [
"lightcraft-raster",
"serde",
"serde_json",
]
[[package]]
name = "lightcraft-sysmem"
version = "0.5.0"
@@ -3357,6 +3367,7 @@ dependencies = [
"lightcraft-pipeline",
"lightcraft-preview",
"lightcraft-raster",
"lightcraft-slideshow",
"log",
"serde",
"serde_json",
+1
View File
@@ -35,6 +35,7 @@ lightcraft-mcp = { path = "crates/mcp" }
lightcraft-fetch = { path = "crates/fetch" }
lightcraft-heif = { path = "crates/heif" }
lightcraft-segment = { path = "crates/segment" }
lightcraft-slideshow = { path = "crates/slideshow" }
# foundation
thiserror = "2"
+10
View File
@@ -165,6 +165,8 @@ struct Busy {
level: SourceLevel,
key: u64,
cache: Option<(Arc<PreviewCache>, Hash128)>,
/// A slideshow slide: the worker renders the photo, it is composed here when it comes back.
slide: Option<Arc<lightcraft_engine::slide::SlideCompose>>,
}
struct W {
@@ -369,6 +371,13 @@ impl Workers {
});
}
g.remote_done += 1;
let rendered = match (rendered, &b.slide) {
(Ok(mut r), Some(slide)) => {
r.image = slide.compose(&r.image);
Ok(r)
}
(r, _) => r,
};
g.done.push((
b.slot,
RenderResult {
@@ -490,6 +499,7 @@ impl RenderOffload for Workers {
level: job.level,
key: job.key,
cache: job.cache,
slide: job.slide,
});
None
}
+1
View File
@@ -27,6 +27,7 @@ lightcraft-meta = { workspace = true }
lightcraft-faces = { workspace = true }
miniz_oxide = { workspace = true }
lightcraft-merge = { workspace = true }
lightcraft-slideshow = { workspace = true }
rayon = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
+129 -13
View File
@@ -1,12 +1,19 @@
//! `slideshow.playback`: the slideshow's Playback settings (slide and fade durations, fade colour,
//! random order, repeat, preparing previews in advance). A library preference, saved in the
//! library's `prefs.json`; the app's slideshow (View ▸ Slideshow) plays with them.
//! random order, repeat, preparing previews in advance). `slideshow.layout`: how a slide looks
//! (Layout, Options and Backdrop: margins, aspect preview, zoom to fill, stroke, shadow, backdrop
//! 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.preview`
//! composes one slide as a PNG.
use serde_json::Value;
use lightcraft_catalog::PhotoId;
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, cmd};
use super::{CommandSpec, always, bad, cmd, str_param};
use crate::{Result, Session};
/// Largest side `slideshow.preview` renders.
pub const PREVIEW_MAX: usize = 4096;
fn playback(s: &mut Session, p: &Value) -> Result<Value> {
let changes = p.as_object().is_some_and(|o| !o.is_empty());
if changes {
@@ -19,14 +26,123 @@ fn playback(s: &mut Session, p: &Value) -> Result<Value> {
Ok(s.slideshow.to_json())
}
fn layout(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_layout.merged(p).map_err(|e| bad("slideshow.layout", e))?;
if next != s.slide_layout {
s.slide_layout = next;
s.save_prefs()?;
}
}
Ok(s.slide_layout.to_json())
}
/// 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)> {
const C: &str = "slideshow.preview";
let side = |k: &str| -> Result<Option<usize>> {
match p.get(k) {
None | Some(Value::Null) => Ok(None),
Some(v) => match v.as_u64() {
Some(n) if (16..=PREVIEW_MAX as u64).contains(&n) => Ok(Some(n as usize)),
_ => Err(bad(C, format!("`{k}` must be a whole number of pixels from 16 to {PREVIEW_MAX}, not {v}"))),
},
}
};
let ratio = s.slide_layout.aspect.ratio(f64::NAN);
let clamp = |v: f64| (v.round() as usize).clamp(16, PREVIEW_MAX);
Ok(match (side("width")?, side("height")?) {
(Some(w), Some(h)) => (w, h),
(Some(w), None) => (w, clamp(w as f64 / ratio)),
(None, Some(h)) => (clamp(h as f64 * ratio), h),
(None, None) => (1280, clamp(1280.0 / ratio)),
})
}
fn preview(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "slideshow.preview";
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}")))?),
};
if s.catalog.photo(id).is_none() {
return Err(bad(C, format!("no photo {}", id.0)));
}
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 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 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]);
let mut out = json!({
"id": id.0,
"width": img.width,
"height": img.height,
"cell": rect(l.cell),
"photo": rect(l.visible),
});
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(out)
}
/// Standard padded base64 (for an image returned in the reply).
fn base64(bytes: &[u8]) -> String {
const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
let b = [chunk.first().copied().unwrap_or(0), chunk.get(1).copied().unwrap_or(0), chunk.get(2).copied().unwrap_or(0)];
let n = (b[0] as u32) << 16 | (b[1] as u32) << 8 | b[2] as u32;
let at = |shift: u32| A.get((n >> shift) as usize & 63).copied().unwrap_or(b'=') as char;
out.push(at(18));
out.push(at(12));
out.push(if chunk.len() > 1 { at(6) } else { '=' });
out.push(if chunk.len() > 2 { at(0) } else { '=' });
}
out
}
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"slideshow.playback",
"Slideshow Playback Settings",
[],
None,
"{slideDuration?: seconds (0.5–120), fadeDuration?: seconds (0 = cut; at most 10 and the slide duration), fadeColor?: \"#rrggbb\"|[r,g,b]|null (fade through a colour instead of crossfading), randomOrder?: bool, repeat?: bool, preparePreviews?: bool (render the next slides before they are due)} — omitted keys keep their value, {} reads them; saved with the library → {slideDuration, fadeDuration, fadeColor, randomOrder, repeat, preparePreviews}",
always,
playback
)]
vec![
cmd!(
"slideshow.playback",
"Slideshow Playback Settings",
[],
None,
"{slideDuration?: seconds (0.5–120), fadeDuration?: seconds (0 = cut; at most 10 and the slide duration), fadeColor?: \"#rrggbb\"|[r,g,b]|null (fade through a colour instead of crossfading), randomOrder?: bool, repeat?: bool, preparePreviews?: bool (render the next slides before they are due)} — omitted keys keep their value, {} reads them; saved with the library → {slideDuration, fadeDuration, fadeColor, randomOrder, repeat, preparePreviews}",
always,
playback
),
cmd!(
"slideshow.layout",
"Slideshow Layout Settings",
[],
None,
"{margins?: all four, marginLeft?, marginRight?, marginTop?, marginBottom?: fractions of the slide's shorter side (0–0.45; with linkMargins one sets all four), linkMargins?: bool, showGuides?: bool (margin guides in the slide editor), aspect?: \"screen\"|\"16:9\"|\"4:3\" (aspect preview), zoomToFill?: bool (crop the photo to fill its cell), strokeBorder?: bool, strokeColor?: \"#rrggbb\"|[r,g,b], strokeWidth?: fraction of the shorter side (0–0.05), castShadow?: bool, shadowOpacity?: 0–1, shadowOffset?: 0–0.1, shadowRadius?: 0–0.1 (fractions of the shorter side), shadowAngle?: degrees counter-clockwise from the right (−45 = down right), backdropColor?: colour, colorWash?: bool, washColor?: colour, washOpacity?: 0–1, washAngle?: degrees (the side the wash colour is full at), backgroundImage?: photo id|null, backgroundOpacity?: 0–1} — omitted keys keep their value, {} reads them; out-of-range numbers are clamped, wrong types refused; saved with the library → the settings",
always,
layout
),
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 → {id, width, height, cell: [x0,y0,x1,y1], photo: [x0,y0,x1,y1] (the visible photo, slide pixels), path | png (base64 PNG)}",
always,
preview
),
]
}
+1
View File
@@ -1386,6 +1386,7 @@ impl Session {
stages: None,
view_cache: None,
display: None,
slide: None,
};
let embedded = match (&self.media.preview_loader, c.kind) {
(Some(l), MediaKind::Raw) => Some((c.path.clone(), l.clone(), edge)),
+10
View File
@@ -55,6 +55,7 @@ pub mod presets;
pub mod rename;
pub mod segment;
pub mod sidecar;
pub mod slide;
pub mod slideshow;
pub mod smart;
pub mod sync;
@@ -274,6 +275,11 @@ pub struct Session {
pub cache_mb: u32,
/// Slideshow playback settings (`slideshow.playback`; persisted in prefs.json).
pub slideshow: slideshow::PlaybackSettings,
/// Slideshow slide layout: Layout, Options and Backdrop (`slideshow.layout`; persisted in
/// prefs.json).
pub slide_layout: slide::SlideSettings,
/// 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).
pub smart_previews_dir: Option<std::path::PathBuf>,
/// Untouched Local records of folders not browsed for this many days are forgotten when the
@@ -357,6 +363,8 @@ impl Session {
import_defaults: import::ImportDefaults::default(),
cache_mb: 0,
slideshow: slideshow::PlaybackSettings::default(),
slide_layout: slide::SlideSettings::default(),
slide_backdrop: Default::default(),
smart_previews_dir: None,
forget_local_days: lightcraft_catalog::DEFAULT_FORGET_DAYS,
}
@@ -910,6 +918,8 @@ mod tests_segment;
#[cfg(test)]
mod tests_settings_files;
#[cfg(test)]
mod tests_slide;
#[cfg(test)]
mod tests_spots;
#[cfg(test)]
mod tests_sync;
+4
View File
@@ -160,6 +160,8 @@ struct PrefsFile {
forget_local_days: Option<u32>,
/// Slideshow playback settings.
slideshow: crate::slideshow::PlaybackSettings,
/// Slideshow slide layout (margins, options, backdrop).
slide_layout: crate::slide::SlideSettings,
}
fn presets_json(s: &Session) -> String {
@@ -343,6 +345,7 @@ impl Session {
self.import_defaults = prefs.import;
self.cache_mb = prefs.cache_mb;
self.slideshow = prefs.slideshow.sanitized();
self.slide_layout = prefs.slide_layout.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 {
@@ -580,6 +583,7 @@ impl Session {
smart_previews_dir: self.smart_previews_dir.as_ref().map(|d| d.to_string_lossy().to_string()),
forget_local_days: Some(self.forget_local_days),
slideshow: self.slideshow.clone(),
slide_layout: self.slide_layout.clone(),
})
.unwrap_or_default();
let Some(lib) = self.library.as_mut() else { return Ok(()) };
+30 -1
View File
@@ -676,6 +676,8 @@ pub struct RenderJob {
pub view_cache: Option<(Arc<PreviewCache>, Hash128)>,
/// Render for this monitor profile ([`Self::with_display`]).
pub display: Option<Arc<crate::display::Display>>,
/// Compose the render into a slideshow slide ([`Self::with_slide`]).
pub slide: Option<Arc<crate::slide::SlideCompose>>,
}
pub struct RenderResult {
@@ -744,7 +746,8 @@ impl RenderJob {
/// Gets its own result key. Thumbnail jobs (rendered-thumbnail cache) are left as they are:
/// [`crate::display::present`] converts their sRGB results.
pub fn with_display(mut self, display: Option<Arc<crate::display::Display>>) -> Self {
if self.cache.is_some() || self.request.depth != lightcraft_pipeline::OutputDepth::U8 {
// (a slide is composed in sRGB, backdrop colours included; the frontend presents it)
if self.cache.is_some() || self.slide.is_some() || self.request.depth != lightcraft_pipeline::OutputDepth::U8 {
return self;
}
let k = |d: &Option<Arc<crate::display::Display>>| d.as_ref().map_or(0, |d| d.id()).wrapping_mul(0xd6e8_feb8_6659_fd93);
@@ -754,7 +757,30 @@ impl RenderJob {
self
}
/// Compose the render into a slideshow slide (layout, options, backdrop): the result is the
/// slide, not the photo. Gets its own result key; the photo's cached view preview stays the
/// photo. The slide stays sRGB ([`Self::with_display`] leaves it alone).
pub fn with_slide(mut self, slide: Arc<crate::slide::SlideCompose>) -> Self {
let k = |s: &Option<Arc<crate::slide::SlideCompose>>| s.as_ref().map_or(0, |s| s.key()).wrapping_mul(0xa24b_aed4_963e_e407);
self.key ^= k(&self.slide);
self.key ^= k(&Some(slide.clone()));
self.slide = Some(slide);
self
}
/// [`Self::run_photo`], composed into its slide when this is a slide job.
pub fn run(self) -> RenderResult {
let slide = self.slide.clone();
let mut r = self.run_photo();
if let (Some(slide), Ok(rendered)) = (slide, r.rendered.as_mut()) {
rendered.image = slide.compose(&rendered.image);
rendered.deep = None;
}
r
}
/// The photo's render (without slide composition).
fn run_photo(self) -> RenderResult {
if let Some((cache, key)) = &self.cache
&& let Some(img) = cache.get_at(self.cache_generation, *key)
{
@@ -1020,6 +1046,7 @@ impl crate::Session {
stages: None,
view_cache: None,
display: None,
slide: None,
})
}
@@ -1071,6 +1098,7 @@ impl crate::Session {
stages: None,
view_cache: None,
display: None,
slide: None,
})
}
@@ -1117,6 +1145,7 @@ impl crate::Session {
stages: None,
view_cache: None,
display: None,
slide: None,
})
}
+140
View File
@@ -0,0 +1,140 @@
//! Slideshow slides in the engine: the library's slide layout ([`SlideSettings`], Lightroom
//! Classic's Layout, Options and Backdrop panels; the `slideshow.layout` command) and render jobs
//! that compose a photo into its slide ([`SlideCompose`], [`crate::media::RenderJob::with_slide`]).
//!
//! The composer itself is `lightcraft-slideshow` (no UI, no catalog): playback, the Settings
//! preview and `slideshow.preview` all compose through it, so they draw the same pixels.
use std::sync::{Arc, Mutex, PoisonError};
use lightcraft_catalog::PhotoId;
use lightcraft_raster::Rgba8;
pub use lightcraft_slideshow::{Aspect, Layout, SlideSettings};
use crate::media::RenderJob;
/// The last rendered background image: (its render key, the image). Shared by the slide jobs of
/// a session, so the backdrop photo is rendered once, not once per slide.
pub type BackdropCache = Mutex<Option<(u64, Arc<Rgba8>)>>;
/// The background image of a slide: a library photo's render job and the shared cache it goes to.
#[derive(Clone)]
pub struct Backdrop {
pub photo: PhotoId,
job: Box<RenderJob>,
cache: Arc<BackdropCache>,
}
impl Backdrop {
/// The rendered background image (from the cache when it holds this render; otherwise
/// rendered now, on the calling thread). `None` when it can't be rendered (the slide then
/// shows the backdrop colour alone).
pub fn image(&self) -> Option<Arc<Rgba8>> {
let key = self.job.key;
if let Some((k, img)) = self.cache.lock().unwrap_or_else(PoisonError::into_inner).as_ref()
&& *k == key
{
return Some(img.clone());
}
let img = Arc::new((*self.job).clone().run().rendered.ok()?.image);
*self.cache.lock().unwrap_or_else(PoisonError::into_inner) = Some((key, img.clone()));
Some(img)
}
}
/// How to turn a photo's render into its slide: the layout, the slide's size in pixels and the
/// background image, if any.
#[derive(Clone)]
pub struct SlideCompose {
pub settings: SlideSettings,
pub width: usize,
pub height: usize,
pub backdrop: Option<Backdrop>,
}
impl SlideCompose {
/// What makes the slide's pixels differ (for render keys).
pub fn key(&self) -> u64 {
self.settings.pixel_key()
^ (self.width as u64).wrapping_mul(0x9e37_79b9_7f4a_7c15)
^ (self.height as u64).wrapping_mul(0xc2b2_ae3d_27d4_eb4f)
^ self.backdrop.as_ref().map_or(0, |b| b.job.key.rotate_left(29))
}
/// The slide showing `photo` (rendered at [`lightcraft_slideshow::photo_size`], or any size).
pub fn compose(&self, photo: &Rgba8) -> Rgba8 {
let bg = self.backdrop.as_ref().and_then(Backdrop::image);
lightcraft_slideshow::compose(photo, bg.as_deref(), &self.settings, self.width, self.height)
}
}
impl crate::Session {
/// The aspect (width / height) photo `id` shows at: cropped, straightened, lens-corrected.
pub fn slide_photo_aspect(&self, id: PhotoId) -> Option<f64> {
let p = self.catalog.photo(id)?;
let frame = lightcraft_pipeline::geometry::Frame::with_lens(
p.width.max(1) as usize,
p.height.max(1) as usize,
&p.develop,
true,
p.embedded_lens.as_ref(),
);
let a = frame.aspect();
Some(if a.is_finite() && a > 0.0 { a } else { 1.5 })
}
/// The composition of `settings` at `width × height`, with its background image's render job
/// (the photo `settings.background_image` names, when it is in the catalog).
pub fn slide_compose(&mut self, settings: &SlideSettings, width: usize, height: usize) -> Arc<SlideCompose> {
let settings = settings.clone().sanitized();
let backdrop = settings.background_image.filter(|_| settings.background_opacity > 0.0).and_then(|bid| {
let id = PhotoId(bid);
let aspect = self.slide_photo_aspect(id)?;
let fill =
SlideSettings { zoom_to_fill: true, margin_left: 0.0, margin_right: 0.0, margin_top: 0.0, margin_bottom: 0.0, ..settings.clone() };
let (w, h) = lightcraft_slideshow::photo_size(&fill, aspect, width, height);
let job = self.render_job(id, w, h, false, true)?;
Some(Backdrop { photo: id, job: Box::new(job), cache: self.slide_backdrop.clone() })
});
Arc::new(SlideCompose { settings, width, height, backdrop })
}
/// A render job whose result is photo `id`'s slide at `width × height` with the library's
/// slide layout: the photo is rendered at the size its cell needs (its long edge at most
/// `photo_edge`, when given), then composed on the worker. The photo's render is kept as its
/// view preview, like the loupe's.
pub fn slide_job(&mut self, id: PhotoId, width: usize, height: usize, photo_edge: Option<usize>) -> Option<RenderJob> {
let settings = self.slide_layout.clone();
self.slide_job_with(&settings, id, width, height, photo_edge, true)
}
/// [`Self::slide_job`] with `settings` in place of the library's layout; `view` keeps the
/// photo's render as its view preview.
pub fn slide_job_with(
&mut self,
settings: &SlideSettings,
id: PhotoId,
width: usize,
height: usize,
photo_edge: Option<usize>,
view: bool,
) -> Option<RenderJob> {
let aspect = self.slide_photo_aspect(id)?;
let (mut w, mut h) = lightcraft_slideshow::photo_size(settings, aspect, width, height);
if let Some(edge) = photo_edge.filter(|e| *e > 0 && w.max(h) > *e) {
let k = edge as f64 / w.max(h) as f64;
(w, h) = (((w as f64 * k).round() as usize).max(1), ((h as f64 * k).round() as usize).max(1));
}
let job = if view { self.loupe_job(id, w.max(8), h.max(8), true)? } else { self.render_job(id, w.max(8), h.max(8), false, true)? };
let compose = self.slide_compose(settings, width, height);
Some(job.with_slide(compose))
}
/// Photo `id`'s slide at `width × height` with `settings`, rendered now on this thread.
pub fn slide_now(&mut self, settings: &SlideSettings, id: PhotoId, width: usize, height: usize) -> Result<Rgba8, String> {
let job = self.slide_job_with(settings, id, width, height, None, false).ok_or("no such photo")?;
let r = job.run();
self.accept(&r);
r.rendered.map(|r| r.image)
}
}
+139
View File
@@ -0,0 +1,139 @@
//! 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]);
}
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "lightcraft-slideshow"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "LightCraft slideshow slides: layout, options and backdrop settings and the CPU slide composer (L1)"
[lints]
workspace = true
[dependencies]
lightcraft-raster = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
+513
View File
@@ -0,0 +1,513 @@
//! The slide composer: [`layout`] (where the image cell, the photo, its frame and shadow go) and
//! [`compose`] (the slide's pixels, on the CPU).
//!
//! Drawing order: backdrop colour → background image (at its opacity, filling the slide) → colour
//! wash → cast shadow → stroke border → photo. The stroke is drawn around the outside of the
//! photo (the photo keeps its size); the shadow falls from the photo with its stroke.
use lightcraft_raster::blur::gaussian;
use lightcraft_raster::resample::{Filter, resize};
use lightcraft_raster::{Image, Plane, Rgba8, par_rows};
use crate::settings::{MAX_SIDE, SlideSettings};
/// A rectangle in slide pixels (`x0 ≤ x1`, `y0 ≤ y1`).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Rect {
pub x0: f64,
pub y0: f64,
pub x1: f64,
pub y1: f64,
}
impl Rect {
pub fn width(&self) -> f64 {
self.x1 - self.x0
}
pub fn height(&self) -> f64 {
self.y1 - self.y0
}
fn intersect(&self, o: &Rect) -> Rect {
let (x0, y0) = (self.x0.max(o.x0), self.y0.max(o.y0));
Rect { x0, y0, x1: self.x1.min(o.x1).max(x0), y1: self.y1.min(o.y1).max(y0) }
}
fn expand(&self, d: f64) -> Rect {
Rect { x0: self.x0 - d, y0: self.y0 - d, x1: self.x1 + d, y1: self.y1 + d }
}
fn shift(&self, dx: f64, dy: f64) -> Rect {
Rect { x0: self.x0 + dx, y0: self.y0 + dy, x1: self.x1 + dx, y1: self.y1 + dy }
}
/// Whole pixel bounds clipped to `w × h`: (x0, y0, x1, y1).
fn pixels(&self, w: usize, h: usize) -> (usize, usize, usize, usize) {
let c = |v: f64, max: usize| if v.is_finite() { v.round().clamp(0.0, max as f64) as usize } else { 0 };
let (x0, y0) = (c(self.x0, w), c(self.y0, h));
(x0, y0, c(self.x1, w).max(x0), c(self.y1, h).max(y0))
}
}
/// Where a slide's parts go, in slide pixels.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Layout {
/// The slide.
pub slide: Rect,
/// The image cell: the slide inside its margins.
pub cell: Rect,
/// The whole photo, scaled (with zoom to fill it extends past the cell).
pub photo: Rect,
/// The part of the photo that shows (the photo inside the cell).
pub visible: Rect,
/// The visible photo with its stroke border (the visible photo when there is none).
pub frame: Rect,
/// The rectangle that casts the shadow (the frame, offset), and the blur's sigma in pixels.
pub shadow: Option<(Rect, f64)>,
}
/// A usable aspect: a non-finite, zero or absurd one takes `fallback`.
fn sane_aspect(a: f64, fallback: f64) -> f64 {
if a.is_finite() && a > 1e-3 && a < 1e3 { a } else { fallback }
}
/// The slide size `out_w × out_h` limited to [`MAX_SIDE`] (proportionally) and at least 1 × 1.
fn slide_size(out_w: usize, out_h: usize) -> (usize, usize) {
let (w, h) = (out_w.max(1), out_h.max(1));
let long = w.max(h);
if long <= MAX_SIDE {
return (w, h);
}
let k = MAX_SIDE as f64 / long as f64;
(((w as f64 * k).round() as usize).clamp(1, MAX_SIDE), ((h as f64 * k).round() as usize).clamp(1, MAX_SIDE))
}
/// The layout of a `out_w × out_h` slide showing a photo of aspect `photo_aspect` (width /
/// height; a bad one fills the cell).
pub fn layout(s: &SlideSettings, photo_aspect: f64, out_w: usize, out_h: usize) -> Layout {
let s = s.clone().sanitized();
let (w, h) = slide_size(out_w, out_h);
let (wf, hf) = (w as f64, h as f64);
let short = wf.min(hf);
// margins in pixels; when they leave no room (a tiny slide), they shrink to leave one pixel
let fit = |a: f64, b: f64, room: f64| {
let (a, b) = ((a * short).round(), (b * short).round());
if a + b <= room - 1.0 {
(a, b)
} else {
let k = ((room - 1.0).max(0.0)) / (a + b).max(1e-9);
((a * k).floor(), (b * k).floor())
}
};
let (ml, mr) = fit(s.margin_left, s.margin_right, wf);
let (mt, mb) = fit(s.margin_top, s.margin_bottom, hf);
let slide = Rect { x0: 0.0, y0: 0.0, x1: wf, y1: hf };
let cell = Rect { x0: ml, y0: mt, x1: wf - mr, y1: hf - mb };
let (cw, ch) = (cell.width().max(1.0), cell.height().max(1.0));
let a = sane_aspect(photo_aspect, cw / ch);
let wider = a > cw / ch;
// fit: the photo's longer side (relative to the cell) meets the cell; fill: the shorter
let (dw, dh) = if wider != s.zoom_to_fill { (cw, cw / a) } else { (ch * a, ch) };
let (dw, dh) = (dw.round().max(1.0), dh.round().max(1.0));
let x0 = (cell.x0 + (cw - dw) / 2.0).round();
let y0 = (cell.y0 + (ch - dh) / 2.0).round();
let photo = Rect { x0, y0, x1: x0 + dw, y1: y0 + dh };
let visible = photo.intersect(&cell);
let stroke = if s.stroke_border && s.stroke_width > 0.0 { (s.stroke_width * short).round().max(1.0) } else { 0.0 };
let frame = visible.expand(stroke).intersect(&slide);
let shadow = (s.cast_shadow && s.shadow_opacity > 0.0).then(|| {
let (d, t) = (s.shadow_offset * short, s.shadow_angle.to_radians());
// counter-clockwise from the right, y down on screen
(frame.shift(d * t.cos(), -d * t.sin()), s.shadow_radius * short / 2.0)
});
Layout { slide, cell, photo, visible, frame, shadow }
}
/// The size to render a photo of aspect `photo_aspect` at for a `out_w × out_h` slide: the
/// photo's whole size in the slide (with zoom to fill, larger than its cell), at most 4× the
/// slide's long side.
pub fn photo_size(s: &SlideSettings, photo_aspect: f64, out_w: usize, out_h: usize) -> (usize, usize) {
let l = layout(s, photo_aspect, out_w, out_h);
let (w, h) = (l.photo.width().max(1.0), l.photo.height().max(1.0));
let cap = 4.0 * l.slide.width().max(l.slide.height());
let k = (cap / w.max(h)).min(1.0);
(((w * k).round() as usize).max(1), ((h * k).round() as usize).max(1))
}
fn mix(a: u8, b: u8, t: f32) -> u8 {
(a as f32 + (b as f32 - a as f32) * t).round().clamp(0.0, 255.0) as u8
}
fn mix_px(dst: &mut [u8; 4], src: [u8; 3], t: f32) {
for (d, s) in dst.iter_mut().zip(src) {
*d = mix(*d, s, t);
}
dst[3] = 255;
}
/// Draw `src` scaled to `dest` (slide pixels), only where it meets `clip`, at `opacity`.
/// Only the part of `src` that shows is resampled (a zoomed-to-fill panorama stays cheap).
fn draw_image(out: &mut Rgba8, src: &Rgba8, dest: &Rect, clip: &Rect, opacity: f32) {
if src.width == 0 || src.height == 0 || src.data.len() != src.width * src.height || opacity <= 0.0 {
return;
}
let (w, h) = (out.width, out.height);
let (vx0, vy0, vx1, vy1) = dest.intersect(clip).pixels(w, h);
let (vw, vh) = (vx1 - vx0, vy1 - vy0);
if vw == 0 || vh == 0 || dest.width() <= 0.0 || dest.height() <= 0.0 {
return;
}
// the source pixels behind the visible part
let (sx, sy) = (src.width as f64 / dest.width(), src.height as f64 / dest.height());
let to_src = |v: f64, origin: f64, k: f64, max: usize| ((v - origin) * k).clamp(0.0, max as f64);
let (s0x, s1x) = (to_src(vx0 as f64, dest.x0, sx, src.width).floor(), to_src(vx1 as f64, dest.x0, sx, src.width).ceil());
let (s0y, s1y) = (to_src(vy0 as f64, dest.y0, sy, src.height).floor(), to_src(vy1 as f64, dest.y0, sy, src.height).ceil());
let (cx, cy) = (s0x as usize, s0y as usize);
let (cw, ch) = (((s1x - s0x) as usize).max(1), ((s1y - s0y) as usize).max(1));
let part = if (cx, cy, cw, ch) == (0, 0, src.width, src.height) { None } else { Some(src.crop(cx, cy, cw, ch)) };
let part = part.as_ref().unwrap_or(src);
let scaled: Rgba8 = if (part.width, part.height) == (vw, vh) {
part.clone()
} else {
let f = part.map(|p| [p[0] as f32, p[1] as f32, p[2] as f32, p[3] as f32]);
let filter = if vw < part.width { Filter::Lanczos3 } else { Filter::Mitchell };
resize(&f, vw, vh, filter).map(|p| [0, 1, 2, 3].map(|i| p[i].round().clamp(0.0, 255.0) as u8))
};
for (row, y) in (vy0..vy1).enumerate() {
let (Some(dst), Some(s)) = (out.data.get_mut(y * w + vx0..y * w + vx1), scaled.data.get(row * vw..(row + 1) * vw)) else {
continue;
};
for (d, p) in dst.iter_mut().zip(s) {
let a = opacity * p[3] as f32 / 255.0;
mix_px(d, [p[0], p[1], p[2]], a);
}
}
}
/// Fill `r` with `c`.
fn fill(out: &mut Rgba8, r: &Rect, c: [u8; 3]) {
let w = out.width;
let (x0, y0, x1, y1) = r.pixels(out.width, out.height);
for y in y0..y1 {
if let Some(row) = out.data.get_mut(y * w + x0..y * w + x1) {
row.fill([c[0], c[1], c[2], 255]);
}
}
}
/// A 1-D blurred step: 1 inside `[a, b)`, blurred with `sigma` (pixels), over `0..n`.
fn blurred_step(n: usize, a: f64, b: f64, sigma: f64) -> Vec<f32> {
let inside = Plane::from_fn(n, 1, |x, _| {
let c = x as f64 + 0.5;
if c >= a && c < b { 1.0 } else { 0.0 }
});
gaussian(&inside, sigma as f32).data
}
/// The slide: `photo` (the developed photo, any size: it is scaled into its cell) and the
/// backdrop image (any size; it fills the slide) composed with `s` at `out_w × out_h` pixels
/// (limited to [`MAX_SIDE`] a side, at least 1 × 1). An empty `photo` draws the backdrop alone.
pub fn compose(photo: &Rgba8, backdrop_image: Option<&Rgba8>, s: &SlideSettings, out_w: usize, out_h: usize) -> Rgba8 {
let s = s.clone().sanitized();
let (w, h) = slide_size(out_w, out_h);
let has_photo = photo.width > 0 && photo.height > 0 && photo.data.len() == photo.width * photo.height;
let aspect = if has_photo { photo.width as f64 / photo.height as f64 } else { f64::NAN };
let l = layout(&s, aspect, w, h);
let c = s.backdrop_color;
let mut out = Image::filled(w, h, [c[0], c[1], c[2], 255]);
// background image: the photo filling the slide, at its opacity
if let Some(bg) = backdrop_image.filter(|_| s.background_image.is_some() && s.background_opacity > 0.0)
&& bg.width > 0
&& bg.height > 0
{
let a = bg.width as f64 / bg.height as f64;
let fill_all = SlideSettings { zoom_to_fill: true, margin_left: 0.0, margin_right: 0.0, margin_top: 0.0, margin_bottom: 0.0, ..s.clone() };
let bl = layout(&fill_all, a, w, h);
draw_image(&mut out, bg, &bl.photo, &bl.slide, s.background_opacity as f32);
}
// colour wash: full wash colour at the side the angle points to, none at the opposite side
if s.color_wash && s.wash_opacity > 0.0 {
let t = s.wash_angle.to_radians();
let (dx, dy) = (t.cos(), -t.sin());
let (cx, cy) = (w as f64 / 2.0, h as f64 / 2.0);
let extent = (cx * dx.abs() + cy * dy.abs()).max(1e-9);
let (wc, op) = (s.wash_color, s.wash_opacity);
par_rows(&mut out.data, w, |y, row| {
let py = y as f64 + 0.5 - cy;
for (x, p) in row.iter_mut().enumerate() {
let proj = (x as f64 + 0.5 - cx) * dx + py * dy;
let k = ((proj / extent + 1.0) / 2.0).clamp(0.0, 1.0);
mix_px(p, wc, (k * op) as f32);
}
});
}
// cast shadow: a blurred rectangle (separable: the product of two blurred steps)
if let Some((r, sigma)) = l.shadow {
let op = s.shadow_opacity as f32;
let fx = blurred_step(w, r.x0, r.x1, sigma);
let fy = blurred_step(h, r.y0, r.y1, sigma);
par_rows(&mut out.data, w, |y, row| {
let ky = fy.get(y).copied().unwrap_or(0.0);
if ky <= 1e-4 {
return;
}
for (p, kx) in row.iter_mut().zip(&fx) {
let a = op * ky * kx;
if a > 1e-4 {
mix_px(p, [0, 0, 0], a);
}
}
});
}
if l.frame != l.visible {
fill(&mut out, &l.frame, s.stroke_color);
}
if has_photo {
draw_image(&mut out, photo, &l.photo, &l.cell, 1.0);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::settings::{MARGIN_MAX, SHADOW_OFFSET_MAX, SHADOW_RADIUS_MAX, STROKE_MAX};
use serde_json::json;
fn solid(w: usize, h: usize, c: [u8; 3]) -> Rgba8 {
Image::filled(w, h, [c[0], c[1], c[2], 255])
}
fn with(p: serde_json::Value) -> SlideSettings {
SlideSettings::default().merged(&p).unwrap()
}
fn px(img: &Rgba8, x: usize, y: usize) -> [u8; 3] {
let p = img.get(x, y);
[p[0], p[1], p[2]]
}
const RED: [u8; 3] = [255, 0, 0];
const GREEN: [u8; 3] = [0, 200, 0];
#[test]
fn margins_make_the_cell_and_the_photo_fits_inside_it() {
// 1000 × 500 slide, 10 % margins (of the 500 px short side = 50 px)
let s = with(json!({"margins": 0.1}));
let l = layout(&s, 1.0, 1000, 500);
assert_eq!(l.cell, Rect { x0: 50.0, y0: 50.0, x1: 950.0, y1: 450.0 });
// a square photo fits the 900 × 400 cell's height, centred
assert_eq!(l.photo, Rect { x0: 300.0, y0: 50.0, x1: 700.0, y1: 450.0 });
assert_eq!(l.visible, l.photo);
// independent margins
let s = with(json!({"linkMargins": false, "marginLeft": 0.2, "marginTop": 0.0, "marginRight": 0.0, "marginBottom": 0.4}));
let l = layout(&s, 3.0, 1000, 500);
assert_eq!(l.cell, Rect { x0: 100.0, y0: 0.0, x1: 1000.0, y1: 300.0 });
assert_eq!((l.photo.width(), l.photo.height()), (900.0, 300.0), "3:1 fits 900 × 300 exactly");
let img = compose(&solid(64, 64, GREEN), None, &with(json!({"margins": 0.1, "backdropColor": "#ff0000"})), 1000, 500);
assert_eq!((img.width, img.height), (1000, 500));
assert_eq!(px(&img, 10, 10), RED, "margin: backdrop");
assert_eq!(px(&img, 500, 250), GREEN, "cell centre: photo");
assert_eq!(px(&img, 290, 250), RED, "beside a fitted square photo: backdrop");
assert_eq!(px(&img, 310, 60), GREEN);
}
#[test]
fn zoom_to_fill_covers_the_cell_and_crops() {
let s = with(json!({"margins": 0.1, "zoomToFill": true}));
let l = layout(&s, 1.0, 1000, 500);
assert_eq!((l.photo.width(), l.photo.height()), (900.0, 900.0), "the square photo covers the 900 × 400 cell");
assert_eq!(l.visible, l.cell, "and shows only inside it");
assert_eq!(photo_size(&s, 1.0, 1000, 500), (900, 900), "rendered whole at the covering size");
// a gradient photo: zoomed to fill, its middle rows show, its top and bottom are cut
let photo = Rgba8::from_fn(100, 100, |_, y| [(y * 2) as u8, 0, 0, 255]);
let img = compose(&photo, None, &s, 1000, 500);
assert_eq!(px(&img, 500, 10), [0, 0, 0], "margin stays the backdrop");
assert_eq!(px(&img, 60, 250), px(&img, 940, 250), "covers the cell edge to edge");
let top = img.get(500, 51)[0];
assert!(top > 40, "the photo's top rows are cropped away ({top})");
// fit draws the whole photo (its first rows near black)
let img = compose(&photo, None, &with(json!({"margins": 0.1})), 1000, 500);
assert!(img.get(500, 51)[0] < 6);
}
#[test]
fn aspect_preview_ratios() {
use crate::settings::Aspect;
let s = with(json!({"aspect": "4:3"}));
let r = s.aspect.ratio(2.0);
assert!((r - 4.0 / 3.0).abs() < 1e-12);
assert_eq!(with(json!({"aspect": "screen"})).aspect.ratio(2.0), 2.0);
assert_eq!(Aspect::parse("16:9"), Some(Aspect::Wide));
// a 4:3 slide of a 3:2 photo letterboxes top and bottom
let l = layout(&s, 1.5, 800, 600);
assert_eq!((l.photo.width(), l.photo.height(), l.photo.y0), (800.0, 533.0, 34.0));
}
#[test]
fn stroke_surrounds_the_photo_and_shadow_falls_at_its_angle() {
let s = with(json!({"margins": 0.2, "strokeBorder": true, "strokeWidth": 0.02, "strokeColor": "#ffffff"}));
let l = layout(&s, 1.0, 1000, 1000);
assert_eq!(l.visible, Rect { x0: 200.0, y0: 200.0, x1: 800.0, y1: 800.0 });
assert_eq!(l.frame, Rect { x0: 180.0, y0: 180.0, x1: 820.0, y1: 820.0 }, "20 px outside the photo");
let img = compose(&solid(10, 10, GREEN), None, &s, 1000, 1000);
assert_eq!(px(&img, 190, 500), [255, 255, 255], "stroke left");
assert_eq!(px(&img, 810, 810), [255, 255, 255], "stroke corner");
assert_eq!(px(&img, 175, 500), [0, 0, 0], "outside the stroke");
assert_eq!(px(&img, 205, 500), GREEN, "the photo keeps its size");
// shadow at −45° (down-right) by 5 %, hard edged (radius 0), opaque
let s = with(
json!({"margins": 0.2, "castShadow": true, "shadowOffset": 0.05, "shadowAngle": -45, "shadowRadius": 0, "shadowOpacity": 1, "backdropColor": "#ffffff"}),
);
let l = layout(&s, 1.0, 1000, 1000);
let (r, _) = l.shadow.unwrap();
let d = 50.0 / 2f64.sqrt();
assert!((r.x0 - (200.0 + d)).abs() < 1e-9 && (r.y0 - (200.0 + d)).abs() < 1e-9, "{r:?}");
let img = compose(&solid(10, 10, GREEN), None, &s, 1000, 1000);
assert_eq!(px(&img, 820, 820), [0, 0, 0], "shadow below right");
assert_eq!(px(&img, 180, 180), [255, 255, 255], "no shadow above left");
assert_eq!(px(&img, 850, 500), [255, 255, 255], "shadow ends at its offset");
// at 90° the shadow goes straight up; a soft radius spreads it but fades out
let s = with(
json!({"margins": 0.2, "castShadow": true, "shadowOffset": 0.05, "shadowAngle": 90, "shadowRadius": 0.04, "shadowOpacity": 0.8, "backdropColor": "#ffffff"}),
);
let img = compose(&solid(10, 10, GREEN), None, &s, 1000, 1000);
assert!(img.get(500, 170)[0] < 200, "above: in shadow");
assert_eq!(px(&img, 500, 950), [255, 255, 255], "below: none");
assert_eq!(px(&img, 500, 50), [255, 255, 255], "beyond the blur: none");
let mid = img.get(500, 151)[0];
assert!(mid > 60 && mid < 250, "a soft edge ({mid})");
}
#[test]
fn colour_wash_follows_its_angle() {
let wash = |angle: f64| {
compose(
&Rgba8::new(0, 0),
None,
&with(json!({"colorWash": true, "washColor": "#ffffff", "washOpacity": 1, "washAngle": angle})),
200,
100,
)
};
// 0°: white at the right, black at the left
let img = wash(0.0);
assert!(img.get(199, 50)[0] > 250 && img.get(0, 50)[0] < 5);
assert!((img.get(100, 50)[0] as i32 - 128).abs() < 3, "half way in the middle");
assert_eq!(img.get(150, 0), img.get(150, 99), "constant along the other axis");
// 90°: white at the top
let img = wash(90.0);
assert!(img.get(100, 0)[0] > 245 && img.get(100, 99)[0] < 10);
// 180°: white at the left
assert!(wash(180.0).get(0, 50)[0] > 250);
// −45°: bottom-right is the brightest corner
let img = wash(-45.0);
assert!(img.get(199, 99)[0] > 250 && img.get(0, 0)[0] < 5);
// opacity scales it
let img =
compose(&Rgba8::new(0, 0), None, &with(json!({"colorWash": true, "washColor": "#ffffff", "washOpacity": 0.5, "washAngle": 0})), 200, 100);
assert!((img.get(199, 50)[0] as i32 - 127).abs() < 3);
}
#[test]
fn background_image_fills_the_slide_at_its_opacity() {
let bg = solid(30, 10, [200, 100, 0]);
let s = with(json!({"backgroundImage": 5, "backgroundOpacity": 0.5, "backdropColor": "#000000", "margins": 0.25}));
let img = compose(&solid(4, 4, GREEN), Some(&bg), &s, 400, 200);
assert_eq!(px(&img, 2, 2), [100, 50, 0], "half the image over black");
assert_eq!(px(&img, 398, 198), [100, 50, 0], "fills the whole slide");
assert_eq!(px(&img, 200, 100), GREEN, "the photo on top");
// off, or no image given: the backdrop colour
let off = SlideSettings { background_image: None, ..s.clone() };
assert_eq!(px(&compose(&solid(4, 4, GREEN), Some(&bg), &off, 400, 200), 2, 2), [0, 0, 0]);
assert_eq!(px(&compose(&solid(4, 4, GREEN), None, &s, 400, 200), 2, 2), [0, 0, 0]);
}
#[test]
fn layouts_are_resolution_independent() {
let s = with(
json!({"linkMargins": false, "marginLeft": 0.1, "marginTop": 0.05, "marginRight": 0.2, "marginBottom": 0.08, "strokeBorder": true, "strokeWidth": 0.01, "castShadow": true, "shadowOffset": 0.02}),
);
let a = layout(&s, 1.5, 800, 450);
let b = layout(&s, 1.5, 1600, 900);
for (ra, rb) in [(a.cell, b.cell), (a.photo, b.photo), (a.frame, b.frame), (a.shadow.unwrap().0, b.shadow.unwrap().0)] {
for (va, vb) in [(ra.x0, rb.x0), (ra.y0, rb.y0), (ra.x1, rb.x1), (ra.y1, rb.y1)] {
assert!((va * 2.0 - vb).abs() <= 2.0, "{ra:?} vs {rb:?}");
}
}
assert!((a.shadow.unwrap().1 * 2.0 - b.shadow.unwrap().1).abs() < 1e-9);
// and the pixels agree: the big slide, halved, is the small one (away from edges)
let photo = Rgba8::from_fn(60, 40, |x, y| [(x * 4) as u8, (y * 6) as u8, 90, 255]);
let small = compose(&photo, None, &s, 800, 450);
let big = compose(&photo, None, &s, 1600, 900);
for (x, y) in [(10, 10), (400, 225), (300, 100), (700, 400)] {
let (p, q) = (small.get(x, y), big.get(x * 2, y * 2));
assert!((0..3).all(|i| (p[i] as i32 - q[i] as i32).abs() < 24), "({x}, {y}): {p:?} vs {q:?}");
}
}
#[test]
fn the_default_slide_is_the_photo_fitted_on_black() {
let photo = Rgba8::from_fn(300, 200, |x, y| [x as u8, y as u8, 7, 255]);
let s = SlideSettings::default();
assert!(s.is_plain());
// a 3:2 photo on a 16:10 screen: the photo at its own pixels, black bars beside it
assert_eq!(photo_size(&s, 1.5, 320, 200), (300, 200));
let img = compose(&photo, None, &s, 320, 200);
assert_eq!(px(&img, 5, 100), [0, 0, 0]);
assert_eq!(px(&img, 314, 100), [0, 0, 0]);
for (x, y) in [(0, 0), (150, 100), (299, 199)] {
assert_eq!(img.get(x + 10, y), photo.get(x, y), "unchanged pixels at ({x}, {y})");
}
}
#[test]
fn hostile_input_never_panics() {
let wild = SlideSettings {
margin_left: f64::NAN,
margin_right: 1e300,
margin_top: -5.0,
margin_bottom: f64::INFINITY,
stroke_border: true,
stroke_width: 1e9,
cast_shadow: true,
shadow_opacity: f64::NAN,
shadow_offset: -1.0,
shadow_radius: f64::INFINITY,
shadow_angle: f64::NAN,
color_wash: true,
wash_opacity: 9.0,
wash_angle: f64::INFINITY,
background_image: Some(u64::MAX),
background_opacity: f64::NEG_INFINITY,
zoom_to_fill: true,
..SlideSettings::default()
};
let photos = [Rgba8::new(0, 0), Rgba8::new(1, 1), Rgba8::new(5000, 1), Rgba8::new(1, 3000), Rgba8 { width: 4, height: 4, data: vec![] }];
for photo in &photos {
for (w, h) in [(0, 0), (1, 1), (3, 2), (1, 400), (100_000, 2), (64, 48)] {
let img = compose(photo, Some(&Rgba8::new(0, 5)), &wild, w, h);
assert!(img.width >= 1 && img.height >= 1 && img.width <= MAX_SIDE && img.height <= MAX_SIDE);
assert_eq!(img.data.len(), img.width * img.height);
let (pw, ph) = photo_size(&wild, photo.width as f64 / photo.height.max(1) as f64, w, h);
assert!(pw >= 1 && ph >= 1 && pw <= 4 * MAX_SIDE && ph <= 4 * MAX_SIDE);
}
}
for a in [f64::NAN, 0.0, -1.0, f64::INFINITY, 1e-300, 1e300] {
let l = layout(&wild, a, 640, 480);
assert!(l.photo.width() >= 1.0 && l.photo.width().is_finite());
}
// the clamps: biggest margins still leave a cell, biggest stroke and shadow stay bounded
let s = SlideSettings {
margin_left: MARGIN_MAX,
margin_right: MARGIN_MAX,
stroke_border: true,
stroke_width: STROKE_MAX,
cast_shadow: true,
shadow_offset: SHADOW_OFFSET_MAX,
shadow_radius: SHADOW_RADIUS_MAX,
..SlideSettings::default()
};
let l = layout(&s, 1.0, 100, 100);
assert!(l.cell.width() >= 1.0, "{l:?}");
assert!(l.frame.x0 >= 0.0 && l.frame.x1 <= 100.0, "the stroke stays on the slide");
}
}
+23
View File
@@ -0,0 +1,23 @@
//! Slideshow slides for LightCraft: the settings of Lightroom Classic's Slideshow Layout, Options
//! and Backdrop panels ([`SlideSettings`]) and the CPU composer that draws one slide from a
//! rendered photo ([`compose`]).
//!
//! No UI and no catalog here: playback, the settings preview, the `slideshow.preview` command and
//! (later) slideshow export all call [`compose`], so every one of them draws the same pixels.
//!
//! **Resolution independence:** every length is relative to the slide: margins, the stroke width
//! and the shadow's offset and radius are fractions of the slide's *shorter side* (so linked
//! margins are equal in pixels, and a slide composed at two sizes has the same proportions).
//! Angles are degrees counter-clockwise from the right (−45° points down and to the right).
//!
//! **Never crash:** settings are validated by [`SlideSettings::merged`] (a wrong type is an error)
//! and clamped by [`SlideSettings::sanitized`] (NaN, ±∞, negative and huge values land in range);
//! [`compose`] accepts any size and any photo (an empty one draws the backdrop alone).
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod compose;
mod settings;
pub use compose::{Layout, Rect, compose, layout, photo_size};
pub use settings::{Aspect, MARGIN_MAX, MAX_SIDE, SHADOW_OFFSET_MAX, SHADOW_RADIUS_MAX, STROKE_MAX, SlideSettings, hex, parse_color};
+401
View File
@@ -0,0 +1,401 @@
//! [`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());
}
}
+1
View File
@@ -18,6 +18,7 @@ lightcraft-pipeline = { workspace = true }
lightcraft-catalog = { workspace = true }
web-time = { workspace = true }
lightcraft-engine = { workspace = true }
lightcraft-slideshow = { workspace = true }
lightcraft-preview = { workspace = true }
egui = { workspace = true }
serde = { workspace = true }
+27 -1
View File
@@ -2031,5 +2031,31 @@
"Downloading model": "Modell wird heruntergeladen",
"Finding faces": "Gesichter werden gesucht",
"Synchronizing folder": "Ordner wird synchronisiert",
"Looking for changes": "Änderungen werden gesucht"
"Looking for changes": "Änderungen werden gesucht",
"Playback": "Wiedergabe",
"Layout": "Layout",
"Link all margins": "Alle Ränder verknüpfen",
"Margins": "Ränder",
"Left": "Links",
"Right": "Rechts",
"Top": "Oben",
"Bottom": "Unten",
"Margins are a share of the slide's shorter side, so slides look the same on any screen.": "Ränder sind ein Anteil der kürzeren Seite der Folie, damit Folien auf jedem Bildschirm gleich aussehen.",
"Show guides": "Hilfslinien anzeigen",
"Aspect preview": "Seitenverhältnis-Vorschau",
"Zoom to fill frame": "Zoomen, um Rahmen zu füllen",
"Stroke border": "Konturlinie",
"Colour": "Farbe",
"Cast shadow": "Schlagschatten",
"Offset": "Versatz",
"Backdrop": "Hintergrund",
"Background colour": "Hintergrundfarbe",
"Colour wash": "Farbverlauf",
"Background image": "Hintergrundbild",
"(not in the library)": "(nicht in der Bibliothek)",
"Use selected photo": "Ausgewähltes Foto verwenden",
"Select a photo, then turn this on to fill the slide behind every photo with it.": "Wählen Sie ein Foto aus und schalten Sie dies ein, um die Folie hinter jedem Foto damit zu füllen.",
"Reset layout": "Layout zurücksetzen",
"Select a photo to preview its slide": "Wählen Sie ein Foto, um seine Folie in der Vorschau zu sehen",
"The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "Das ausgewählte Foto als Folie. Die Wiedergabe füllt den Bildschirm; die Seitenverhältnis-Vorschau ändert nur diese Vorschau."
}
+27 -1
View File
@@ -1876,5 +1876,31 @@
"Downloading model": "Descargando modelo",
"Finding faces": "Buscando caras",
"Synchronizing folder": "Sincronizando carpeta",
"Looking for changes": "Buscando cambios"
"Looking for changes": "Buscando cambios",
"Playback": "Reproducción",
"Layout": "Diseño",
"Link all margins": "Vincular todos los márgenes",
"Margins": "Márgenes",
"Left": "Izquierda",
"Right": "Derecha",
"Top": "Arriba",
"Bottom": "Abajo",
"Margins are a share of the slide's shorter side, so slides look the same on any screen.": "Los márgenes son una parte del lado más corto de la diapositiva, así que las diapositivas se ven igual en cualquier pantalla.",
"Show guides": "Mostrar guías",
"Aspect preview": "Vista previa de proporción",
"Zoom to fill frame": "Ampliar para llenar el marco",
"Stroke border": "Borde de trazo",
"Colour": "Color",
"Cast shadow": "Proyectar sombra",
"Offset": "Desplazamiento",
"Backdrop": "Fondo",
"Background colour": "Color de fondo",
"Colour wash": "Degradado de color",
"Background image": "Imagen de fondo",
"(not in the library)": "(no está en la biblioteca)",
"Use selected photo": "Usar la foto seleccionada",
"Select a photo, then turn this on to fill the slide behind every photo with it.": "Selecciona una foto y activa esta opción para llenar con ella el fondo detrás de cada foto.",
"Reset layout": "Restablecer diseño",
"Select a photo to preview its slide": "Selecciona una foto para previsualizar su diapositiva",
"The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "La foto seleccionada como diapositiva. La reproducción llena la pantalla; la vista previa de proporción solo cambia esta vista previa."
}
+27 -1
View File
@@ -1876,5 +1876,31 @@
"Downloading model": "Téléchargement du modèle",
"Finding faces": "Recherche des visages",
"Synchronizing folder": "Synchronisation du dossier",
"Looking for changes": "Recherche des modifications"
"Looking for changes": "Recherche des modifications",
"Playback": "Lecture",
"Layout": "Disposition",
"Link all margins": "Lier toutes les marges",
"Margins": "Marges",
"Left": "Gauche",
"Right": "Droite",
"Top": "Haut",
"Bottom": "Bas",
"Margins are a share of the slide's shorter side, so slides look the same on any screen.": "Les marges sont une part du petit côté de la diapositive : les diapositives sont identiques sur tous les écrans.",
"Show guides": "Afficher les repères",
"Aspect preview": "Aperçu des proportions",
"Zoom to fill frame": "Zoom pour remplir le cadre",
"Stroke border": "Contour",
"Colour": "Couleur",
"Cast shadow": "Ombre portée",
"Offset": "Décalage",
"Backdrop": "Toile de fond",
"Background colour": "Couleur d’arrière-plan",
"Colour wash": "Dégradé de couleur",
"Background image": "Image d’arrière-plan",
"(not in the library)": "(absente de la bibliothèque)",
"Use selected photo": "Utiliser la photo sélectionnée",
"Select a photo, then turn this on to fill the slide behind every photo with it.": "Sélectionnez une photo, puis activez cette option pour en remplir le fond derrière chaque photo.",
"Reset layout": "Réinitialiser la disposition",
"Select a photo to preview its slide": "Sélectionnez une photo pour prévisualiser sa diapositive",
"The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "La photo sélectionnée en diapositive. La lecture remplit l’écran ; l’aperçu des proportions ne change que cet aperçu."
}
+27 -1
View File
@@ -1878,5 +1878,31 @@
"Downloading model": "モデルをダウンロード中",
"Finding faces": "顔を検索中",
"Synchronizing folder": "フォルダを同期中",
"Looking for changes": "変更を確認中"
"Looking for changes": "変更を確認中",
"Playback": "再生",
"Layout": "レイアウト",
"Link all margins": "すべての余白をリンク",
"Margins": "余白",
"Left": "左",
"Right": "右",
"Top": "上",
"Bottom": "下",
"Margins are a share of the slide's shorter side, so slides look the same on any screen.": "余白はスライドの短辺に対する割合なので、どの画面でもスライドは同じように見えます。",
"Show guides": "ガイドを表示",
"Aspect preview": "縦横比プレビュー",
"Zoom to fill frame": "ズームして枠を埋める",
"Stroke border": "境界線",
"Colour": "カラー",
"Cast shadow": "ドロップシャドウ",
"Offset": "オフセット",
"Backdrop": "背景",
"Background colour": "背景色",
"Colour wash": "カラーウォッシュ",
"Background image": "背景画像",
"(not in the library)": "(ライブラリにありません)",
"Use selected photo": "選択した写真を使用",
"Select a photo, then turn this on to fill the slide behind every photo with it.": "写真を選択してからこれをオンにすると、各写真の背後をその写真で埋めます。",
"Reset layout": "レイアウトをリセット",
"Select a photo to preview its slide": "写真を選択するとスライドをプレビューできます",
"The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "選択した写真をスライドとして表示します。再生では画面全体に表示され、縦横比プレビューはこのプレビューだけを変更します。"
}
+27 -1
View File
@@ -1876,5 +1876,31 @@
"Downloading model": "Baixando modelo",
"Finding faces": "Procurando rostos",
"Synchronizing folder": "Sincronizando pasta",
"Looking for changes": "Procurando alterações"
"Looking for changes": "Procurando alterações",
"Playback": "Reprodução",
"Layout": "Layout",
"Link all margins": "Vincular todas as margens",
"Margins": "Margens",
"Left": "Esquerda",
"Right": "Direita",
"Top": "Superior",
"Bottom": "Inferior",
"Margins are a share of the slide's shorter side, so slides look the same on any screen.": "As margens são uma parte do lado menor do slide, então os slides ficam iguais em qualquer tela.",
"Show guides": "Mostrar guias",
"Aspect preview": "Visualização da proporção",
"Zoom to fill frame": "Zoom para preencher o quadro",
"Stroke border": "Borda",
"Colour": "Cor",
"Cast shadow": "Projetar sombra",
"Offset": "Deslocamento",
"Backdrop": "Fundo",
"Background colour": "Cor de fundo",
"Colour wash": "Degradê de cor",
"Background image": "Imagem de fundo",
"(not in the library)": "(não está na biblioteca)",
"Use selected photo": "Usar a foto selecionada",
"Select a photo, then turn this on to fill the slide behind every photo with it.": "Selecione uma foto e ative esta opção para preencher com ela o fundo atrás de cada foto.",
"Reset layout": "Redefinir layout",
"Select a photo to preview its slide": "Selecione uma foto para visualizar o slide dela",
"The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "A foto selecionada como slide. A reprodução preenche a tela; a visualização da proporção muda só esta visualização."
}
+27 -1
View File
@@ -1590,5 +1590,31 @@
"Finding faces": "Поиск лиц",
"Quit Anyway": "Всё равно выйти",
"Synchronizing folder": "Синхронизация папки",
"Looking for changes": "Поиск изменений"
"Looking for changes": "Поиск изменений",
"Playback": "Воспроизведение",
"Layout": "Макет",
"Link all margins": "Связать все поля",
"Margins": "Поля",
"Left": "Слева",
"Right": "Справа",
"Top": "Сверху",
"Bottom": "Снизу",
"Margins are a share of the slide's shorter side, so slides look the same on any screen.": "Поля задаются долей короткой стороны слайда, поэтому слайды выглядят одинаково на любом экране.",
"Show guides": "Показывать направляющие",
"Aspect preview": "Предпросмотр пропорций",
"Zoom to fill frame": "Масштабировать по рамке",
"Stroke border": "Обводка",
"Colour": "Цвет",
"Cast shadow": "Отбрасывать тень",
"Offset": "Смещение",
"Backdrop": "Фон",
"Background colour": "Цвет фона",
"Colour wash": "Цветовой градиент",
"Background image": "Фоновое изображение",
"(not in the library)": "(нет в библиотеке)",
"Use selected photo": "Использовать выбранное фото",
"Select a photo, then turn this on to fill the slide behind every photo with it.": "Выберите фото и включите этот параметр, чтобы заполнить им фон за каждым фото.",
"Reset layout": "Сбросить макет",
"Select a photo to preview its slide": "Выберите фото, чтобы увидеть его слайд",
"The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "Выбранное фото в виде слайда. При воспроизведении слайд заполняет экран; предпросмотр пропорций меняет только этот предпросмотр."
}
+27 -1
View File
@@ -2070,5 +2070,31 @@
"Downloading model": "Завантаження моделі",
"Finding faces": "Пошук облич",
"Synchronizing folder": "Синхронізація папки",
"Looking for changes": "Пошук змін"
"Looking for changes": "Пошук змін",
"Playback": "Відтворення",
"Layout": "Макет",
"Link all margins": "Зв’язати всі поля",
"Margins": "Поля",
"Left": "Ліворуч",
"Right": "Праворуч",
"Top": "Угорі",
"Bottom": "Унизу",
"Margins are a share of the slide's shorter side, so slides look the same on any screen.": "Поля задаються часткою короткої сторони слайда, тож слайди виглядають однаково на будь-якому екрані.",
"Show guides": "Показувати напрямні",
"Aspect preview": "Попередній перегляд пропорцій",
"Zoom to fill frame": "Масштабувати за рамкою",
"Stroke border": "Обведення",
"Colour": "Колір",
"Cast shadow": "Відкидати тінь",
"Offset": "Зсув",
"Backdrop": "Тло",
"Background colour": "Колір тла",
"Colour wash": "Кольоровий градієнт",
"Background image": "Фонове зображення",
"(not in the library)": "(немає в бібліотеці)",
"Use selected photo": "Використати вибране фото",
"Select a photo, then turn this on to fill the slide behind every photo with it.": "Виберіть фото й увімкніть цей параметр, щоб заповнити ним тло за кожним фото.",
"Reset layout": "Скинути макет",
"Select a photo to preview its slide": "Виберіть фото, щоб переглянути його слайд",
"The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "Вибране фото як слайд. Під час відтворення слайд заповнює екран; попередній перегляд пропорцій змінює лише цей перегляд."
}
+27 -1
View File
@@ -1876,5 +1876,31 @@
"Downloading model": "正在下载模型",
"Finding faces": "正在查找人脸",
"Synchronizing folder": "正在同步文件夹",
"Looking for changes": "正在查找更改"
"Looking for changes": "正在查找更改",
"Playback": "播放",
"Layout": "布局",
"Link all margins": "链接所有边距",
"Margins": "边距",
"Left": "左",
"Right": "右",
"Top": "上",
"Bottom": "下",
"Margins are a share of the slide's shorter side, so slides look the same on any screen.": "边距按幻灯片短边的比例设置,因此幻灯片在任何屏幕上看起来都一样。",
"Show guides": "显示参考线",
"Aspect preview": "长宽比预览",
"Zoom to fill frame": "缩放以填充框架",
"Stroke border": "描边",
"Colour": "颜色",
"Cast shadow": "投影",
"Offset": "偏移",
"Backdrop": "背景",
"Background colour": "背景颜色",
"Colour wash": "颜色渐变",
"Background image": "背景图像",
"(not in the library)": "(不在图库中)",
"Use selected photo": "使用所选照片",
"Select a photo, then turn this on to fill the slide behind every photo with it.": "选择一张照片,然后打开此项,用它填充每张照片后面的幻灯片背景。",
"Reset layout": "重置布局",
"Select a photo to preview its slide": "选择一张照片以预览其幻灯片",
"The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "将所选照片显示为幻灯片。播放时会填满屏幕;长宽比预览只改变此预览。"
}
+27 -1
View File
@@ -1876,5 +1876,31 @@
"Downloading model": "正在下載模型",
"Finding faces": "正在尋找臉孔",
"Synchronizing folder": "正在同步資料夾",
"Looking for changes": "正在尋找變更"
"Looking for changes": "正在尋找變更",
"Playback": "播放",
"Layout": "版面",
"Link all margins": "連結所有邊界",
"Margins": "邊界",
"Left": "左",
"Right": "右",
"Top": "上",
"Bottom": "下",
"Margins are a share of the slide's shorter side, so slides look the same on any screen.": "邊界以投影片短邊的比例設定,因此投影片在任何螢幕上看起來都一樣。",
"Show guides": "顯示參考線",
"Aspect preview": "長寬比預覽",
"Zoom to fill frame": "縮放以填滿框架",
"Stroke border": "描邊",
"Colour": "顏色",
"Cast shadow": "投射陰影",
"Offset": "偏移",
"Backdrop": "背景",
"Background colour": "背景顏色",
"Colour wash": "顏色漸層",
"Background image": "背景影像",
"(not in the library)": "(不在圖庫中)",
"Use selected photo": "使用所選照片",
"Select a photo, then turn this on to fill the slide behind every photo with it.": "選取一張照片,然後開啟此項,以它填滿每張照片後方的投影片背景。",
"Reset layout": "重設版面",
"Select a photo to preview its slide": "選取一張照片以預覽其投影片",
"The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "將所選照片顯示為投影片。播放時會填滿螢幕;長寬比預覽只會改變此預覽。"
}
+1
View File
@@ -27,6 +27,7 @@ pub mod right;
pub mod rules_editor;
pub mod second;
pub mod settings;
pub mod slide_layout;
pub mod slideshow;
pub mod strip;
pub mod topbar;
+26 -1
View File
@@ -68,7 +68,7 @@ pub(super) fn heading(ui: &mut egui::Ui, t: &Tokens, text: &str) {
ui.label(RichText::new(crate::i18n::tr(text)).font(t.semibold(12.5)).color(t.text));
}
fn row<R>(ui: &mut egui::Ui, t: &Tokens, label: &str, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
pub(super) fn row<R>(ui: &mut egui::Ui, t: &Tokens, label: &str, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
ui.horizontal(|ui| {
ui.allocate_ui_with_layout(egui::vec2(LABEL_W, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.set_min_width(LABEL_W);
@@ -642,7 +642,32 @@ fn duration_slider(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, key: &s
}
}
/// Width of the Slideshow tab's settings column (the slide preview is beside it).
const SLIDESHOW_COLUMN_W: f32 = 480.0;
fn slideshow_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
// the settings scroll on the left, the composed slide stays in view on the right
let top_down = egui::Layout::top_down(egui::Align::Min);
// as tall as the window allows (the dialog's title, tabs and Close button take ~260 pt)
let tall = (ui.ctx().content_rect().height() - 260.0).clamp(300.0, 760.0);
ui.horizontal_top(|ui| {
ui.allocate_ui_with_layout(egui::vec2(SLIDESHOW_COLUMN_W, tall), top_down, |ui| {
ui.set_width(SLIDESHOW_COLUMN_W);
egui::ScrollArea::vertical().id_salt("settingsSlideshowScroll").max_height(tall).auto_shrink([false, true]).show(ui, |ui| {
playback_section(app, ui, t);
ui.add_space(6.0);
super::slide_layout::sections(app, ui, t);
});
});
ui.add_space(12.0);
ui.allocate_ui_with_layout(egui::vec2(super::slide_layout::PREVIEW_W, tall), top_down, |ui| {
ui.set_width(super::slide_layout::PREVIEW_W);
super::slide_layout::preview(app, ui, t)
});
});
}
fn playback_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
use lightcraft_engine::slideshow::{FADE_MAX, SLIDE_MAX, SLIDE_MIN};
heading(ui, t, crate::i18n::tr("Playback"));
let s = app.session.slideshow.clone();
+228
View File
@@ -0,0 +1,228 @@
//! Settings ▸ Slideshow: the slide's Layout, Options and Backdrop sections (Lightroom Classic's
//! Slideshow panels of the same names) and a small live preview of the composed slide.
//!
//! Every control goes through `slideshow.layout` (validated there, saved with the library).
//! Sliders change the layout in memory while dragged (the preview follows) and save it when let
//! go. The preview is the active photo's slide rendered off the UI thread ([`Slot::SlidePreview`])
//! by the same composer playback uses; it is requested once the layout has been still for
//! [`DEBOUNCE`] seconds, so a drag doesn't queue a render per frame.
use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2};
use lightcraft_engine::slide::{Aspect, SlideSettings};
use lightcraft_slideshow::{MARGIN_MAX, SHADOW_OFFSET_MAX, SHADOW_RADIUS_MAX, STROKE_MAX};
use serde_json::{Value, json};
use super::settings::{check, choices, heading, hint, row};
use crate::LightcraftApp;
use crate::render::Slot;
use crate::theme::Tokens;
use crate::widgets::register;
/// Seconds the layout must stay unchanged before the preview is rendered again.
pub const DEBOUNCE: f64 = 0.15;
/// The preview's width in points.
pub const PREVIEW_W: f32 = 300.0;
#[derive(Clone, Copy)]
enum Unit {
/// A fraction shown as a percentage (of the slide's shorter side, or an opacity).
Percent,
Degrees,
}
fn set(app: &mut LightcraftApp, p: Value) {
let _ = app.run("slideshow.layout", p);
}
/// A layout slider (`slider:{id}`): `key`'s value, 0–`max` (percent) or −180–180° (degrees).
fn slider(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, key: &str, value: f64, max: f64, unit: Unit) {
let (mut v, range, suffix) = match unit {
Unit::Percent => (value * 100.0, 0.0..=max * 100.0, " %"),
Unit::Degrees => (value, -180.0..=180.0, "°"),
};
let r = ui.add(egui::Slider::new(&mut v, range).suffix(suffix).max_decimals(1));
register(ui.ctx(), format!("slider:{id}"), r.rect);
let value = match unit {
Unit::Percent => v / 100.0,
Unit::Degrees => v,
};
if r.changed()
&& let Ok(next) = app.session.slide_layout.merged(&json!({ key: value }))
{
app.session.slide_layout = next;
}
if r.drag_stopped() || (r.changed() && !r.dragged()) {
set(app, json!({ key: value }));
}
}
/// A colour button (`button:{id}`) for `key`.
fn colour(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, 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, json!({ key: c }));
}
}
/// A checkbox (`check:settings.{id}`) for `key`; true when it is on.
fn toggle(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, key: &str, value: bool, label: &str) -> bool {
let mut v = value;
if check(ui, &format!("settings.{id}"), &mut v, label) {
set(app, json!({ key: v }));
}
v
}
/// The Layout, Options and Backdrop sections.
pub fn sections(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
let s = app.session.slide_layout.clone();
heading(ui, t, "Layout");
let linked = toggle(app, ui, "slideshowLinkMargins", "linkMargins", s.link_margins, "Link all margins");
if linked {
row(ui, t, "Margins", |ui| slider(app, ui, "slideshowMargins", "marginLeft", s.margin_left, MARGIN_MAX, Unit::Percent));
} else {
for (id, key, label, v) in [
("slideshowMarginLeft", "marginLeft", "Left", s.margin_left),
("slideshowMarginRight", "marginRight", "Right", s.margin_right),
("slideshowMarginTop", "marginTop", "Top", s.margin_top),
("slideshowMarginBottom", "marginBottom", "Bottom", s.margin_bottom),
] {
row(ui, t, label, |ui| slider(app, ui, id, key, v, MARGIN_MAX, Unit::Percent));
}
}
hint(ui, t, "Margins are a share of the slide's shorter side, so slides look the same on any screen.");
toggle(app, ui, "slideshowGuides", "showGuides", s.show_guides, "Show guides");
row(ui, t, "Aspect preview", |ui| {
let mut a = s.aspect;
let opts = [(Aspect::Screen, "Screen"), (Aspect::Wide, "16:9"), (Aspect::Standard, "4:3")];
if choices(ui, "settingsSlideshowAspect", &opts, &mut a) {
set(app, json!({"aspect": a.id()}));
}
});
ui.add_space(6.0);
heading(ui, t, "Options");
toggle(app, ui, "slideshowZoomToFill", "zoomToFill", s.zoom_to_fill, "Zoom to fill frame");
if toggle(app, ui, "slideshowStroke", "strokeBorder", s.stroke_border, "Stroke border") {
row(ui, t, "Colour", |ui| colour(app, ui, "slideshowStrokeColor", "strokeColor", s.stroke_color));
row(ui, t, "Width", |ui| slider(app, ui, "slideshowStrokeWidth", "strokeWidth", s.stroke_width, STROKE_MAX, Unit::Percent));
}
if toggle(app, ui, "slideshowShadow", "castShadow", s.cast_shadow, "Cast shadow") {
row(ui, t, "Opacity", |ui| slider(app, ui, "slideshowShadowOpacity", "shadowOpacity", s.shadow_opacity, 1.0, Unit::Percent));
row(ui, t, "Offset", |ui| slider(app, ui, "slideshowShadowOffset", "shadowOffset", s.shadow_offset, SHADOW_OFFSET_MAX, Unit::Percent));
row(ui, t, "Radius", |ui| slider(app, ui, "slideshowShadowRadius", "shadowRadius", s.shadow_radius, SHADOW_RADIUS_MAX, Unit::Percent));
row(ui, t, "Angle", |ui| slider(app, ui, "slideshowShadowAngle", "shadowAngle", s.shadow_angle, 180.0, Unit::Degrees));
}
ui.add_space(6.0);
heading(ui, t, "Backdrop");
row(ui, t, "Background colour", |ui| colour(app, ui, "slideshowBackdropColor", "backdropColor", s.backdrop_color));
if toggle(app, ui, "slideshowWash", "colorWash", s.color_wash, "Colour wash") {
row(ui, t, "Colour", |ui| colour(app, ui, "slideshowWashColor", "washColor", s.wash_color));
row(ui, t, "Opacity", |ui| slider(app, ui, "slideshowWashOpacity", "washOpacity", s.wash_opacity, 1.0, Unit::Percent));
row(ui, t, "Angle", |ui| slider(app, ui, "slideshowWashAngle", "washAngle", s.wash_angle, 180.0, Unit::Degrees));
}
let active = app.session.active();
let mut on = s.background_image.is_some();
let r = ui.add_enabled(on || active.is_some(), egui::Checkbox::new(&mut on, crate::i18n::tr("Background image")));
register(ui.ctx(), "check:settings.slideshowBackgroundImage", r.rect);
if r.changed() {
set(app, json!({"backgroundImage": if on { active.map(|a| a.0) } else { None }}));
}
if let Some(bid) = s.background_image {
let name = app.session.catalog.photo(lightcraft_catalog::PhotoId(bid)).map(|p| p.file_name.clone());
row(ui, t, "Photo", |ui| {
let text = name.unwrap_or_else(|| crate::i18n::tr("(not in the library)").to_string());
ui.label(egui::RichText::new(text).color(t.text));
if let Some(a) = active.filter(|a| a.0 != bid)
&& crate::widgets::text_button(ui, "slideshowUseSelected", "Use selected photo", false).clicked()
{
set(app, json!({"backgroundImage": a.0}));
}
});
row(ui, t, "Opacity", |ui| slider(app, ui, "slideshowBackgroundOpacity", "backgroundOpacity", s.background_opacity, 1.0, Unit::Percent));
} else {
hint(ui, t, "Select a photo, then turn this on to fill the slide behind every photo with it.");
}
ui.add_space(4.0);
if crate::widgets::text_button(ui, "slideshowResetLayout", "Reset layout", false).clicked() {
reset(app);
}
}
/// The aspect of the screen a slideshow plays on (the monitor, else the window).
fn screen_aspect(ctx: &egui::Context) -> f64 {
let monitor = ctx.input(|i| i.viewport().monitor_size).filter(|m| m.x > 0.0 && m.y > 0.0);
let r = monitor.unwrap_or_else(|| ctx.content_rect().size());
if r.y > 0.0 { (r.x / r.y) as f64 } else { 1.6 }
}
/// The small composed slide of the active photo, with the margin guides when they are on.
pub fn preview(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
heading(ui, t, "Preview");
let s = app.session.slide_layout.clone();
let ratio = s.aspect.ratio(screen_aspect(ui.ctx())) as f32;
let h = (PREVIEW_W / ratio).clamp(80.0, 320.0);
let (rect, _) = ui.allocate_exact_size(vec2(PREVIEW_W, h), Sense::hover());
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);
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));
return;
};
let ppp = ui.ctx().pixels_per_point();
let (pw, ph) = (((PREVIEW_W * ppp).round() as usize).max(16), ((h * ppp).round() as usize).max(16));
let key = s.pixel_key() ^ id.0.rotate_left(17) ^ ((pw as u64) << 32 | ph as u64);
let now = ui.input(|i| i.time);
let mem = egui::Id::new("settingsSlideshowPreviewKey");
let (last, since) = ui.ctx().data(|d| d.get_temp::<(u64, f64)>(mem)).unwrap_or((0, f64::NEG_INFINITY));
let current = app.renderer.textures.get(&Slot::SlidePreview).filter(|tex| tex.photo == id);
if last != key {
ui.ctx().data_mut(|d| d.insert_temp(mem, (key, now)));
}
let still = if last == key { now - since } else { 0.0 };
// the first preview (or another photo's) at once, a changing layout once it has settled
if current.is_none() || still >= DEBOUNCE {
let side = super::detail::texture_side(ui.ctx());
let edge = app.ui.settings.loupe_edge(pw.max(ph) as f32, usize::MAX, side);
if let Some(job) = app.session.slide_job_with(&s, id, pw, ph, Some(edge), false) {
app.renderer.request(Slot::SlidePreview, job, 90);
}
} else {
ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64((DEBOUNCE - still).clamp(0.01, DEBOUNCE)));
}
let Some(tex) = app.renderer.textures.get(&Slot::SlidePreview).filter(|tex| tex.photo == id) 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("Rendering…"), t.font(11.5), Color32::from_gray(200));
return;
};
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);
if s.show_guides {
// the margins, as lines across the slide (where the image cell's edges are)
let aspect = app.session.slide_photo_aspect(id).unwrap_or(1.5);
let l = lightcraft_slideshow::layout(&s, aspect, tex.size[0], tex.size[1]);
let (kx, ky) = (drawn.width() / tex.size[0].max(1) as f32, drawn.height() / tex.size[1].max(1) as f32);
let stroke = Stroke::new(1.0, Color32::from_rgba_unmultiplied(120, 170, 255, 200));
let x = |v: f64| drawn.left() + v as f32 * kx;
let y = |v: f64| drawn.top() + v as f32 * ky;
for gx in [x(l.cell.x0), x(l.cell.x1)] {
p.line_segment([pos2(gx, drawn.top()), pos2(gx, drawn.bottom())], stroke);
}
for gy in [y(l.cell.y0), y(l.cell.y1)] {
p.line_segment([pos2(drawn.left(), gy), pos2(drawn.right(), gy)], stroke);
}
}
hint(ui, t, "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.");
}
/// The default layout (for the Reset button).
pub fn reset(app: &mut LightcraftApp) {
let d = SlideSettings::default().to_json();
set(app, d);
}
+33 -5
View File
@@ -12,8 +12,10 @@
//! transition until the next slide's texture exists ([`ready`]): the current slide is held
//! instead of flashing a stand-in. With it off, a slide is rendered when it is due.
//!
//! Which picture a slide shows comes from one place, [`slide_job`], so a later slide composer
//! (layout, overlays, backdrop) replaces that function and the playback stays as it is.
//! Which picture a slide shows comes from one place, [`slide_job`]: the photo itself with the
//! 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.
use egui::{Align2, Color32, Pos2, Rect, pos2, vec2};
use lightcraft_catalog::PhotoId;
@@ -167,21 +169,47 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) {
}
}
/// The render that shows slide `id` in `area` (logical points at `ppp`): the photo as developed,
/// fitted to the screen. The one place that decides what a slide's picture is.
/// The render that shows slide `id` in `area` (logical points at `ppp`). The one place that
/// decides what a slide's picture is:
/// - with the plain layout (no margins, border, shadow or backdrop: [`SlideSettings::is_plain`])
/// the photo as developed, fitted to the screen (drawn over black);
/// - otherwise the slide composed with the library's layout (`slideshow.layout`) at the screen's
/// pixels: the photo rendered at its image cell's size, then composed on the render worker, so
/// a slide is ready (and fades) like any other texture.
///
/// [`SlideSettings::is_plain`]: lightcraft_engine::slide::SlideSettings::is_plain
fn slide_job(app: &mut LightcraftApp, id: PhotoId, area: Rect, ppp: f32, texture_side: usize) -> Option<lightcraft_engine::RenderJob> {
let photo = app.session.catalog.photo(id)?.clone();
let frame = Frame::with_lens(photo.width.max(1) as usize, photo.height.max(1) as usize, &photo.develop, true, photo.embedded_lens.as_ref());
let native = super::detail::output_px(&frame);
if !app.session.slide_layout.is_plain() {
let (w, h) = slide_px(area, ppp, texture_side);
let edge = app.ui.settings.loupe_edge(w.max(h) as f32, native[0].max(native[1]), texture_side);
return app.session.slide_job(id, w, h, Some(edge));
}
let aspect = frame.aspect() as f32;
let aspect = if aspect.is_finite() && aspect > 0.0 { aspect } else { 1.5 };
let fit = (area.width() / aspect).min(area.height());
let drawn_long = fit.max(fit * aspect) * ppp;
let native = super::detail::output_px(&frame);
let edge = app.ui.settings.loupe_edge(drawn_long, native[0].max(native[1]), texture_side);
let (w, h) = if aspect >= 1.0 { (edge, (edge as f32 / aspect) as usize) } else { ((edge as f32 * aspect) as usize, edge) };
app.session.loupe_job(id, w.max(8), h.max(8), true)
}
/// A composed slide's size in pixels: `area` at `ppp`, its long side at most the texture side
/// (and the composer's limit), at least 16 × 16.
pub fn slide_px(area: Rect, ppp: f32, texture_side: usize) -> (usize, usize) {
let px = |v: f32| if v.is_finite() { (v * ppp).round().max(16.0) as usize } else { 16 };
let (w, h) = (px(area.width()), px(area.height()));
let cap = texture_side.clamp(16, lightcraft_slideshow::MAX_SIDE);
let long = w.max(h);
if long <= cap {
return (w, h);
}
let k = cap as f64 / long as f64;
(((w as f64 * k).round() as usize).max(16), ((h as f64 * k).round() as usize).max(16))
}
/// The slides to keep rendered now, most wanted first, with their render priority.
fn wanted(play: &Playback, now: f64) -> Vec<(PhotoId, u32)> {
let f = play.frame(now);
+3
View File
@@ -55,6 +55,9 @@ pub enum Slot {
/// A slideshow slide (the one up, the one fading out, the next ones prepared in advance; see
/// [`crate::panels::slideshow`]).
Slide(PhotoId),
/// The small composed slide in Settings ▸ Slideshow (the active photo with the layout being
/// edited).
SlidePreview,
}
pub struct Tex {
+104
View File
@@ -240,3 +240,107 @@ fn a_photo_deleted_while_it_plays_does_not_stall_the_slideshow() {
assert!(photos.len() > 3);
h.settle(SETTLE);
}
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()]
}
#[test]
fn a_composed_slide_shows_during_playback() {
let mut h = grid();
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();
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();
assert_eq!(tex.size, [1000, 700], "a composed slide fills the screen");
let img = h.paint();
assert_eq!(img.size, [1000, 700]);
// 10 % of the 700 px short side = 70 px of red backdrop around the image cell
for (x, y) in [(5, 5), (995, 5), (500, 30), (500, 690), (30, 350), (985, 350)] {
assert_eq!(painted(&img, x, y), [255, 0, 0], "margin at ({x}, {y})");
}
let mid = painted(&img, 500, 350);
assert_ne!(mid, [255, 0, 0], "the photo in the cell");
// the next slide is composed too, and stepping shows it
let next = play(&h).upcoming(1)[0];
assert!(h.step_until(SETTLE, |h| has_slide(h, next)));
run(&mut h, "slideshow.next", json!({}));
h.step();
assert_eq!(painted(&h.paint(), 5, 5), [255, 0, 0]);
h.settle(SETTLE);
}
#[test]
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();
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 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}");
assert_ne!(tex.size, [1000, 700]);
let img = h.paint();
let r = crate::panels::detail::fit_texture_rect(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 700.0)), tex.size);
if r.left() > 2.0 {
assert_eq!(painted(&img, 1, 350), [0, 0, 0], "black beside the photo");
}
if r.top() > 2.0 {
assert_eq!(painted(&img, 500, 1), [0, 0, 0], "black above the photo");
}
h.settle(SETTLE);
}
#[test]
fn settings_shows_the_slide_layout_and_a_live_preview() {
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!({}));
h.step();
let has = |h: &Headless, w: &str| h.app.widgets.iter().any(|(id, _)| id == w);
for w in [
"slider:slideshowMargins",
"check:settings.slideshowZoomToFill",
"check:settings.slideshowStroke",
"check:settings.slideshowShadow",
"button:slideshowBackdropColor",
"check:settings.slideshowWash",
"slideshow:preview",
] {
assert!(has(&h, w), "{w}");
}
// 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();
}
let r = h.request("ui.clickWidget", json!({"id": "check:settings.slideshowShadow"}), T);
assert_eq!(r["ok"], true, "{r}");
h.step();
assert!(h.app.session.slide_layout.cast_shadow);
assert!(has(&h, "slider:slideshowShadowAngle"));
// a changed layout renders the preview again (once it has settled)
let before = h.app.renderer.textures.get(&Slot::SlidePreview).map(|t| t.key);
run(&mut h, "slideshow.layout", json!({"margins": 0.2, "backdropColor": "#00ff00"}));
assert!(h.step_until(SETTLE, |h| h.app.renderer.textures.get(&Slot::SlidePreview).map(|t| t.key) != before));
h.step();
let rect = h.app.widgets.iter().find(|(id, _)| id == "slideshow:preview").map(|(_, r)| *r).unwrap();
let img = h.paint();
let c = painted(&img, (rect.left() + 3.0) as usize, (rect.center().y) as usize);
assert!(c[1] > 200 && c[0] < 60, "the green backdrop in the preview margin: {c:?}");
h.settle(SETTLE);
}
+2
View File
@@ -49,6 +49,8 @@ mode ([mcp.md](mcp.md)) is a thin layer over this channel. Implementation:
| `engine.execute {command: "activity.list"}` | — | The long-running tasks in flight (imports, exports, preview builds, downloads, the face scan…), oldest first: `{tasks: [{id, kind, label, done, total, unit, detail, cancellable, cancelling, ageMs}]}`; `total` 0 = not known yet; `unit` is `count`, `bytes` or `percent`; `label` is English (the app shows it translated) |
| `engine.execute {command: "activity.cancel"}` | `{id}` or `{all: true}` | Asks the task (or every cancellable one) to stop at its next file or photo → `{cancelled: n}`; an unknown id (it may have just finished) or a task that can't be cancelled is an error |
| `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.preview"}` | `{id?, width?, height?, path?}` | One slide composed with the layout, as the slideshow plays it: a PNG written to `path`, or returned base64 in `png` without one; `{id, width, height, cell, photo}` (`cell` and `photo` are `[x0, y0, x1, y1]` in slide pixels). Sides 16–4096; one side given takes the other from the aspect preview |
| `app.quit` | — | Close the app |
Image navigation is also available directly as the UI command `view.navigate`, with
+6
View File
@@ -196,6 +196,12 @@ JSON params (documented in each tool's description and by `list_commands`). Pass
leave these per-command tools out when a client struggles with a large tool list; `run_command`
still reaches every command.
Slideshow slides, for example: `cmd_slideshow_layout` reads or changes the slide layout (margins,
zoom to fill, stroke border, cast shadow, backdrop colour, colour wash, background image; saved with
the library) and `cmd_slideshow_preview {id?, width?, height?, path?}` composes one slide with it —
written as a PNG to `path`, or returned as base64 PNG in `png` — so an agent can look at a layout
before playing it. `cmd_slideshow_playback` sets the timing and fades.
## Resources
`resources/list` / `resources/read` serve JSON snapshots:
+7 -7
View File
@@ -52,10 +52,10 @@ cited here still exists. Formerly docs/parity.md.
| X. Cross-cutting behaviours (BEHAV) | 18 | 6 | 1 | 1 | 8/8 (100%) | 6/9 (67%) |
| Y. Menus | 82 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) |
| Z. Keyboard shortcuts (desktop) | 75 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) |
| Lightroom Classic extras | 33 | 16 | 34 | 9 | — | 23/25 (92%) |
| **Total** | 397 | 50 | 72 | 36 | 192/200 (96%) | 143/155 (92%) |
| Lightroom Classic extras | 35 | 17 | 31 | 9 | — | 23/25 (92%) |
| **Total** | 399 | 51 | 69 | 36 | 192/200 (96%) | 143/155 (92%) |
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.3%** of 519 in-scope rows — P0 98.0% of 200 · P1 95.2% of 155 · P2 47.5% of 163.
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.8%** of 519 in-scope rows — P0 98.0% of 200 · P1 95.2% of 155 · P2 49.1% of 163.
<!-- /parity:summary -->
## Top gaps and shortcut notes
@@ -728,13 +728,13 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06-
| LRC-BOOK-VIEWS | Book views | P2 | ⬜ | | |
| LRC-BOOK-EXPORT | Book export (PDF/JPEG) | P2 | ⬜ | | |
| LRC-SS-TEMPLATES | Slideshow templates | P2 | ⬜ | | |
| LRC-SS-OPTIONS | Slideshow options | P2 | ⬜ | | |
| LRC-SS-LAYOUT | Slideshow layout | P2 | ⬜ | | |
| LRC-SS-OPTIONS | Slideshow options | P2 | ✅ | `cmd:slideshow.layout`, `cmd:slideshow.preview`, `path:crates/slideshow/src/compose.rs` | Zoom to Fill Frame (the photo covers its image cell, cropped to it; off: fits inside), Stroke Border (colour, width as a share of the slide's shorter side, drawn around the photo), Cast Shadow (opacity, offset, radius, angle; a blurred offset rectangle under the photo and its stroke). Composed by `lightcraft-slideshow` (CPU, no UI) for playback, the Settings ▸ Slideshow preview and `slideshow.preview`; edited in Settings ▸ Slideshow until the Slideshow module view exists |
| 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 | ⬜ | | |
| LRC-SS-BACKDROP | Slideshow backdrop | P2 | ⬜ | | |
| 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-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, slides composed with layout/overlays/backdrop (LRC-SS-LAYOUT…) |
| 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, overlays and titles on the slides (LRC-SS-OVERLAYS, LRC-SS-TITLES). Slides are composed with the layout, options and backdrop (LRC-SS-LAYOUT / OPTIONS / BACKDROP) |
| 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 | ⬜ | | |
+12
View File
@@ -2,6 +2,18 @@
## 2026-10-10
### Slideshow layout, options and backdrop
- Slides can now be laid out like Lightroom Classic's Slideshow Layout, Options and Backdrop panels: margins (all four
linked, or each on its own), Zoom to Fill Frame (crop the photo to fill its cell), a stroke border (colour, width),
a cast shadow (opacity, offset, radius, angle), and a backdrop colour with an optional colour wash (a gradient from a
second colour, at an angle and opacity) and a background image (a photo from the library, at an opacity).
- Set them in Settings ▸ Slideshow, which now shows the selected photo as a slide as you change them (with the margin
guides, at the screen's aspect or 16:9 / 4:3). Sizes are a share of the slide, so a slide looks the same on any
screen. They are saved with the library.
- View ▸ Slideshow plays the composed slides, prepared ahead off the UI thread like before, so fades and the playback
controls work as they did. With the default layout (no margins, border, shadow or backdrop) it plays the photos as
before. Agents use `slideshow.layout`, and `slideshow.preview` returns one composed slide as a PNG.
### Slideshow playback
- View ▸ Slideshow (⌥⌘↩) now plays with Lightroom Classic's Playback settings: how long each slide stays up, a fade
between slides (a crossfade, or through black, white or any colour; 0 cuts), random order (every photo once per
+1
View File
@@ -48,6 +48,7 @@ pub const TABLE: &[(&str, Class)] = &[
("develop", Class::Layer(1)),
("faces", Class::Layer(1)),
("scenes", Class::Layer(1)),
("slideshow", Class::Layer(1)),
("pipeline", Class::Layer(2)),
("gpu", Class::Layer(3)),
("catalog", Class::Layer(3)),