# Conflicts:
#	crates/ui-egui/locales/de.json
#	crates/ui-egui/locales/es.json
#	crates/ui-egui/locales/fr.json
#	crates/ui-egui/locales/ja.json
#	crates/ui-egui/locales/pt-br.json
#	crates/ui-egui/locales/ru.json
#	crates/ui-egui/locales/uk.json
#	crates/ui-egui/locales/zh-hans.json
#	crates/ui-egui/locales/zh-hant.json
This commit is contained in:
Brandon Thomas
2026-10-14 21:54:47 -04:00
51 changed files with 5921 additions and 293 deletions
Generated
+11
View File
@@ -3239,6 +3239,7 @@ dependencies = [
"lightcraft-raw",
"lightcraft-scenes",
"lightcraft-segment",
"lightcraft-slideshow",
"lightcraft-tiff",
"log",
"miniz_oxide 0.8.9",
@@ -3460,6 +3461,15 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "lightcraft-slideshow"
version = "0.7.1"
dependencies = [
"lightcraft-raster",
"serde",
"serde_json",
]
[[package]]
name = "lightcraft-sysmem"
version = "0.8.0"
@@ -3493,6 +3503,7 @@ dependencies = [
"lightcraft-pipeline",
"lightcraft-preview",
"lightcraft-raster",
"lightcraft-slideshow",
"log",
"serde",
"serde_json",
+1
View File
@@ -39,6 +39,7 @@ lightcraft-fetch = { path = "crates/fetch" }
lightcraft-heif = { path = "crates/heif" }
lightcraft-segment = { path = "crates/segment" }
lightcraft-geocode = { path = "crates/geocode" }
lightcraft-slideshow = { path = "crates/slideshow" }
# foundation
thiserror = "2"
+10
View File
@@ -161,6 +161,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 {
@@ -370,6 +372,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 {
@@ -493,6 +502,7 @@ impl RenderOffload for Workers {
level: job.level,
key: job.key,
cache: job.cache,
slide: job.slide,
});
None
}
+1
View File
@@ -29,6 +29,7 @@ lightcraft-faces = { workspace = true }
miniz_oxide = { workspace = true }
lightcraft-merge = { workspace = true }
lightcraft-geocode = { workspace = true }
lightcraft-slideshow = { workspace = true }
rayon = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
+2
View File
@@ -33,6 +33,7 @@ mod prefs;
mod preset_files;
pub mod previews;
mod query;
mod slideshow;
mod sync;
mod xmp;
@@ -153,6 +154,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(xmp::specs());
v.extend(preset_files::specs());
v.extend(prefs::specs());
v.extend(slideshow::specs());
v.extend(export::specs());
v.extend(before::specs());
v.extend(browse::specs());
+175
View File
@@ -0,0 +1,175 @@
//! `slideshow.playback`: the slideshow's Playback settings (slide and fade durations, fade colour,
//! 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.overlays`:
//! the identity plate, rating stars, text overlays with tokens and the watermark drawn on every
//! slide (also a library preference). `slideshow.preview` composes one slide as a PNG.
use lightcraft_catalog::PhotoId;
use serde_json::{Value, json};
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 {
let next = s.slideshow.merged(p).map_err(|e| bad("slideshow.playback", e))?;
if next != s.slideshow {
s.slideshow = next;
s.save_prefs()?;
}
}
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())
}
fn overlays(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_overlays.merged(p).map_err(|e| bad("slideshow.overlays", e))?;
if next != s.slide_overlays {
s.slide_overlays = next;
s.save_prefs()?;
}
}
let mut out = s.slide_overlays.to_json();
out["warnings"] = json!(s.slide_overlays.warnings());
Ok(out)
}
/// A slide size from the request: both sides given, one (the other from the aspect preview), or
/// neither (1280 wide). Each side 16–[`PREVIEW_MAX`].
fn preview_size(s: &Session, p: &Value) -> Result<(usize, usize)> {
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),
});
let photo = s.catalog.photo(id).cloned();
let o = crate::slide_overlay::SlideOverlays::resolve(&s.slide_overlays, photo.as_deref());
out["overlays"] = o.boxes(&l).into_iter().map(|(k, r)| json!({"kind": k.id(), "box": rect(r)})).collect();
match path {
Some(path) => {
crate::export::write_file(path, &png).map_err(crate::EngineError::Other)?;
out["path"] = json!(path);
}
None => out["png"] = json!(base64(&png)),
}
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
),
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!(
"slideshow.overlays",
"Slideshow Overlay Settings",
[],
None,
"{identityPlate?: {enabled?, text?, image?: PNG/JPEG file (drawn instead of the text; \"\" = the text), size?: height as a fraction of the slide's shorter side (0.005–0.5), color?: \"#rrggbb\"|[r,g,b], opacity?: 0–1, …placement, behind?: bool (render behind the photo)}, ratingStars?: {enabled?, color?, size?: a star's height (0.005–0.15), …placement}, texts?: [text overlay] (replaces the list, at most 16; each entry merged onto the one at its index), addText?: text overlay ({} = the default \"{file}\"), removeText?: index, showWatermark?: bool, watermark?: text | the export watermark object {text, vertical, size, opacity, anchor, inset, color, shadow, image, imageWidth} (merged; sizes relative to the photo)} where a text overlay is {text?: template with tokens ({file}, {name}, {title}, {caption}, {date} (YYYY-MM-DD) / {date:%d.%m.%Y}, {exposure}, {shutter}, {aperture}, {focal}, {iso}, {camera}, {lens}, {rating}, {creator}, {copyright}, {folder}, {ext}; unknown tags show as typed), size?: line height (0.005–0.3), color?, opacity?, …placement, shadow?: bool, shadowOpacity?: 0–1, shadowOffset?, shadowRadius?: 0–0.05 (fractions of the shorter side), shadowAngle?: degrees counter-clockwise from the right} and the placement keys are anchor?: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, offsetX?, offsetY?: fractions of the slide's shorter side (x right, y down; −1–1), relativeTo?: \"slide\"|\"photo\" (the overlay is kept on the slide) — omitted keys keep their value, {} reads them; out-of-range numbers are clamped, wrong types refused; saved with the library; slideshow playback and slideshow.preview draw them → the settings and warnings (an unreadable identity plate image, unknown tokens)",
always,
overlays
),
cmd!(
query "slideshow.preview",
"Slideshow Slide Preview",
[],
None,
"{id?: photo (default: the active one), width?, height?: pixels (16–4096; one given: the other from the aspect preview; neither: 1280 wide), path?: write the PNG here} — the photo's slide composed with the slideshow layout and overlays → {id, width, height, cell: [x0,y0,x1,y1], photo: [x0,y0,x1,y1] (the visible photo, slide pixels), overlays: [{kind: identityPlate|ratingStars|textN|watermark, box: [x0,y0,x1,y1]}], path | png (base64 PNG)}",
always,
preview
),
]
}
+36 -163
View File
@@ -293,58 +293,8 @@ fn turn(turns: u8, x: f32, y: f32, w: f32, h: f32) -> (f32, f32) {
}
}
/// Inter SemiBold (OFL, see assets/ATTRIBUTION.md).
static WATERMARK_FONT: &[u8] = include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf");
/// The watermark faces: Inter first, then the craft-fonts CJK faces (Mincho first, the watermark's
/// serif look; then any other CJK face — a watermark has no UI language, so every CJK script is
/// offered). Without craft-fonts that is Inter alone, and CJK characters draw as Inter's
/// missing-glyph box.
fn watermark_fonts(craft: &'static [crate::fonts::CraftFont]) -> Vec<ab_glyph::FontRef<'static>> {
let mut cjk: Vec<_> = crate::fonts::cjk(craft).collect();
cjk.sort_by_key(|f| !f.is_mincho());
std::iter::once(WATERMARK_FONT)
.chain(cjk.into_iter().map(|f| f.bytes))
.filter_map(|bytes| ab_glyph::FontRef::try_from_slice(bytes).ok())
.collect()
}
/// Shape a whole grapheme in one cell. CJK uses vertical origins and forms; Latin stays upright.
/// Unlike Unicode presentation-form characters, GSUB works with BIZ UD fonts too.
fn watermark_cell_glyphs(
font: &ab_glyph::FontRef<'_>,
data: &harfrust::ShaperData,
text: &str,
vertical: bool,
px: f32,
left: f32,
top: f32,
) -> Option<Vec<ab_glyph::Glyph>> {
use ab_glyph::{Font, ScaleFont};
let face = harfrust::FontRef::new(font.font_data()).ok()?;
let mut buf = harfrust::UnicodeBuffer::new();
buf.push_str(text);
buf.set_direction(if vertical { harfrust::Direction::TopToBottom } else { harfrust::Direction::LeftToRight });
buf.guess_segment_properties();
let shaped = data.shaper(&face).build().shape(buf, harfrust::ShapeOptions::new());
if shaped.glyph_infos().is_empty() {
return None;
}
let sf = font.as_scaled(px);
let (sx, sy) = (sf.h_scale_factor(), sf.v_scale_factor());
let advance_x = shaped.glyph_positions().iter().map(|p| p.x_advance as f32 * sx).sum::<f32>();
let advance_y = shaped.glyph_positions().iter().map(|p| -(p.y_advance as f32) * sy).sum::<f32>();
let (mut x, mut y) = if vertical { (left + px / 2.0, top + (px - advance_y) / 2.0) } else { (left + (px - advance_x) / 2.0, top + sf.ascent()) };
let mut glyphs = Vec::new();
for (info, pos) in shaped.glyph_infos().iter().zip(shaped.glyph_positions()) {
let gid = u16::try_from(info.glyph_id).ok().filter(|g| *g != 0)?;
let pen = ab_glyph::point(x + pos.x_offset as f32 * sx, y - pos.y_offset as f32 * sy);
glyphs.push(ab_glyph::GlyphId(gid).with_scale_and_position(px, pen));
x += pos.x_advance as f32 * sx;
y -= pos.y_advance as f32 * sy;
}
Some(glyphs)
}
#[cfg(test)]
use crate::text::{TEXT_FONT as WATERMARK_FONT, cell_glyphs as watermark_cell_glyphs, faces as watermark_fonts};
/// Draw `wm` onto `img` (straight alpha blending of the encoded values).
pub fn draw_watermark(img: &mut Rgba8, wm: &Watermark) {
@@ -369,6 +319,23 @@ fn blend_watermark(img: &mut Rgba8, wm: &Watermark, floor_scale: f32) {
});
}
/// Draw `wm` into the `w × h` area of `img` whose top-left corner is (`x0`, `y0`), sized and
/// anchored as on an image of that size (a slideshow slide's photo). The area is clipped to `img`.
pub fn draw_watermark_in(img: &mut Rgba8, wm: &Watermark, x0: usize, y0: usize, w: usize, h: usize) {
let (width, height) = (img.width, img.height);
watermark_coverage(w, h, wm, crate::fonts::CRAFT_FONTS, 1.0, |x, y, k, col| {
let (px, py) = (x0.saturating_add(x), y0.saturating_add(y));
if px >= width || py >= height {
return;
}
if let Some(p) = img.data.get_mut(py * width + px) {
for c in 0..3 {
p[c] = (p[c] as f32 + (col[c] as f32 - p[c] as f32) * k).round() as u8;
}
}
});
}
/// Draw `wm` onto a high-bit-depth image (`wm.color` is in the image's space, 8-bit encoded; float
/// images are linear and blend in linear light).
pub fn draw_watermark_deep(img: &mut DeepImage, wm: &Watermark) {
@@ -403,7 +370,6 @@ fn watermark_coverage(
floor_scale: f32,
mut blend_px: impl FnMut(usize, usize, f32, [u8; 3]),
) {
use ab_glyph::{Font, PxScale, ScaleFont, point};
if !wm.image.trim().is_empty() {
logo_coverage(width, height, wm, blend_px);
return;
@@ -412,97 +378,10 @@ fn watermark_coverage(
if text.is_empty() || width == 0 || height == 0 {
return;
}
let fonts = watermark_fonts(craft);
let Some(latin) = fonts.first() else { return };
let vertical_data: Vec<_> = if wm.vertical {
fonts.iter().map(|font| harfrust::FontRef::new(font.font_data()).ok().map(|face| harfrust::ShaperData::new(&face))).collect()
} else {
Vec::new()
};
let short = width.min(height) as f32;
let px = (wm.size.clamp(0.005, 0.5) * short).max(6.0 * floor_scale);
let mut glyphs = Vec::new();
let mut x = 0.0f32;
let mut y = 0.0f32;
let mut tw = 0.0f32;
let mut prev = None;
let columns = text.split('\n').count();
let mut column = 0usize;
use unicode_segmentation::UnicodeSegmentation;
// Horizontal watermarks retain scalar layout and kerning; only vertical cells use graphemes.
let cells: Box<dyn Iterator<Item = &str>> =
if wm.vertical { Box::new(text.graphemes(true)) } else { Box::new(text.char_indices().filter_map(|(i, c)| text.get(i..i + c.len_utf8()))) };
for cell in cells {
let Some(original) = cell.chars().next() else { continue };
if cell == "\n" || (wm.vertical && cell == "\r\n") {
if wm.vertical {
column = column.saturating_add(1);
tw = tw.max(y);
y = 0.0;
} else {
tw = tw.max(x);
x = 0.0;
y += px;
}
prev = None;
continue;
}
let cjk = matches!(original as u32, 0x3000..=0x30FF | 0x3400..=0x9FFF | 0xFF01..=0xFF60);
if wm.vertical && (cjk || cell.chars().count() > 1) {
let left = columns.saturating_sub(column.saturating_add(1)) as f32 * px;
let shaped = fonts.iter().zip(&vertical_data).enumerate().find_map(|(face, (font, data))| {
let data = data.as_ref()?;
let cluster = watermark_cell_glyphs(font, data, cell, cjk, px, left, y)?;
let substituted = cluster.len() != 1 || cluster.first().is_some_and(|g| g.id != font.glyph_id(original));
if cjk && !substituted && matches!(original, '、' | '。' | 'ー' | '(' | ')' | '「' | '」' | '『' | '』' | '【' | '】') {
return None;
}
Some((face, cluster))
});
if let Some((face, cluster)) = shaped {
glyphs.extend(cluster.into_iter().map(|g| (face, g, None)));
y += px;
continue;
}
}
let vertical_form = match original {
'、' => '︑',
'。' => '︒',
'(' => '︵',
')' => '︶',
'「' => '﹁',
'」' => '﹂',
'『' => '﹃',
'』' => '﹄',
'【' => '︻',
'】' => '︼',
'…' => '︙',
other => other,
};
let has = |font: &ab_glyph::FontRef<'_>, c: char| font.glyph_id(c).0 != 0;
let ch = if wm.vertical && fonts.iter().skip(1).any(|f| has(f, vertical_form)) { vertical_form } else { original };
// The first face with the glyph; Inter (its missing-glyph box) when none has it.
let face = fonts.iter().position(|f| has(f, ch)).unwrap_or(0);
let sf = fonts.get(face).unwrap_or(latin).as_scaled(PxScale::from(px));
let id = sf.glyph_id(ch);
if wm.vertical {
let left = columns.saturating_sub(column.saturating_add(1)) as f32 * px;
let pen = point(left + (px - sf.h_advance(id)) / 2.0, y + sf.ascent());
let rotation = matches!(original, 'ー' | '—' | '–').then_some((left + px / 2.0, y + px / 2.0));
glyphs.push((face, id.with_scale_and_position(px, pen), rotation));
y += px;
} else {
if let Some((previous_face, previous)) = prev
&& previous_face == face
{
x += sf.kern(previous, id);
}
glyphs.push((face, id.with_scale_and_position(px, point(x, y + sf.ascent())), None));
x += sf.h_advance(id);
prev = Some((face, id));
}
}
let (tw, th) = if wm.vertical { (columns as f32 * px, tw.max(y)) } else { (tw.max(x), y + px) };
let Some(layout) = crate::text::TextLayout::new(text, px, wm.vertical, craft) else { return };
let (tw, th) = (layout.width, layout.height);
// laid out unturned in a tw × th box, placed by the box it becomes
let turns = wm.quarter_turns();
let (bw, bh) = if turns % 2 == 1 { (th, tw) } else { (tw, th) };
@@ -520,35 +399,29 @@ fn watermark_coverage(
BottomLeft | Bottom | BottomRight => h - iy - bh,
};
let alpha = wm.opacity.clamp(0.0, 1.0);
let mut blend = |gx: i32, gy: i32, cov: f32, col: [u8; 3], a: f32| {
if gx < 0 || gy < 0 || gx >= width as i32 || gy >= height as i32 {
return;
}
blend_px(gx as usize, gy as usize, (cov * a).clamp(0.0, 1.0), col);
};
let passes: &[(f32, [u8; 3], f32)] =
if wm.shadow { &[((px * 0.05).max(floor_scale), [0, 0, 0], 0.45), (0.0, wm.color, 1.0)] } else { &[(0.0, wm.color, 1.0)] };
for &(off, col, a) in passes {
for (face, g, rotation) in &glyphs {
if let Some(o) = fonts.get(*face).and_then(|font| font.outline_glyph(g.clone())) {
let b = o.px_bounds();
o.draw(|gx, gy, cov| {
let (sx, sy) = (b.min.x + gx as f32, b.min.y + gy as f32);
let (sx, sy) = match rotation {
Some((cx, cy)) => (cx - (sy - cy), cy + (sx - cx)),
None => (sx, sy),
};
// turned with the box; the shadow still falls down and to the right
let (sx, sy) = turn(turns, sx, sy, tw, th);
blend((ox + off + sx) as i32, (oy + off + sy) as i32, cov, col, a * alpha);
});
let mut put = |gx: i32, gy: i32, cov: f32| {
if gx < 0 || gy < 0 || gx >= width as i32 || gy >= height as i32 {
return;
}
blend_px(gx as usize, gy as usize, (cov * a * alpha).clamp(0.0, 1.0), col);
};
if turns == 0 {
layout.draw_in(ox + off, oy + off, w, h, &mut put);
} else {
// turned with the box; the shadow still falls down and to the right
layout.draw_points(|x, y, cov| {
let (x, y) = turn(turns, x, y, tw, th);
put((ox + off + x) as i32, (oy + off + y) as i32, cov);
});
}
}
}
/// A watermark graphic, decoded once per file (and again when the file changes).
fn watermark_logo(path: &str) -> Option<std::sync::Arc<Rgba8>> {
pub(crate) fn watermark_logo(path: &str) -> Option<std::sync::Arc<Rgba8>> {
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
static CACHE: Mutex<Option<HashMap<String, (std::time::SystemTime, Arc<Rgba8>)>>> = Mutex::new(None);
@@ -995,7 +868,7 @@ impl ExportOptions {
}
/// A `watermark` value: text, or an object of [`WATERMARK_PARAMS`] (issue #183).
fn validate_watermark(cmd: &str, v: &serde_json::Value) -> crate::Result<()> {
pub(crate) fn validate_watermark(cmd: &str, v: &serde_json::Value) -> crate::Result<()> {
use serde_json::Value;
let bad = |msg: String| crate::cmd::bad(cmd, msg);
let obj = match v {
+1
View File
@@ -1532,6 +1532,7 @@ impl Session {
stages: None,
view_cache: None,
display: None,
slide: None,
preview_cut: None,
};
let embedded = match (&self.media.preview_loader, c.kind) {
+19
View File
@@ -62,8 +62,12 @@ pub mod proof;
pub mod rename;
pub mod segment;
pub mod sidecar;
pub mod slide;
pub mod slide_overlay;
pub mod slideshow;
pub mod smart;
pub mod sync;
pub mod text;
mod view;
pub mod walk;
@@ -302,6 +306,15 @@ pub struct Session {
pub import_defaults: import::ImportDefaults,
/// Disk budget of the library's thumbnail cache in MB (0 = default; persisted in prefs.json).
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,
/// Slideshow overlays (identity plate, rating stars, text overlays, watermark).
pub slide_overlays: slide_overlay::OverlaySettings,
/// 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
@@ -400,6 +413,10 @@ impl Session {
preview_build: None,
import_defaults: import::ImportDefaults::default(),
cache_mb: 0,
slideshow: slideshow::PlaybackSettings::default(),
slide_layout: slide::SlideSettings::default(),
slide_overlays: slide_overlay::OverlaySettings::default(),
slide_backdrop: Default::default(),
smart_previews_dir: None,
forget_local_days: lightcraft_catalog::DEFAULT_FORGET_DAYS,
#[cfg(not(target_arch = "wasm32"))]
@@ -1056,6 +1073,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;
+12
View File
@@ -232,6 +232,12 @@ struct PrefsFile {
/// Days after which untouched Local records of unbrowsed folders are forgotten (missing =
/// the default, 0 = never).
forget_local_days: Option<u32>,
/// Slideshow playback settings.
slideshow: crate::slideshow::PlaybackSettings,
/// Slideshow slide layout (margins, options, backdrop).
slide_layout: crate::slide::SlideSettings,
/// Slideshow overlays.
slide_overlays: crate::slide_overlay::OverlaySettings,
}
fn presets_json(s: &Session) -> String {
@@ -454,6 +460,9 @@ impl Session {
self.recent_keywords = prefs.recent_keywords;
self.import_defaults = prefs.import;
self.cache_mb = prefs.cache_mb;
self.slideshow = prefs.slideshow.sanitized();
self.slide_layout = prefs.slide_layout.sanitized();
self.slide_overlays = prefs.slide_overlays.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 {
@@ -699,6 +708,9 @@ impl Session {
cache_mb: self.cache_mb,
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(),
slide_overlays: self.slide_overlays.clone(),
})
.unwrap_or_default();
let Some(lib) = self.library.as_mut() else { return Ok(()) };
+30 -1
View File
@@ -752,6 +752,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>>,
/// A face tile that can be cut from the raw's embedded preview, tried before `source`
/// ([`crate::Session::face_job`]; `None` for every other job).
pub preview_cut: Option<crate::face_tiles::PreviewCut>,
@@ -849,7 +851,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);
@@ -883,7 +886,30 @@ impl RenderJob {
}
}
/// 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 (inside [`Self::run`]'s guard).
fn run_unguarded(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)
{
@@ -1227,6 +1253,7 @@ impl crate::Session {
stages: None,
view_cache: None,
display: None,
slide: None,
preview_cut: None,
})
}
@@ -1279,6 +1306,7 @@ impl crate::Session {
stages: None,
view_cache: None,
display: None,
slide: None,
preview_cut: None,
})
}
@@ -1332,6 +1360,7 @@ impl crate::Session {
stages: None,
view_cache: None,
display: None,
slide: None,
preview_cut,
})
}
+40 -23
View File
@@ -245,6 +245,19 @@ pub fn expand(template: &str, p: &Photo, seq: usize) -> String {
/// The template's tokens replaced for `p` (no extension added, nothing sanitized). A bare `{seq}`
/// is zero-padded to `seq_width` digits.
pub fn expand_tokens(template: &str, p: &Photo, seq: usize, seq_width: usize) -> String {
expand_tokens_with(template, p, seq, seq_width, &|_, _, _| None)
}
/// [`expand_tokens`] with more tokens: `extra(name, arg, tag)` (the name lower-cased, the text
/// after `:`, the whole tag without braces) answers a token first; `None` leaves it to the rename
/// tokens (and an unknown one stays as typed). Slideshow text overlays add their tokens this way.
pub fn expand_tokens_with(
template: &str,
p: &Photo,
seq: usize,
seq_width: usize,
extra: &dyn Fn(&str, Option<&str>, &str) -> Option<String>,
) -> String {
let (stem, ext) = split_ext(&p.file_name);
let mut out = String::new();
let mut rest = template;
@@ -257,31 +270,35 @@ pub fn expand_tokens(template: &str, p: &Photo, seq: usize, seq_width: usize) ->
};
let tok = &rest[i + 1..i + j];
let (name, arg) = tok.split_once(':').map(|(a, b)| (a, Some(b))).unwrap_or((tok, None));
let v = match name.trim().to_ascii_lowercase().as_str() {
"name" | "filename" => stem.to_string(),
"num" => {
let digits = stem.chars().rev().take_while(char::is_ascii_digit).count();
stem[stem.len() - digits..].to_string()
}
"seq" | "n" => {
let w: usize = arg.and_then(|a| a.parse().ok()).unwrap_or(seq_width).min(9);
format!("{seq:0w$}")
}
"date" => format_date(p.date(), arg.unwrap_or("%Y%m%d")),
"folder" => match &p.source {
Source::File { path } => {
Path::new(path).parent().and_then(Path::file_name).map(|f| f.to_string_lossy().to_string()).unwrap_or_default()
let lower = name.trim().to_ascii_lowercase();
let v = match extra(&lower, arg, tok) {
Some(v) => v,
None => match lower.as_str() {
"name" | "filename" => stem.to_string(),
"num" => {
let digits = stem.chars().rev().take_while(char::is_ascii_digit).count();
stem[stem.len() - digits..].to_string()
}
Source::Demo { .. } => String::new(),
"seq" | "n" => {
let w: usize = arg.and_then(|a| a.parse().ok()).unwrap_or(seq_width).min(9);
format!("{seq:0w$}")
}
"date" => format_date(p.date(), arg.unwrap_or("%Y%m%d")),
"folder" => match &p.source {
Source::File { path } => {
Path::new(path).parent().and_then(Path::file_name).map(|f| f.to_string_lossy().to_string()).unwrap_or_default()
}
Source::Demo { .. } => String::new(),
},
"camera" => p.meta.camera.clone(),
"lens" => p.meta.lens.clone(),
"iso" => p.meta.iso.map(|v| v.to_string()).unwrap_or_default(),
"rating" => p.rating.to_string(),
"title" => p.meta.title.clone(),
"creator" => p.meta.creator.clone(),
"ext" => ext.to_string(),
_ => format!("{{{tok}}}"),
},
"camera" => p.meta.camera.clone(),
"lens" => p.meta.lens.clone(),
"iso" => p.meta.iso.map(|v| v.to_string()).unwrap_or_default(),
"rating" => p.rating.to_string(),
"title" => p.meta.title.clone(),
"creator" => p.meta.creator.clone(),
"ext" => ext.to_string(),
_ => format!("{{{tok}}}"),
};
out.push_str(&v);
rest = &rest[i + j + 1..];
+161
View File
@@ -0,0 +1,161 @@
//! Slideshow slides in the engine: the library's slide layout ([`SlideSettings`], Lightroom
//! Classic's Layout, Options and Backdrop panels; the `slideshow.layout` command), its overlays
//! ([`crate::slide_overlay`], `slideshow.overlays`) 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>,
/// The overlays resolved for the slide's photo (`None`: none draw).
pub overlays: Option<Arc<crate::slide_overlay::SlideOverlays>>,
}
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))
^ self.overlays.as_ref().map_or(0, |o| o.key().rotate_left(43))
}
/// 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);
let Some(o) = &self.overlays else {
return lightcraft_slideshow::compose(photo, bg.as_deref(), &self.settings, self.width, self.height);
};
let (mut img, l) =
lightcraft_slideshow::compose_with(photo, bg.as_deref(), &self.settings, self.width, self.height, &mut |img, l| o.draw_behind(img, l));
o.draw_front(&mut img, &l);
img
}
}
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 })
}
/// Does a slide show nothing but the photo fitted on black (the plain layout and no
/// overlays)? Playback then shows the photo's own render.
pub fn slides_plain(&self) -> bool {
self.slide_layout.is_plain() && !self.slide_overlays.any()
}
/// 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) and the library's
/// overlays resolved for `photo`.
pub fn slide_compose(&mut self, settings: &SlideSettings, width: usize, height: usize, photo: Option<PhotoId>) -> 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() })
});
let overlays = self.slide_overlays.any().then(|| {
let p = photo.and_then(|id| self.catalog.photo(id));
Arc::new(crate::slide_overlay::SlideOverlays::resolve(&self.slide_overlays, p.map(|p| &**p)))
});
Arc::new(SlideCompose { settings, width, height, backdrop, overlays })
}
/// 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(id));
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)
}
}
File diff suppressed because it is too large Load Diff
+622
View File
@@ -0,0 +1,622 @@
//! Slideshow playback: the settings of Lightroom Classic's Playback panel and the timeline that
//! plays the photos in view (durations, fades, random order, repeat, pause, stepping).
//!
//! No UI here: [`Playback`] is a pure state machine driven with injected times (seconds on any
//! monotonic clock), so the desktop app, a later Slideshow module and tests run the same timeline.
//! The frontend asks [`Playback::frame`] what to draw (the current slide, and while a transition
//! runs the previous one and its progress) and [`Playback::upcoming`] which slides to prepare; it
//! tells [`Playback::tick`] which slides are ready, so a transition never starts on a slide that
//! has not been rendered yet (the current one is held instead).
//!
//! The settings are a library preference (`prefs.json`, see [`crate::library`]), read and changed
//! with the `slideshow.playback` command.
use lightcraft_catalog::PhotoId;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
/// Shortest and longest time a slide stays up (seconds).
pub const SLIDE_MIN: f64 = 0.5;
pub const SLIDE_MAX: f64 = 120.0;
/// Longest fade (seconds); a fade is never longer than the slide duration.
pub const FADE_MAX: f64 = 10.0;
/// How playback runs (Lightroom Classic's Playback panel, without music and pan & zoom).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct PlaybackSettings {
/// Seconds from the start of one slide's transition to the start of the next one's (the
/// fade in is part of it).
pub slide_duration: f64,
/// Seconds a transition takes (0 = cut).
pub fade_duration: f64,
/// Fade through this colour (out to it, then in from it) instead of crossfading.
pub fade_color: Option<[u8; 3]>,
/// Shuffle the photos (a new order for every pass when repeating).
pub random_order: bool,
/// Start over after the last slide; off: playback ends there.
pub repeat: bool,
/// Render the next slides before they are due.
pub prepare_previews: bool,
}
impl Default for PlaybackSettings {
fn default() -> Self {
PlaybackSettings { slide_duration: 4.0, fade_duration: 1.0, fade_color: None, random_order: false, repeat: true, prepare_previews: true }
}
}
impl PlaybackSettings {
/// The settings with every value in range (a non-finite duration takes the default; the fade
/// is never longer than the slide).
pub fn sanitized(mut self) -> Self {
let d = PlaybackSettings::default();
self.slide_duration = if self.slide_duration.is_finite() { self.slide_duration.clamp(SLIDE_MIN, SLIDE_MAX) } else { d.slide_duration };
let fade = if self.fade_duration.is_finite() { self.fade_duration } else { d.fade_duration };
self.fade_duration = fade.clamp(0.0, FADE_MAX).min(self.slide_duration);
self
}
pub fn to_json(&self) -> Value {
json!({
"slideDuration": self.slide_duration,
"fadeDuration": self.fade_duration,
"fadeColor": self.fade_color.map(hex),
"randomOrder": self.random_order,
"repeat": self.repeat,
"preparePreviews": self.prepare_previews,
})
}
/// 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; nothing changes then.
pub fn merged(&self, p: &Value) -> std::result::Result<Self, String> {
let Some(obj) = p.as_object() else { return Err("parameters must be an object".into()) };
let mut s = self.clone();
for (k, v) in obj {
match k.as_str() {
"slideDuration" => s.slide_duration = finite(k, v)?,
"fadeDuration" => s.fade_duration = finite(k, v)?,
"fadeColor" => s.fade_color = color(v)?,
"randomOrder" => s.random_order = boolean(k, v)?,
"repeat" => s.repeat = boolean(k, v)?,
"preparePreviews" => s.prepare_previews = boolean(k, v)?,
other => {
return Err(format!("unknown setting `{other}` (slideDuration, fadeDuration, fadeColor, randomOrder, repeat, preparePreviews)"));
}
}
}
// a fade longer than a slide asked for together with a shorter slide: the slide wins
Ok(s.sanitized())
}
}
fn finite(k: &str, v: &Value) -> std::result::Result<f64, String> {
match v.as_f64() {
Some(x) if x.is_finite() => Ok(x),
_ => Err(format!("`{k}` must be a finite number of seconds, not {v}")),
}
}
fn boolean(k: &str, v: &Value) -> std::result::Result<bool, String> {
v.as_bool().ok_or_else(|| format!("`{k}` must be true or false, not {v}"))
}
fn hex(c: [u8; 3]) -> String {
format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2])
}
/// `null` / `""` / `"none"` = crossfade; `"#rrggbb"` or `[r, g, b]` (0–255) = fade through it.
fn color(v: &Value) -> std::result::Result<Option<[u8; 3]>, String> {
let bad = || format!("`fadeColor` must be \"#rrggbb\", [r, g, b] or null, not {v}");
match v {
Value::Null => Ok(None),
Value::String(s) if s.is_empty() || s.eq_ignore_ascii_case("none") => Ok(None),
Value::String(s) => {
let h = s.strip_prefix('#').unwrap_or(s);
if h.len() != 6 || !h.is_ascii() {
return Err(bad());
}
let byte = |i: usize| h.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok()).ok_or_else(bad);
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 = x.as_u64().filter(|n| *n <= 255).ok_or_else(bad)? as u8;
}
Ok(Some(c))
}
_ => Err(bad()),
}
}
/// What a tick did.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Tick {
/// Nothing changed (the slide is up, paused, or a transition is running).
Idle,
/// The next slide is due but not ready: the current one is held.
Waiting(PhotoId),
/// A new slide came up (its transition starts now).
Advanced(PhotoId),
/// The last slide was shown and repeat is off.
Ended,
}
/// What to draw at a moment of playback.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Frame {
pub current: PhotoId,
/// The slide being faded out, while a transition runs.
pub from: Option<PhotoId>,
/// How far the transition is (0–1; 1 when there is none).
pub progress: f32,
pub fade_color: Option<[u8; 3]>,
}
/// One run of a slideshow over a fixed list of photos.
#[derive(Clone, Debug)]
pub struct Playback {
settings: PlaybackSettings,
photos: Vec<PhotoId>,
/// This pass's order (indices into `photos`).
order: Vec<usize>,
/// Index into `order` of the slide up now.
pos: usize,
pass: u64,
seed: u64,
/// When the current slide came up (its transition started).
started: f64,
/// The slide fading out (until the transition ends).
from: Option<PhotoId>,
/// Paused at this time.
paused_at: Option<f64>,
ended: bool,
/// The next slide was due but not ready at the last tick.
held: Option<PhotoId>,
}
/// SplitMix64: a small, well-mixed generator for the shuffle (deterministic per seed).
fn splitmix(state: &mut u64) -> u64 {
*state = state.wrapping_add(0x9e37_79b9_7f4a_7c15);
let mut z = *state;
z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
z ^ (z >> 31)
}
/// Fisher–Yates shuffle of `v` with `seed`.
fn shuffle(v: &mut [usize], seed: u64) {
let mut st = seed;
for i in (1..v.len()).rev() {
let j = (splitmix(&mut st) % (i as u64 + 1)) as usize;
v.swap(i, j);
}
}
impl Playback {
/// Start playing `photos` (the ones in view, in view order) at `start` (the selected photo;
/// the first one when absent or not among them), at time `now`. `seed` fixes the random order.
/// `None` when there is nothing to show.
pub fn new(photos: Vec<PhotoId>, start: Option<PhotoId>, settings: PlaybackSettings, seed: u64, now: f64) -> Option<Self> {
if photos.is_empty() {
return None;
}
let settings = settings.sanitized();
let n = photos.len();
let first = start.and_then(|s| photos.iter().position(|p| *p == s)).unwrap_or(0);
let order: Vec<usize> = if settings.random_order {
// the starting photo first, the others shuffled
let mut rest: Vec<usize> = (0..n).filter(|i| *i != first).collect();
shuffle(&mut rest, seed);
std::iter::once(first).chain(rest).collect()
} else {
// from the starting photo to the end, then the ones before it: every photo once
(first..n).chain(0..first).collect()
};
let now = if now.is_finite() { now } else { 0.0 };
Some(Playback { settings, photos, order, pos: 0, pass: 0, seed, started: now, from: None, paused_at: None, ended: false, held: None })
}
pub fn settings(&self) -> &PlaybackSettings {
&self.settings
}
/// The order of pass `pass` (after the first), given the order of the pass before it.
fn order_after(&self, pass: u64, prev: &[usize]) -> Vec<usize> {
let mut order = prev.to_vec();
if self.settings.random_order {
order.sort_unstable();
shuffle(&mut order, self.seed ^ pass.wrapping_mul(0xa076_1d64_78bd_642f));
// never the same photo twice in a row across passes
if order.len() > 1 && order.first() == prev.last() {
order.swap(0, 1);
}
}
order
}
fn photo_at(&self, order: &[usize], pos: usize) -> Option<PhotoId> {
order.get(pos).and_then(|i| self.photos.get(*i)).copied()
}
/// The slide up now.
pub fn current(&self) -> PhotoId {
self.photo_at(&self.order, self.pos).or_else(|| self.photos.first().copied()).unwrap_or(PhotoId(0))
}
/// (slide number from 1, slides per pass).
pub fn position(&self) -> (usize, usize) {
(self.pos + 1, self.order.len())
}
pub fn is_paused(&self) -> bool {
self.paused_at.is_some()
}
pub fn is_ended(&self) -> bool {
self.ended
}
/// The slide that was due but not ready at the last tick (the current one is held for it).
pub fn waiting_for(&self) -> Option<PhotoId> {
self.held
}
/// The clock as playback sees it: stopped while paused.
fn clock(&self, now: f64) -> f64 {
let now = if now.is_finite() { now } else { self.started };
self.paused_at.map_or(now, |p| p.min(now))
}
/// When the next slide is due.
pub fn due(&self) -> f64 {
self.started + self.settings.slide_duration
}
/// Seconds until the next slide is due (as of `now`; frozen while paused).
pub fn remaining(&self, now: f64) -> f64 {
(self.due() - self.clock(now)).max(0.0)
}
/// The next `n` slides in playing order (into the next pass when repeating; never the one up
/// now twice).
pub fn upcoming(&self, n: usize) -> Vec<PhotoId> {
let mut out = Vec::new();
let mut order = self.order.clone();
let (mut pos, mut pass) = (self.pos, self.pass);
while out.len() < n {
pos += 1;
if pos >= order.len() {
if !self.settings.repeat {
break;
}
pass += 1;
order = self.order_after(pass, &order);
pos = 0;
}
match self.photo_at(&order, pos) {
Some(p) if p != self.current() && !out.contains(&p) => out.push(p),
_ => break,
}
}
out
}
/// Move one slide on (`Advanced`), or end (`Ended`) past the last one without repeat.
fn advance(&mut self) -> Tick {
if self.pos + 1 < self.order.len() {
self.pos += 1;
} else if self.settings.repeat {
self.pass += 1;
self.order = self.order_after(self.pass, &self.order);
self.pos = 0;
} else {
self.ended = true;
return Tick::Ended;
}
Tick::Advanced(self.current())
}
/// Advance the timeline to `now`. `ready(photo)` says whether a slide can be shown: the
/// transition to the next slide waits until it is.
pub fn tick(&mut self, now: f64, ready: impl Fn(PhotoId) -> bool) -> Tick {
if self.ended {
return Tick::Ended;
}
let now = self.clock(now);
if self.from.is_some() && now >= self.started + self.settings.fade_duration {
self.from = None;
}
if self.paused_at.is_some() {
return Tick::Idle;
}
// a slide's time starts once it can be shown (the first one, one stepped to with ←/→)
if self.from.is_none() && !ready(self.current()) {
self.started = now;
return Tick::Idle;
}
if now < self.due() {
return Tick::Idle;
}
let Some(next) = self.upcoming(1).first().copied() else {
if self.settings.repeat {
// a single photo repeating: nothing to change to
self.started = now;
return Tick::Idle;
}
self.ended = true;
return Tick::Ended;
};
if !ready(next) {
self.held = Some(next);
return Tick::Waiting(next);
}
self.held = None;
let old = self.current();
let t = self.advance();
if let Tick::Advanced(_) = t {
self.started = now;
self.from = (self.settings.fade_duration > 0.0).then_some(old);
}
t
}
/// What to draw at `now`.
pub fn frame(&self, now: f64) -> Frame {
let now = self.clock(now);
let fade = self.settings.fade_duration;
let (from, progress) = match self.from {
Some(f) if fade > 0.0 && now < self.started + fade => (Some(f), ((now - self.started) / fade).clamp(0.0, 1.0) as f32),
_ => (None, 1.0),
};
Frame { current: self.current(), from, progress, fade_color: self.settings.fade_color }
}
/// Pause or resume at `now`; resuming keeps the time the slide had left. Returns whether it
/// is paused now.
pub fn toggle_pause(&mut self, now: f64) -> bool {
let now = if now.is_finite() { now } else { self.started };
match self.paused_at.take() {
Some(p) => {
// the slide (and a transition in progress) carries on where it stopped
self.started += (now - p).max(0.0);
}
None => self.paused_at = Some(now),
}
self.paused_at.is_some()
}
/// Step `delta` slides (←/→) at `now`: a cut, and the new slide gets its full time (paused
/// playback stays paused). Past the last slide without repeat playback ends; before the first
/// it stays there.
pub fn step(&mut self, delta: i32, now: f64) -> Tick {
if self.ended {
return Tick::Ended;
}
let now = if now.is_finite() { now } else { self.started };
// (more steps than slides would only go round again)
let steps = delta.unsigned_abs().min(self.order.len().min(u32::MAX as usize) as u32);
let mut t = Tick::Idle;
if delta > 0 {
for _ in 0..steps {
t = self.advance();
if t == Tick::Ended {
return t;
}
}
} else {
for _ in 0..steps {
if self.pos > 0 {
self.pos -= 1;
} else if self.settings.repeat {
self.pos = self.order.len().saturating_sub(1);
}
}
t = Tick::Advanced(self.current());
}
self.from = None;
self.held = None;
self.started = now;
if self.paused_at.is_some() {
self.paused_at = Some(now);
}
t
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ids(n: u64) -> Vec<PhotoId> {
(1..=n).map(PhotoId).collect()
}
fn settings(slide: f64, fade: f64) -> PlaybackSettings {
PlaybackSettings { slide_duration: slide, fade_duration: fade, ..Default::default() }
}
fn all_ready(_: PhotoId) -> bool {
true
}
#[test]
fn timeline_durations_and_fade_progress() {
let mut p = Playback::new(ids(3), Some(PhotoId(2)), settings(4.0, 1.0), 1, 10.0).unwrap();
assert_eq!(p.current(), PhotoId(2), "starts at the selected photo");
assert_eq!(p.position(), (1, 3));
assert_eq!(p.tick(13.9, all_ready), Tick::Idle);
assert_eq!(p.frame(13.9).from, None);
assert_eq!(p.tick(14.0, all_ready), Tick::Advanced(PhotoId(3)));
let f = p.frame(14.25);
assert_eq!((f.current, f.from), (PhotoId(3), Some(PhotoId(2))));
assert!((f.progress - 0.25).abs() < 1e-6, "{f:?}");
assert!((p.frame(14.5).progress - 0.5).abs() < 1e-6);
assert_eq!(p.tick(15.0, all_ready), Tick::Idle);
assert_eq!(p.frame(15.0).from, None, "the fade is over after fadeDuration");
// the slide period counts from its transition: next at 18
assert_eq!(p.tick(17.99, all_ready), Tick::Idle);
assert_eq!(p.tick(18.0, all_ready), Tick::Advanced(PhotoId(1)), "wraps to the photos before the start");
assert_eq!(p.position(), (3, 3));
}
#[test]
fn fade_zero_is_a_cut_and_fade_color_is_reported() {
let mut p = Playback::new(ids(2), None, settings(2.0, 0.0), 1, 0.0).unwrap();
assert_eq!(p.tick(2.0, all_ready), Tick::Advanced(PhotoId(2)));
assert_eq!(p.frame(2.0).from, None);
let s = PlaybackSettings { fade_color: Some([255, 0, 0]), ..settings(2.0, 1.0) };
let mut p = Playback::new(ids(2), None, s, 1, 0.0).unwrap();
p.tick(2.0, all_ready);
assert_eq!(p.frame(2.5).fade_color, Some([255, 0, 0]));
}
#[test]
fn arrows_cut_immediately_and_restart_the_slide_time() {
let mut p = Playback::new(ids(4), None, settings(4.0, 2.0), 1, 0.0).unwrap();
p.tick(4.0, all_ready);
assert!(p.frame(4.5).from.is_some(), "fading");
assert_eq!(p.step(1, 4.5), Tick::Advanced(PhotoId(3)));
assert_eq!(p.frame(4.5), Frame { current: PhotoId(3), from: None, progress: 1.0, fade_color: None }, "a cut");
assert_eq!(p.tick(8.4, all_ready), Tick::Idle, "full time from the step");
assert_eq!(p.step(-1, 6.0), Tick::Advanced(PhotoId(2)));
assert_eq!(p.step(-1, 6.0), Tick::Advanced(PhotoId(1)));
// repeat on: before the first is the last
assert_eq!(p.step(-1, 6.0), Tick::Advanced(PhotoId(4)));
assert_eq!(p.remaining(6.0), 4.0);
}
#[test]
fn random_order_is_deterministic_per_seed_and_covers_every_photo_once_per_pass() {
let photos = ids(20);
let run = |seed: u64| {
let s = PlaybackSettings { random_order: true, ..settings(1.0, 0.0) };
let mut p = Playback::new(photos.clone(), Some(PhotoId(5)), s, seed, 0.0).unwrap();
let mut seen = vec![p.current()];
for k in 1..60 {
p.tick(k as f64, all_ready);
seen.push(p.current());
}
seen
};
let a = run(42);
assert_eq!(a, run(42), "same seed, same order");
assert_ne!(a, run(43), "another seed, another order");
assert_eq!(a[0], PhotoId(5), "starts at the selected photo");
assert_ne!(a[..20].to_vec(), photos, "shuffled");
for pass in a.chunks(20).take(2) {
let mut sorted = pass.to_vec();
sorted.sort();
assert_eq!(sorted, photos, "every photo once per pass");
}
assert_ne!(a[..20], a[20..40], "a new order for the next pass");
assert!(a.windows(2).all(|w| w[0] != w[1]), "never the same photo twice in a row");
}
#[test]
fn repeat_off_stops_after_the_last_slide() {
let s = PlaybackSettings { repeat: false, ..settings(1.0, 0.0) };
let mut p = Playback::new(ids(3), Some(PhotoId(2)), s.clone(), 1, 0.0).unwrap();
assert_eq!(p.tick(1.0, all_ready), Tick::Advanced(PhotoId(3)));
assert_eq!(p.tick(2.0, all_ready), Tick::Advanced(PhotoId(1)));
assert_eq!(p.upcoming(2), vec![], "nothing after the last");
assert_eq!(p.tick(3.0, all_ready), Tick::Ended);
assert!(p.is_ended());
// → past the last one ends it too
let mut p = Playback::new(ids(2), None, s, 1, 0.0).unwrap();
assert_eq!(p.step(1, 0.5), Tick::Advanced(PhotoId(2)));
assert_eq!(p.step(1, 0.6), Tick::Ended);
// with repeat: it goes on
let mut p = Playback::new(ids(2), None, settings(1.0, 0.0), 1, 0.0).unwrap();
for k in 1..10 {
assert!(matches!(p.tick(k as f64, all_ready), Tick::Advanced(_)));
}
}
#[test]
fn pause_and_resume_keep_the_remaining_time() {
let mut p = Playback::new(ids(3), None, settings(4.0, 1.0), 1, 0.0).unwrap();
assert!(p.toggle_pause(3.0));
assert_eq!(p.tick(100.0, all_ready), Tick::Idle, "paused: nothing moves");
assert_eq!(p.remaining(100.0), 1.0);
assert!(!p.toggle_pause(100.0));
assert_eq!(p.tick(100.9, all_ready), Tick::Idle);
assert_eq!(p.tick(101.0, all_ready), Tick::Advanced(PhotoId(2)), "1 s left after resuming");
// pausing mid-fade freezes the fade
p.toggle_pause(101.25);
assert!((p.frame(150.0).progress - 0.25).abs() < 1e-6);
p.toggle_pause(150.0);
assert!((p.frame(150.5).progress - 0.75).abs() < 1e-6);
}
#[test]
fn a_transition_waits_for_the_next_slide_to_be_ready() {
let mut p = Playback::new(ids(3), None, settings(2.0, 1.0), 1, 0.0).unwrap();
assert_eq!(p.upcoming(2), vec![PhotoId(2), PhotoId(3)], "what to prepare");
let not_two = |id: PhotoId| id != PhotoId(2);
assert_eq!(p.tick(2.0, not_two), Tick::Waiting(PhotoId(2)));
assert_eq!(p.frame(3.0), Frame { current: PhotoId(1), from: None, progress: 1.0, fade_color: None }, "held, not flashed");
assert_eq!(p.waiting_for(), Some(PhotoId(2)));
assert_eq!(p.tick(3.5, all_ready), Tick::Advanced(PhotoId(2)));
assert_eq!(p.frame(3.5).from, Some(PhotoId(1)), "the fade starts once it is ready");
assert!((p.frame(4.0).progress - 0.5).abs() < 1e-6, "and runs its full length");
}
#[test]
fn a_slide_s_time_starts_once_it_is_ready() {
let mut p = Playback::new(ids(3), None, settings(2.0, 0.5), 1, 0.0).unwrap();
let none = |_: PhotoId| false;
assert_eq!(p.tick(1.0, none), Tick::Idle);
assert_eq!(p.tick(5.0, none), Tick::Idle, "the first slide isn't rendered yet: its clock waits");
assert_eq!(p.remaining(5.0), 2.0);
assert_eq!(p.tick(6.0, all_ready), Tick::Idle);
assert_eq!(p.tick(7.0, all_ready), Tick::Advanced(PhotoId(2)), "2 s from when it could be shown");
}
#[test]
fn hostile_settings_are_rejected_or_clamped() {
let d = PlaybackSettings::default();
for bad in [
json!({"slideDuration": "fast"}),
json!({"fadeDuration": null}),
json!({"repeat": 1}),
json!({"fadeColor": "#12345"}),
json!({"fadeColor": "#ééé"}),
json!({"fadeColor": [300, 0, 0]}),
json!({"fadeColor": [1, 2]}),
json!({"speed": 2}),
json!([1, 2]),
] {
assert!(d.merged(&bad).is_err(), "{bad}");
}
let s = d.merged(&json!({"slideDuration": 1e300, "fadeDuration": -5})).unwrap();
assert_eq!((s.slide_duration, s.fade_duration), (SLIDE_MAX, 0.0));
let s = d.merged(&json!({"slideDuration": 0.0, "fadeDuration": 9})).unwrap();
assert_eq!((s.slide_duration, s.fade_duration), (SLIDE_MIN, SLIDE_MIN), "the fade fits in the slide");
let s = d.merged(&json!({"fadeColor": "#FF8000"})).unwrap();
assert_eq!(s.fade_color, Some([255, 128, 0]));
assert_eq!(s.to_json()["fadeColor"], "#ff8000");
assert_eq!(d.merged(&json!({"fadeColor": [1, 2, 3]})).unwrap().fade_color, Some([1, 2, 3]));
// NaN / inf (from a hand-edited file or a caller in-process) take the defaults
let s = PlaybackSettings { slide_duration: f64::NAN, fade_duration: f64::INFINITY, ..d.clone() }.sanitized();
assert_eq!((s.slide_duration, s.fade_duration), (d.slide_duration, d.fade_duration));
// and the timeline never panics on hostile times
let mut p = Playback::new(ids(2), None, s, 0, f64::NAN).unwrap();
p.tick(f64::NAN, all_ready);
p.tick(f64::INFINITY, all_ready);
p.toggle_pause(f64::NEG_INFINITY);
p.step(i32::MIN, f64::NAN);
let _ = p.frame(f64::NAN);
assert!(Playback::new(vec![], None, d, 0, 0.0).is_none());
}
#[test]
fn a_single_photo_repeats_without_changing() {
let mut p = Playback::new(ids(1), None, settings(1.0, 0.5), 1, 0.0).unwrap();
assert_eq!(p.upcoming(2), vec![]);
assert_eq!(p.tick(5.0, all_ready), Tick::Idle);
assert_eq!(p.frame(5.0).from, None);
}
}
+40
View File
@@ -317,3 +317,43 @@ fn reopening_a_library_retires_the_old_cache_so_its_jobs_cannot_write_after_a_cl
drop(s);
let _ = std::fs::remove_dir_all(&lib);
}
#[test]
fn slideshow_playback_settings_persist_with_the_library() {
let lib = temp_dir("slideshow");
{
let mut s = Session::new().with_fs();
s.open_library(&lib, false).unwrap();
let r = s.execute("slideshow.playback", &json!({})).unwrap();
assert_eq!(r["slideDuration"], 4.0, "defaults: {r}");
let r = s
.execute(
"slideshow.playback",
&json!({"slideDuration": 6, "fadeDuration": 2.5, "fadeColor": "#000000", "randomOrder": true, "repeat": false}),
)
.unwrap();
assert_eq!(r["fadeColor"], "#000000");
// a hostile value changes nothing
assert!(s.execute("slideshow.playback", &json!({"slideDuration": "soon", "repeat": true})).is_err());
assert!(!s.slideshow.repeat);
drop(s);
}
let mut s = Session::new().with_fs();
s.open_library(&lib, false).unwrap();
let r = s.execute("slideshow.playback", &json!({})).unwrap();
assert_eq!(
r,
json!({"slideDuration": 6.0, "fadeDuration": 2.5, "fadeColor": "#000000", "randomOrder": true, "repeat": false, "preparePreviews": true})
);
drop(s);
// out-of-range values written by hand are clamped when the library opens
std::fs::write(lib.join("prefs.json"), r#"{"slideshow": {"slideDuration": 1e9, "fadeDuration": -3}}"#).unwrap();
let s = {
let mut s = Session::new().with_fs();
s.open_library(&lib, false).unwrap();
s
};
assert_eq!((s.slideshow.slide_duration, s.slideshow.fade_duration), (120.0, 0.0));
drop(s);
let _ = std::fs::remove_dir_all(&lib);
}
+229
View File
@@ -0,0 +1,229 @@
//! Slideshow slides: `slideshow.layout` (validated, saved with the library) and
//! `slideshow.preview` (a slide composed with the layout, as a PNG).
use std::path::PathBuf;
use serde_json::json;
use crate::Session;
fn temp_dir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("lc-slide-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn png_size(bytes: &[u8]) -> (u32, u32) {
assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n", "a PNG");
let be = |o: usize| u32::from_be_bytes([bytes[o], bytes[o + 1], bytes[o + 2], bytes[o + 3]]);
(be(16), be(20))
}
fn base64_decode(text: &str) -> Vec<u8> {
const A: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let (mut out, mut acc, mut bits) = (Vec::new(), 0u32, 0u32);
for c in text.bytes().take_while(|c| *c != b'=') {
acc = (acc << 6) | A.iter().position(|a| *a == c).unwrap() as u32;
bits += 6;
if bits >= 8 {
bits -= 8;
out.push((acc >> bits) as u8);
acc &= (1 << bits) - 1;
}
}
out
}
#[test]
fn slide_layout_is_validated_and_persists_with_the_library() {
let lib = temp_dir("layout");
{
let mut s = Session::new().with_fs();
s.open_library(&lib, false).unwrap();
let r = s.execute("slideshow.layout", &json!({})).unwrap();
assert_eq!(r["marginLeft"], 0.0, "defaults: {r}");
assert_eq!(r["backdropColor"], "#000000");
let r = s
.execute(
"slideshow.layout",
&json!({"marginTop": 0.1, "strokeBorder": true, "strokeColor": [255, 255, 255], "castShadow": true, "backdropColor": "#203040", "aspect": "4:3"}),
)
.unwrap();
assert_eq!((r["marginLeft"].as_f64(), r["marginBottom"].as_f64()), (Some(0.1), Some(0.1)), "linked margins: {r}");
assert_eq!(r["strokeColor"], "#ffffff");
// a bad value changes nothing (the good keys beside it included)
for bad in [json!({"marginLeft": "wide", "zoomToFill": true}), json!({"backdropColor": "red"}), json!({"glitter": 1})] {
assert!(s.execute("slideshow.layout", &bad).is_err(), "{bad}");
}
assert!(!s.slide_layout.zoom_to_fill);
// out of range: clamped
let r = s.execute("slideshow.layout", &json!({"linkMargins": false, "marginRight": 9, "shadowOpacity": -1})).unwrap();
assert_eq!((r["marginRight"].as_f64(), r["marginLeft"].as_f64(), r["shadowOpacity"].as_f64()), (Some(0.45), Some(0.1), Some(0.0)));
drop(s);
}
let mut s = Session::new().with_fs();
s.open_library(&lib, false).unwrap();
let r = s.execute("slideshow.layout", &json!({})).unwrap();
assert_eq!((r["marginRight"].as_f64(), r["aspect"].as_str(), r["backdropColor"].as_str()), (Some(0.45), Some("4:3"), Some("#203040")));
assert_eq!(r["linkMargins"], false);
drop(s);
// hand-edited values out of range are clamped when the library opens
std::fs::write(lib.join("prefs.json"), r#"{"slideLayout": {"marginTop": 1e9, "shadowAngle": 720, "strokeWidth": -3}}"#).unwrap();
let mut s = Session::new().with_fs();
s.open_library(&lib, false).unwrap();
assert_eq!((s.slide_layout.margin_top, s.slide_layout.shadow_angle, s.slide_layout.stroke_width), (0.45, 0.0, 0.0));
// and the playback settings beside them are untouched by a layout change
let before = s.slideshow.clone();
s.execute("slideshow.layout", &json!({"zoomToFill": true})).unwrap();
assert_eq!(s.slideshow, before);
drop(s);
let _ = std::fs::remove_dir_all(&lib);
}
#[test]
fn slide_preview_returns_the_composed_slide_at_the_asked_size() {
let mut s = Session::with_demo();
let photos = s.visible_cloned();
s.execute("library.select", &json!({"ids": [photos[0].0]})).unwrap();
s.execute("slideshow.layout", &json!({"margins": 0.1, "backdropColor": "#ff0000"})).unwrap();
let r = s.execute("slideshow.preview", &json!({"width": 320, "height": 200})).unwrap();
assert_eq!((r["width"].as_u64(), r["height"].as_u64(), r["id"].as_u64()), (Some(320), Some(200), Some(photos[0].0)));
assert_eq!(r["cell"], json!([20.0, 20.0, 300.0, 180.0]), "10 % of the 200 px short side");
let png = base64_decode(r["png"].as_str().unwrap());
assert_eq!(png_size(&png), (320, 200));
// written to a file instead
let dir = temp_dir("preview");
let path = dir.join("slide.png");
let r = s.execute("slideshow.preview", &json!({"id": photos[1].0, "width": 240, "path": path.to_string_lossy()})).unwrap();
assert_eq!(r["path"], path.to_string_lossy().as_ref());
assert!(r.get("png").is_none());
assert_eq!(png_size(&std::fs::read(&path).unwrap()), (240, 150), "the height from the aspect preview (screen: 16:10)");
s.execute("slideshow.layout", &json!({"aspect": "16:9"})).unwrap();
let r = s.execute("slideshow.preview", &json!({"height": 90})).unwrap();
assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(160), Some(90)));
// the pixels: red margins, the photo inside
let img = s.slide_now(&s.slide_layout.clone(), photos[0], 320, 200).unwrap();
assert_eq!((img.width, img.height), (320, 200));
assert_eq!(img.get(3, 3), [255, 0, 0, 255]);
assert_eq!(img.get(160, 196), [255, 0, 0, 255]);
assert_ne!(img.get(160, 100), [255, 0, 0, 255], "the photo in the cell");
// refused: bad sizes, a photo that isn't there
for bad in [json!({"width": 4}), json!({"height": 100000}), json!({"width": "big"}), json!({"id": 999_999}), json!({"id": "one"})] {
assert!(s.execute("slideshow.preview", &bad).is_err(), "{bad}");
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_background_image_and_the_slide_key() {
let mut s = Session::with_demo();
let photos = s.visible_cloned();
let plain = s.slide_layout.clone();
let job = s.slide_job(photos[0], 320, 200, None).unwrap();
let loupe = s.loupe_job(photos[0], 320, 200, true).unwrap();
assert_ne!(job.key, loupe.key, "a slide is not the photo's render");
// a background image fills the slide around the photo
let bg =
s.slide_layout.merged(&json!({"margins": 0.2, "backdropColor": "#ff0000", "backgroundImage": photos[2].0, "backgroundOpacity": 1})).unwrap();
let img = s.slide_now(&bg, photos[0], 320, 200).unwrap();
assert_ne!(img.get(3, 3), [255, 0, 0, 255], "the background photo, not the colour");
// it is rendered once and reused
assert!(s.slide_backdrop.lock().unwrap().is_some());
let other = s.slide_job_with(&bg, photos[1], 320, 200, None, false).unwrap();
assert_ne!(other.key, s.slide_job_with(&plain, photos[1], 320, 200, None, false).unwrap().key);
// a background photo that isn't in the catalog: the colour alone
let gone = bg.merged(&json!({"backgroundImage": 987_654})).unwrap();
let img = s.slide_now(&gone, photos[0], 320, 200).unwrap();
assert_eq!(img.get(3, 3), [255, 0, 0, 255]);
}
#[test]
fn slide_overlays_are_validated_and_persist_with_the_library() {
let lib = temp_dir("overlays");
{
let mut s = Session::new().with_fs();
s.open_library(&lib, false).unwrap();
let r = s.execute("slideshow.overlays", &json!({})).unwrap();
assert_eq!(r["identityPlate"]["enabled"], false, "defaults: {r}");
assert_eq!(r["texts"], json!([]));
assert!(s.slides_plain());
let r = s
.execute(
"slideshow.overlays",
&json!({"identityPlate": {"enabled": true, "text": "Studio Nord"}, "ratingStars": {"enabled": true}, "addText": {"text": "{caption} {bogus}"}, "showWatermark": true, "watermark": "© Ann"}),
)
.unwrap();
assert_eq!(r["texts"].as_array().map(Vec::len), Some(1));
assert_eq!(r["watermark"]["text"], "© Ann");
assert_eq!(r["warnings"].as_array().map(Vec::len), Some(1), "the unknown token is reported: {r}");
assert!(!s.slides_plain(), "overlays make playback compose its slides");
// a bad value changes nothing (the good keys beside it included)
for bad in
[json!({"identityPlate": {"size": "big"}, "showWatermark": false}), json!({"texts": [{"anchor": "nowhere"}]}), json!({"removeText": 5})]
{
assert!(s.execute("slideshow.overlays", &bad).is_err(), "{bad}");
}
assert!(s.slide_overlays.show_watermark);
drop(s);
}
let mut s = Session::new().with_fs();
s.open_library(&lib, false).unwrap();
let r = s.execute("slideshow.overlays", &json!({})).unwrap();
assert_eq!((r["identityPlate"]["text"].as_str(), r["texts"][0]["text"].as_str()), (Some("Studio Nord"), Some("{caption} {bogus}")));
// the layout beside it is untouched by an overlay change
let before = s.slide_layout.clone();
s.execute("slideshow.overlays", &json!({"removeText": 0})).unwrap();
assert_eq!(s.slide_layout, before);
drop(s);
// hand-edited values out of range are clamped when the library opens
std::fs::write(lib.join("prefs.json"), r#"{"slideOverlays": {"identityPlate": {"size": 1e9, "opacity": -2}, "texts": [{"size": 1e300}]}}"#)
.unwrap();
let mut s = Session::new().with_fs();
s.open_library(&lib, false).unwrap();
assert_eq!((s.slide_overlays.identity_plate.size, s.slide_overlays.identity_plate.opacity), (crate::slide_overlay::PLATE_SIZE_MAX, 0.0));
assert_eq!(s.slide_overlays.texts[0].size, crate::slide_overlay::TEXT_SIZE_MAX);
drop(s);
let _ = std::fs::remove_dir_all(&lib);
}
#[test]
fn slide_preview_draws_the_overlays() {
let mut s = Session::with_demo();
let photos = s.visible_cloned();
s.execute("library.select", &json!({"ids": [photos[0].0]})).unwrap();
s.execute("photo.rate", &json!({"rating": 4, "ids": [photos[0].0]})).unwrap();
s.execute("slideshow.layout", &json!({"margins": 0.2, "backdropColor": "#000000"})).unwrap();
let before = s.slide_job(photos[0], 320, 200, None).unwrap().key;
let plain = s.slide_now(&s.slide_layout.clone(), photos[0], 640, 400).unwrap();
s.execute(
"slideshow.overlays",
&json!({
"identityPlate": {"enabled": true, "text": "LC", "size": 0.1, "color": "#00ff00", "opacity": 1, "anchor": "topLeft", "offsetX": 0.01, "offsetY": 0.01},
"ratingStars": {"enabled": true, "color": "#ffff00", "size": 0.05, "anchor": "bottomRight", "offsetX": -0.01, "offsetY": -0.01, "relativeTo": "slide"},
"addText": {"text": "{file}", "size": 0.05, "color": "#ffffff", "opacity": 1, "anchor": "bottom", "offsetX": 0, "offsetY": -0.01},
}),
)
.unwrap();
assert_ne!(s.slide_job(photos[0], 320, 200, None).unwrap().key, before, "overlays change the slide's render key");
let r = s.execute("slideshow.preview", &json!({"width": 640, "height": 400})).unwrap();
let kinds: Vec<&str> = r["overlays"].as_array().unwrap().iter().filter_map(|o| o["kind"].as_str()).collect();
assert_eq!(kinds, ["identityPlate", "ratingStars", "text0"]);
let img = s.slide_now(&s.slide_layout.clone(), photos[0], 640, 400).unwrap();
let count = |img: &lightcraft_raster::Rgba8, b: &serde_json::Value, want: fn([u8; 4]) -> bool| {
let v: Vec<usize> = (0..4).map(|i| b[i].as_f64().unwrap() as usize).collect();
(v[1]..v[3]).flat_map(|y| (v[0]..v[2]).map(move |x| (x, y))).filter(|&(x, y)| want(img.get(x, y))).count()
};
for (i, (kind, want)) in [
("identityPlate", (|p: [u8; 4]| p[1] > 200 && p[0] < 60) as fn([u8; 4]) -> bool),
("ratingStars", |p| p[0] > 200 && p[1] > 200 && p[2] < 60),
("text0", |p| p[0] > 200 && p[1] > 200 && p[2] > 200),
]
.into_iter()
.enumerate()
{
let b = &r["overlays"][i]["box"];
assert!(count(&img, b, want) > 10, "{kind} is drawn in its box {b}");
assert_eq!(count(&plain, b, want), 0, "{kind}: not there without overlays");
}
}
+225
View File
@@ -0,0 +1,225 @@
//! Text drawing in pure Rust, shared by the export watermark and the slideshow overlays: Inter
//! SemiBold (bundled) first, then the craft-fonts CJK faces as fallbacks ([`faces`]); glyph outlines
//! from `ab_glyph`, vertical CJK cells shaped with `harfrust`. [`TextLayout`] lays a string out
//! once (lines split at `\n`, kerning within a face) and reports its size, so callers can anchor
//! it before drawing; drawing hands out per-pixel coverage, and the caller blends.
//!
//! RTL scripts are laid out in logical order without bidi reordering, and scripts no face covers
//! draw as Inter's missing-glyph box: wrong-looking, never a crash.
use ab_glyph::{Font, FontRef, Glyph, PxScale, ScaleFont, point};
/// Inter SemiBold (OFL, see assets/ATTRIBUTION.md).
pub static TEXT_FONT: &[u8] = include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf");
/// Most characters one layout draws (longer text is cut there), so hostile text stays cheap.
pub const MAX_CHARS: usize = 4000;
/// The text faces: Inter first, then the craft-fonts CJK faces (Mincho first, the watermark's
/// serif look; then any other CJK face — drawn text has no UI language, so every CJK script is
/// offered). Without craft-fonts that is Inter alone, and CJK characters draw as Inter's
/// missing-glyph box.
pub fn faces(craft: &'static [crate::fonts::CraftFont]) -> Vec<FontRef<'static>> {
let mut cjk: Vec<_> = crate::fonts::cjk(craft).collect();
cjk.sort_by_key(|f| !f.is_mincho());
std::iter::once(TEXT_FONT).chain(cjk.into_iter().map(|f| f.bytes)).filter_map(|bytes| FontRef::try_from_slice(bytes).ok()).collect()
}
/// Shape a whole grapheme in one cell. CJK uses vertical origins and forms; Latin stays upright.
/// Unlike Unicode presentation-form characters, GSUB works with BIZ UD fonts too.
pub fn cell_glyphs(font: &FontRef<'_>, data: &harfrust::ShaperData, text: &str, vertical: bool, px: f32, left: f32, top: f32) -> Option<Vec<Glyph>> {
let face = harfrust::FontRef::new(font.font_data()).ok()?;
let mut buf = harfrust::UnicodeBuffer::new();
buf.push_str(text);
buf.set_direction(if vertical { harfrust::Direction::TopToBottom } else { harfrust::Direction::LeftToRight });
buf.guess_segment_properties();
let shaped = data.shaper(&face).build().shape(buf, harfrust::ShapeOptions::new());
if shaped.glyph_infos().is_empty() {
return None;
}
let sf = font.as_scaled(px);
let (sx, sy) = (sf.h_scale_factor(), sf.v_scale_factor());
let advance_x = shaped.glyph_positions().iter().map(|p| p.x_advance as f32 * sx).sum::<f32>();
let advance_y = shaped.glyph_positions().iter().map(|p| -(p.y_advance as f32) * sy).sum::<f32>();
let (mut x, mut y) = if vertical { (left + px / 2.0, top + (px - advance_y) / 2.0) } else { (left + (px - advance_x) / 2.0, top + sf.ascent()) };
let mut glyphs = Vec::new();
for (info, pos) in shaped.glyph_infos().iter().zip(shaped.glyph_positions()) {
let gid = u16::try_from(info.glyph_id).ok().filter(|g| *g != 0)?;
let pen = point(x + pos.x_offset as f32 * sx, y - pos.y_offset as f32 * sy);
glyphs.push(ab_glyph::GlyphId(gid).with_scale_and_position(px, pen));
x += pos.x_advance as f32 * sx;
y -= pos.y_advance as f32 * sy;
}
Some(glyphs)
}
/// A string laid out at one size: its glyphs relative to the box's top-left corner and the box's
/// size in pixels (lines are `px` apart; vertical text: columns `px` wide, right to left).
pub struct TextLayout {
fonts: Vec<FontRef<'static>>,
/// (face, glyph, rotation centre for glyphs turned 90° in vertical text).
glyphs: Vec<(usize, Glyph, Option<(f32, f32)>)>,
pub width: f32,
pub height: f32,
}
impl TextLayout {
/// `text` (cut at [`MAX_CHARS`]) at `px` pixels a line; `None` when there is nothing to draw
/// (empty text, a non-finite or non-positive size).
pub fn new(text: &str, px: f32, vertical: bool, craft: &'static [crate::fonts::CraftFont]) -> Option<TextLayout> {
use unicode_segmentation::UnicodeSegmentation;
if text.is_empty() || !px.is_finite() || px <= 0.0 {
return None;
}
let text = match text.char_indices().nth(MAX_CHARS) {
Some((i, _)) => text.get(..i).unwrap_or(text),
None => text,
};
let fonts = faces(craft);
let latin = fonts.first()?.clone();
let vertical_data: Vec<_> = if vertical {
fonts.iter().map(|font| harfrust::FontRef::new(font.font_data()).ok().map(|face| harfrust::ShaperData::new(&face))).collect()
} else {
Vec::new()
};
let mut glyphs = Vec::new();
let mut x = 0.0f32;
let mut y = 0.0f32;
let mut tw = 0.0f32;
let mut prev = None;
let columns = text.split('\n').count();
let mut column = 0usize;
// Horizontal text keeps scalar layout and kerning; only vertical cells use graphemes.
let cells: Box<dyn Iterator<Item = &str>> =
if vertical { Box::new(text.graphemes(true)) } else { Box::new(text.char_indices().filter_map(|(i, c)| text.get(i..i + c.len_utf8()))) };
for cell in cells {
let Some(original) = cell.chars().next() else { continue };
if cell == "\n" || (vertical && cell == "\r\n") {
if vertical {
column = column.saturating_add(1);
tw = tw.max(y);
y = 0.0;
} else {
tw = tw.max(x);
x = 0.0;
y += px;
}
prev = None;
continue;
}
if original.is_control() {
continue;
}
let cjk = matches!(original as u32, 0x3000..=0x30FF | 0x3400..=0x9FFF | 0xFF01..=0xFF60);
if vertical && (cjk || cell.chars().count() > 1) {
let left = columns.saturating_sub(column.saturating_add(1)) as f32 * px;
let shaped = fonts.iter().zip(&vertical_data).enumerate().find_map(|(face, (font, data))| {
let data = data.as_ref()?;
let cluster = cell_glyphs(font, data, cell, cjk, px, left, y)?;
let substituted = cluster.len() != 1 || cluster.first().is_some_and(|g| g.id != font.glyph_id(original));
if cjk && !substituted && matches!(original, '、' | '。' | 'ー' | '(' | ')' | '「' | '」' | '『' | '』' | '【' | '】')
{
return None;
}
Some((face, cluster))
});
if let Some((face, cluster)) = shaped {
glyphs.extend(cluster.into_iter().map(|g| (face, g, None)));
y += px;
continue;
}
}
let vertical_form = match original {
'、' => '︑',
'。' => '︒',
'(' => '︵',
')' => '︶',
'「' => '﹁',
'」' => '﹂',
'『' => '﹃',
'』' => '﹄',
'【' => '︻',
'】' => '︼',
'…' => '︙',
other => other,
};
let has = |font: &FontRef<'_>, c: char| font.glyph_id(c).0 != 0;
let ch = if vertical && fonts.iter().skip(1).any(|f| has(f, vertical_form)) { vertical_form } else { original };
// The first face with the glyph; Inter (its missing-glyph box) when none has it.
let face = fonts.iter().position(|f| has(f, ch)).unwrap_or(0);
let sf = fonts.get(face).unwrap_or(&latin).as_scaled(PxScale::from(px));
let id = sf.glyph_id(ch);
if vertical {
let left = columns.saturating_sub(column.saturating_add(1)) as f32 * px;
let pen = point(left + (px - sf.h_advance(id)) / 2.0, y + sf.ascent());
let rotation = matches!(original, 'ー' | '—' | '–').then_some((left + px / 2.0, y + px / 2.0));
glyphs.push((face, id.with_scale_and_position(px, pen), rotation));
y += px;
} else {
if let Some((previous_face, previous)) = prev
&& previous_face == face
{
x += sf.kern(previous, id);
}
glyphs.push((face, id.with_scale_and_position(px, point(x, y + sf.ascent())), None));
x += sf.h_advance(id);
prev = Some((face, id));
}
}
let (width, height) = if vertical { (columns as f32 * px, tw.max(y)) } else { (tw.max(x), y + px) };
Some(TextLayout { fonts, glyphs, width, height })
}
/// Call `f(x, y, coverage)` for every pixel the text covers with its top-left corner at
/// (`ox`, `oy`); coordinates may be negative or past the image (the caller clips).
pub fn draw(&self, ox: f32, oy: f32, f: impl FnMut(i32, i32, f32)) {
self.draw_in(ox, oy, f32::INFINITY, f32::INFINITY, f);
}
/// [`Self::draw`], skipping the glyphs that fall wholly outside `0..clip_w × 0..clip_h`
/// (so huge text costs what shows, not what is laid out).
/// Call `f(x, y, coverage)` for every pixel the text covers, in unrounded coordinates relative
/// to the box's top-left corner (for callers that transform them, e.g. a turned watermark).
pub fn draw_points(&self, mut f: impl FnMut(f32, f32, f32)) {
for (face, g, rotation) in &self.glyphs {
if let Some(o) = self.fonts.get(*face).and_then(|font| font.outline_glyph(g.clone())) {
let b = o.px_bounds();
o.draw(|gx, gy, cov| {
let (sx, sy) = (b.min.x + gx as f32, b.min.y + gy as f32);
let (sx, sy) = match rotation {
Some((cx, cy)) => (cx - (sy - cy), cy + (sx - cx)),
None => (sx, sy),
};
f(sx, sy, cov);
});
}
}
}
pub fn draw_in(&self, ox: f32, oy: f32, clip_w: f32, clip_h: f32, mut f: impl FnMut(i32, i32, f32)) {
for (face, g, rotation) in &self.glyphs {
if rotation.is_none() {
// a glyph lies within one line height below its pen's top and an em or so after it
let (px, py) = (ox + g.position.x, oy + g.position.y);
let em = g.scale.y.max(g.scale.x);
if px + 2.0 * em < 0.0 || px - em > clip_w || py + em < 0.0 || py - 2.0 * em > clip_h {
continue;
}
}
if let Some(o) = self.fonts.get(*face).and_then(|font| font.outline_glyph(g.clone())) {
let b = o.px_bounds();
if rotation.is_none() && (ox + b.max.x < 0.0 || ox + b.min.x > clip_w || oy + b.max.y < 0.0 || oy + b.min.y > clip_h) {
continue;
}
o.draw(|gx, gy, cov| {
let (sx, sy) = (b.min.x + gx as f32, b.min.y + gy as f32);
let (sx, sy) = match rotation {
Some((cx, cy)) => (cx - (sy - cy), cy + (sx - cx)),
None => (sx, sy),
};
f((ox + sx) as i32, (oy + sy) as i32, cov);
});
}
}
}
}
+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 }
+541
View File
@@ -0,0 +1,541 @@
//! 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 {
compose_with(photo, backdrop_image, s, out_w, out_h, &mut |_, _| {}).0
}
/// [`compose`], calling `behind(slide, layout)` once the backdrop is drawn and before the shadow,
/// the stroke and the photo (so it draws behind the photo: a slideshow identity plate with
/// "render behind image"), and returning the layout it drew with (for overlays drawn on top).
pub fn compose_with(
photo: &Rgba8,
backdrop_image: Option<&Rgba8>,
s: &SlideSettings,
out_w: usize,
out_h: usize,
behind: &mut dyn FnMut(&mut Rgba8, &Layout),
) -> (Rgba8, Layout) {
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);
}
});
}
behind(&mut out, &l);
// 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, l)
}
#[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 behind_draws_under_the_photo_and_the_layout_comes_back() {
let s = with(json!({"margins": 0.2}));
let mut seen = None;
let (img, l) = compose_with(&solid(10, 10, GREEN), None, &s, 200, 100, &mut |out, l| {
seen = Some(*l);
out.data.fill([255, 0, 0, 255]);
});
assert_eq!(seen, Some(l), "the hook gets the layout the slide is drawn with");
assert_eq!(px(&img, 2, 2), RED, "the hook's paint stays on the backdrop");
assert_eq!(px(&img, 100, 50), GREEN, "and the photo covers it");
}
#[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, compose_with, 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
@@ -20,6 +20,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 }
+39 -1
View File
@@ -2123,5 +2123,43 @@
"Destination library": "Zielbibliothek",
"Show in Library": "In Bibliothek anzeigen",
"Lightroom catalogs import into the open library; no separate catalog folder is created.": "Lightroom-Kataloge werden in die geöffnete Bibliothek importiert; es wird kein eigener Katalogordner erstellt.",
"These photos are only in memory. Open a library on disk and import the catalog again to save them.": "Diese Fotos befinden sich nur im Arbeitsspeicher. Öffne eine Bibliothek auf dem Datenträger und importiere den Katalog erneut, um sie zu speichern."
"These photos are only in memory. Open a library on disk and import the catalog again to save them.": "Diese Fotos befinden sich nur im Arbeitsspeicher. Öffne eine Bibliothek auf dem Datenträger und importiere den Katalog erneut, um sie zu speichern.",
"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.",
"Overlays": "Überlagerungen",
"Identity plate": "Erkennungstafel",
"Your name or studio": "Ihr Name oder Studio",
"Anchored to": "Verankert an",
"Slide": "Folie",
"Render behind image": "Hinter dem Bild rendern",
"Rating stars": "Sternebewertung",
"Text overlays": "Textüberlagerungen",
"Add text overlay": "Textüberlagerung hinzufügen",
"Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "Token: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Anderer Text erscheint wie eingegeben.",
"Use export watermark": "Export-Wasserzeichen verwenden",
"The export watermark, drawn on the photo and sized relative to it.": "Das Export-Wasserzeichen, auf das Foto gezeichnet und relativ zu ihm skaliert."
}
+39 -1
View File
@@ -2000,5 +2000,43 @@
"These photos are only in memory. Open a library on disk and import the catalog again to save them.": "Estas fotos solo están en memoria. Abre una biblioteca en disco e importa el catálogo de nuevo para guardarlas.",
"Show in Library": "Mostrar en la biblioteca",
"Lightroom catalogs import into the open library; no separate catalog folder is created.": "Los catálogos de Lightroom se importan a la biblioteca abierta; no se crea una carpeta de catálogo separada.",
"Destination library": "Biblioteca de destino"
"Destination library": "Biblioteca de destino",
"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.",
"Overlays": "Superposiciones",
"Identity plate": "Placa de identidad",
"Your name or studio": "Tu nombre o estudio",
"Anchored to": "Anclado a",
"Slide": "Diapositiva",
"Render behind image": "Representar detrás de la imagen",
"Rating stars": "Estrellas de valoración",
"Text overlays": "Superposiciones de texto",
"Add text overlay": "Añadir superposición de texto",
"Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. El resto del texto se muestra tal cual.",
"Use export watermark": "Usar la marca de agua de exportación",
"The export watermark, drawn on the photo and sized relative to it.": "La marca de agua de exportación, dibujada sobre la foto y escalada respecto a ella."
}
+39 -1
View File
@@ -1962,5 +1962,43 @@
"These photos are only in memory. Open a library on disk and import the catalog again to save them.": "Ces photos sont uniquement en mémoire. Ouvrez une bibliothèque sur disque et importez à nouveau le catalogue pour les enregistrer.",
"Show in Library": "Afficher dans la bibliothèque",
"Lightroom catalogs import into the open library; no separate catalog folder is created.": "Les catalogues Lightroom sont importés dans la bibliothèque ouverte ; aucun dossier de catalogue distinct n’est créé.",
"Destination library": "Bibliothèque de destination"
"Destination library": "Bibliothèque de destination",
"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.",
"Overlays": "Incrustations",
"Identity plate": "Plaque d'identification",
"Your name or studio": "Votre nom ou studio",
"Anchored to": "Ancré à",
"Slide": "Diapositive",
"Render behind image": "Afficher derrière l'image",
"Rating stars": "Étoiles de notation",
"Text overlays": "Incrustations de texte",
"Add text overlay": "Ajouter une incrustation de texte",
"Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "Jetons : {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Le reste du texte s'affiche tel quel.",
"Use export watermark": "Utiliser le filigrane d'exportation",
"The export watermark, drawn on the photo and sized relative to it.": "Le filigrane d'exportation, dessiné sur la photo et dimensionné par rapport à elle."
}
+39 -1
View File
@@ -1966,5 +1966,43 @@
"These photos are only in memory. Open a library on disk and import the catalog again to save them.": "これらの写真はメモリ内にのみあります。ディスク上のライブラリを開き、カタログを再度読み込んで保存してください。",
"Show in Library": "ライブラリに表示",
"Lightroom catalogs import into the open library; no separate catalog folder is created.": "Lightroomカタログは開いているライブラリに読み込まれます。別のカタログフォルダーは作成されません。",
"Destination library": "読み込み先ライブラリ"
"Destination library": "読み込み先ライブラリ",
"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.": "選択した写真をスライドとして表示します。再生では画面全体に表示され、縦横比プレビューはこのプレビューだけを変更します。",
"Overlays": "オーバーレイ",
"Identity plate": "ID プレート",
"Your name or studio": "あなたの名前またはスタジオ名",
"Anchored to": "基準",
"Slide": "スライド",
"Render behind image": "画像の背後に表示",
"Rating stars": "レーティングの星",
"Text overlays": "テキストオーバーレイ",
"Add text overlay": "テキストオーバーレイを追加",
"Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "トークン: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}。その他の文字は入力どおりに表示されます。",
"Use export watermark": "書き出しの透かしを使用",
"The export watermark, drawn on the photo and sized relative to it.": "書き出しの透かし。写真の上に、写真に対する比率で描かれます。"
}
+39 -1
View File
@@ -2003,5 +2003,43 @@
"These photos are only in memory. Open a library on disk and import the catalog again to save them.": "Estas fotos estão apenas na memória. Abra uma biblioteca no disco e importe o catálogo novamente para salvá-las.",
"Show in Library": "Mostrar na biblioteca",
"Lightroom catalogs import into the open library; no separate catalog folder is created.": "Os catálogos do Lightroom são importados para a biblioteca aberta; nenhuma pasta de catálogo separada é criada.",
"Destination library": "Biblioteca de destino"
"Destination library": "Biblioteca de destino",
"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.",
"Overlays": "Sobreposições",
"Identity plate": "Placa de identidade",
"Your name or studio": "Seu nome ou estúdio",
"Anchored to": "Ancorado em",
"Slide": "Slide",
"Render behind image": "Renderizar atrás da imagem",
"Rating stars": "Estrelas de classificação",
"Text overlays": "Sobreposições de texto",
"Add text overlay": "Adicionar sobreposição de texto",
"Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. O restante do texto aparece como digitado.",
"Use export watermark": "Usar a marca d'água de exportação",
"The export watermark, drawn on the photo and sized relative to it.": "A marca d'água de exportação, desenhada sobre a foto e dimensionada em relação a ela."
}
+39 -1
View File
@@ -2156,5 +2156,43 @@
"Destination library": "Библиотека назначения",
"Show in Library": "Показать в библиотеке",
"Lightroom catalogs import into the open library; no separate catalog folder is created.": "Каталоги Lightroom импортируются в открытую библиотеку; отдельная папка каталога не создаётся.",
"These photos are only in memory. Open a library on disk and import the catalog again to save them.": "Эти фотографии находятся только в памяти. Откройте библиотеку на диске и импортируйте каталог повторно, чтобы сохранить их."
"These photos are only in memory. Open a library on disk and import the catalog again to save them.": "Эти фотографии находятся только в памяти. Откройте библиотеку на диске и импортируйте каталог повторно, чтобы сохранить их.",
"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.": "Выбранное фото в виде слайда. При воспроизведении слайд заполняет экран; предпросмотр пропорций меняет только этот предпросмотр.",
"Overlays": "Наложения",
"Identity plate": "Фирменная табличка",
"Your name or studio": "Ваше имя или студия",
"Anchored to": "Привязка к",
"Slide": "Слайд",
"Render behind image": "Рисовать за изображением",
"Rating stars": "Звёзды рейтинга",
"Text overlays": "Текстовые наложения",
"Add text overlay": "Добавить текстовое наложение",
"Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "Токены: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Остальной текст выводится как написан.",
"Use export watermark": "Использовать водяной знак экспорта",
"The export watermark, drawn on the photo and sized relative to it.": "Водяной знак экспорта, рисуется на фото и масштабируется относительно него."
}
+39 -1
View File
@@ -2156,5 +2156,43 @@
"Destination library": "Бібліотека призначення",
"Show in Library": "Показати в бібліотеці",
"Lightroom catalogs import into the open library; no separate catalog folder is created.": "Каталоги Lightroom імпортуються у відкриту бібліотеку; окрема папка каталогу не створюється.",
"These photos are only in memory. Open a library on disk and import the catalog again to save them.": "Ці фотографії є лише в пам’яті. Відкрийте бібліотеку на диску та імпортуйте каталог повторно, щоб зберегти їх."
"These photos are only in memory. Open a library on disk and import the catalog again to save them.": "Ці фотографії є лише в пам’яті. Відкрийте бібліотеку на диску та імпортуйте каталог повторно, щоб зберегти їх.",
"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.": "Вибране фото як слайд. Під час відтворення слайд заповнює екран; попередній перегляд пропорцій змінює лише цей перегляд.",
"Overlays": "Накладання",
"Identity plate": "Фірмова табличка",
"Your name or studio": "Ваше ім'я або студія",
"Anchored to": "Прив'язка до",
"Slide": "Слайд",
"Render behind image": "Малювати за зображенням",
"Rating stars": "Зірки рейтингу",
"Text overlays": "Текстові накладання",
"Add text overlay": "Додати текстове накладання",
"Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "Токени: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Решта тексту виводиться як написано.",
"Use export watermark": "Використати водяний знак експорту",
"The export watermark, drawn on the photo and sized relative to it.": "Водяний знак експорту, малюється на фото й масштабується відносно нього."
}
+39 -1
View File
@@ -2002,5 +2002,43 @@
"These photos are only in memory. Open a library on disk and import the catalog again to save them.": "这些照片仅保存在内存中。请打开磁盘上的图库并再次导入目录以保存照片。",
"Show in Library": "在图库中显示",
"Lightroom catalogs import into the open library; no separate catalog folder is created.": "Lightroom 目录会导入到当前打开的图库中,不会创建单独的目录文件夹。",
"Destination library": "目标图库"
"Destination library": "目标图库",
"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.": "将所选照片显示为幻灯片。播放时会填满屏幕;长宽比预览只改变此预览。",
"Overlays": "叠加",
"Identity plate": "身份标识",
"Your name or studio": "你的名字或工作室",
"Anchored to": "锚定到",
"Slide": "幻灯片",
"Render behind image": "在图像后面渲染",
"Rating stars": "评级星标",
"Text overlays": "文本叠加",
"Add text overlay": "添加文本叠加",
"Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "标记:{file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}。其他文字按原样显示。",
"Use export watermark": "使用导出水印",
"The export watermark, drawn on the photo and sized relative to it.": "导出水印,绘制在照片上,大小相对于照片。"
}
+39 -1
View File
@@ -1964,5 +1964,43 @@
"These photos are only in memory. Open a library on disk and import the catalog again to save them.": "這些相片僅保存在記憶體中。請開啟磁碟上的圖庫並再次匯入目錄以儲存相片。",
"Show in Library": "在圖庫中顯示",
"Lightroom catalogs import into the open library; no separate catalog folder is created.": "Lightroom 目錄會匯入目前開啟的圖庫,不會建立獨立的目錄資料夾。",
"Destination library": "目標圖庫"
"Destination library": "目標圖庫",
"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.": "將所選照片顯示為投影片。播放時會填滿螢幕;長寬比預覽只會改變此預覽。",
"Overlays": "疊加",
"Identity plate": "身分標識",
"Your name or studio": "你的名字或工作室",
"Anchored to": "錨定到",
"Slide": "幻燈片",
"Render behind image": "在影像後方繪製",
"Rating stars": "評等星號",
"Text overlays": "文字疊加",
"Add text overlay": "新增文字疊加",
"Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "標記:{file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}。其他文字按原樣顯示。",
"Use export watermark": "使用匯出浮水印",
"The export watermark, drawn on the photo and sized relative to it.": "匯出浮水印,繪製在照片上,大小相對於照片。"
}
+5
View File
@@ -292,6 +292,11 @@ impl Headless {
self.frames
}
/// The simulated clock (seconds) the next frame runs at.
pub fn time(&self) -> f64 {
self.time
}
/// Every piece of text the last frame painted ([`HeadlessView::painted_text`]).
pub fn painted_text(&self) -> Vec<String> {
self.view.painted_text()
+9
View File
@@ -83,6 +83,10 @@ pub enum Icon {
Chat,
/// Face boxes on/off: four corner brackets round a small face.
FaceBox,
/// Play (a triangle pointing right).
Play,
/// Pause (two bars).
Pause,
/// Colour grading, all three wheels: three small circles.
GradeAll,
/// Colour grading, shadows wheel: a filled circle.
@@ -497,6 +501,11 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, color: Color32, turn: Option<
pen.arc(10.0, 18.0, 6.5, 200.0, 340.0);
pen.rect(2.5, 2.5, 17.5, 17.5, 2.0);
}
Play => pen.fill(&[(6.0, 4.0), (6.0, 16.0), (16.0, 10.0)], color),
Pause => {
pen.rect_fill(5.0, 4.0, 8.5, 16.0, 0.5);
pen.rect_fill(11.5, 4.0, 15.0, 16.0, 0.5);
}
FaceBox => {
pen.line(&[(2.5, 7.0), (2.5, 2.5), (7.0, 2.5)]);
pen.line(&[(13.0, 2.5), (17.5, 2.5), (17.5, 7.0)]);
+7 -22
View File
@@ -115,6 +115,8 @@ mod tests_sam_setup;
#[cfg(test)]
mod tests_scroll;
#[cfg(test)]
mod tests_slideshow;
#[cfg(test)]
mod tests_switch_library;
#[cfg(test)]
mod tests_sync;
@@ -645,28 +647,9 @@ impl LightcraftApp {
}
/// Show a transient toast at the bottom of the canvas (like the reference app's HUD).
/// Advance a running slideshow (wrapping around at the end).
/// Advance a running slideshow.
fn slideshow_tick(&mut self, ctx: &egui::Context) {
let Some((interval, due, paused)) = self.ui.slideshow else { return };
if !self.ui.fullscreen {
self.ui.slideshow = None;
return;
}
if paused {
return;
}
let now = ctx.input(|i| i.time);
if now >= due {
let vis = self.session.visible_cloned();
if let Some(cur) = self.session.active()
&& !vis.is_empty()
{
let i = vis.iter().position(|x| *x == cur).map_or(0, |i| (i + 1) % vis.len());
let _ = self.run("library.select", serde_json::json!({"ids": [vis[i].0]}));
}
self.ui.slideshow = Some((interval, now + interval, false));
}
ctx.request_repaint_after(std::time::Duration::from_secs_f64((due - now).clamp(0.05, interval)));
panels::slideshow::tick(self, ctx);
}
/// Announce the end of a Build Previews run.
@@ -1329,7 +1312,9 @@ impl LightcraftApp {
self.place_names_outcome(&ctx);
if self.ui.fullscreen {
// full-screen preview: the photo alone on black
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(egui::Color32::BLACK)).show(ui, |ui| panels::detail::show(self, ui));
egui::CentralPanel::default()
.frame(egui::Frame::NONE.fill(egui::Color32::BLACK))
.show(ui, |ui| if self.ui.slideshow.is_some() { panels::slideshow::show(self, ui) } else { panels::detail::show(self, ui) });
panels::second::show(self, &ctx);
panels::notices::show(self, &ctx);
panels::dialogs::show(self, &ctx);
+21 -18
View File
@@ -148,6 +148,12 @@ pub const UI_COMMANDS: &[UiCommand] = &[
("view.sidebarMoveSection", "Move Sidebar Section", None, ""),
("view.sidebarReset", "Reset Sidebar Sections", None, ""),
("view.slideshow", "Slideshow", Some("Cmd+Alt+Enter"), "View"),
("view.slideshowOptions", "Slideshow Options…", None, "View"),
// the slideshow's playback controls (Space, ←/→ and Esc while it plays)
("slideshow.togglePause", "Pause / Resume Slideshow", None, ""),
("slideshow.next", "Next Slide", None, ""),
("slideshow.previous", "Previous Slide", None, ""),
("slideshow.end", "End Slideshow", None, ""),
("view.secondWindow", "Second Window", Some("Cmd+F11"), "Window"),
("tool.keywordPainter", "Keyword Painter", None, ""),
("view.gridInfo", "Grid Info", None, ""),
@@ -586,25 +592,21 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
Ok(Value::Null)
}
"view.slideshow" => {
// {interval?: seconds (4)}: the photos in view, full screen, one after another
let interval = p.get("interval").and_then(Value::as_f64).unwrap_or(4.0).clamp(0.5, 120.0);
if app.session.active().is_none() {
let first = app.session.visible_cloned().first().copied();
match first {
Some(f) => {
let _ = app.run("library.select", json!({"ids": [f.0]}));
}
None => return Some(Err("no photos to show".into())),
}
}
// the photos in view, full screen, with the playback settings (see panels::slideshow)
let now = ctx.input(|i| i.time);
app.ui.slideshow = Some((interval, now + interval, false));
app.ui.fullscreen = true;
app.ui.zoom = Zoom::Fit;
app.ui.tool.clear();
let _ = app.session.end_interaction();
app.toast(&ctx, crate::i18n::tr("Slideshow · Space pauses · Esc ends"));
Ok(json!({"interval": interval}))
let r = crate::panels::slideshow::start(app, p, now);
if r.is_ok() {
app.toast(&ctx, crate::i18n::tr("Slideshow · Space pauses · Esc ends"));
}
r
}
"slideshow.togglePause" | "slideshow.next" | "slideshow.previous" | "slideshow.end" => {
let now = ctx.input(|i| i.time);
crate::panels::slideshow::command(app, id, now)
}
"view.slideshowOptions" => {
app.ui.dialog = Some(Dialog::Settings { tab: "slideshow".into() });
Ok(Value::Null)
}
"view.fullScreenPreview" => {
app.ui.fullscreen = !app.ui.fullscreen;
@@ -1931,6 +1933,7 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool {
"file.restoreLibrary" => app.services.restore_library.is_some(),
"app.clearRecentLibraries" => !app.ui.settings.recent_libraries.is_empty(),
"compare.swap" | "compare.makeSelect" | "compare.set" => app.ui.view == ViewMode::Compare,
"slideshow.togglePause" | "slideshow.next" | "slideshow.previous" | "slideshow.end" => app.ui.slideshow.is_some(),
s if s.starts_with("dialog.merge") || (s.starts_with("merge.") && s.ends_with("Last")) => {
app.session.targets(&serde_json::json!({})).len() >= 2 && app.merge.final_task.is_none()
}
+6 -1
View File
@@ -27,6 +27,9 @@ pub mod right;
pub mod rules_editor;
pub mod second;
pub mod settings;
pub mod slide_layout;
pub mod slide_overlays;
pub mod slideshow;
pub mod strip;
pub mod topbar;
pub mod unnamed;
@@ -86,7 +89,9 @@ pub fn toast(app: &mut LightcraftApp, ctx: &egui::Context) {
let foreground = foreground.gamma_multiply(fade);
let galley = painter.layout(text, t.font(14.0), foreground, wrap);
let size = vec2(galley.size().x + 40.0, (galley.size().y + 22.0).max(40.0));
let r = Rect::from_center_size(pos2(canvas.center().x, canvas.bottom() - 60.0), size);
// a slideshow's playback controls sit at the bottom: the toast goes to the top there, clear of them
let y = if app.ui.slideshow.is_some() { canvas.top() + 60.0 } else { canvas.bottom() - 60.0 };
let r = Rect::from_center_size(pos2(canvas.center().x, y), size);
painter.rect_filled(r, 6.0, background.gamma_multiply(fade));
painter.galley(r.center() - galley.size() / 2.0, galley, foreground);
ctx.request_repaint_after(std::time::Duration::from_millis(30));
+124 -4
View File
@@ -1,12 +1,14 @@
//! The Settings dialog (⌘,): General, Import, Performance, Display, Interface, Faces, AI Denoise.
//! The Settings dialog (⌘,): General, Import, Performance, Display, Interface, Slideshow, Faces,
//! AI Denoise.
//!
//! Changes apply immediately (no OK/Cancel). Where they are stored:
//! - **app settings** ([`crate::state::AppSettings`]: startup view, delete confirmation, GPU,
//! preview size, filmstrip/grid badges, last library, monitor profile) and Auto Advance live in
//! the UI state,
//! saved by the host in its config folder (`ui.json`);
//! - **library settings** (import defaults, XMP sidecars, thumbnail cache size) go through the
//! `library.preferences` / `library.xmpPreferences` commands into the library's `prefs.json`,
//! - **library settings** (import defaults, XMP sidecars, thumbnail cache size, slideshow
//! playback) go through the `library.preferences` / `library.xmpPreferences` /
//! `slideshow.playback` commands into the library's `prefs.json`,
//! so they travel with the library.
use egui::RichText;
@@ -24,6 +26,7 @@ pub const TABS: &[(&str, &str)] = &[
("performance", "Performance"),
("display", "Display"),
("interface", "Interface"),
("slideshow", "Slideshow"),
("faces", "Faces"),
("denoise", "AI Denoise"),
];
@@ -52,6 +55,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, tab: &mut String) {
"performance" => performance_tab(app, ui, &t),
"display" => display_tab(app, ui, &t),
"interface" => interface_tab(app, ui, &t),
"slideshow" => slideshow_tab(app, ui, &t),
"faces" => super::faces::settings_tab(app, ui, &t),
"denoise" => super::denoise::settings_tab(app, ui, &t),
_ => general_tab(app, ui, &t),
@@ -63,7 +67,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);
@@ -663,6 +667,122 @@ fn interface_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
});
}
// ----------------------------------------------------------------------------------- Slideshow
/// A playback duration slider (`slider:{id}`). While it is dragged the value changes in memory
/// (the slideshow reads it); letting go saves it through `slideshow.playback`.
fn duration_slider(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, key: &str, value: f64, range: std::ops::RangeInclusive<f64>) {
let mut v = value;
let r = ui.add(egui::Slider::new(&mut v, range).logarithmic(key == "slideDuration").suffix(" s").max_decimals(1));
register(ui.ctx(), format!("slider:{id}"), r.rect);
if r.changed()
&& let Ok(next) = app.session.slideshow.merged(&json!({ key: v }))
{
app.session.slideshow = next;
}
if r.drag_stopped() || (r.changed() && !r.dragged()) {
let _ = app.run("slideshow.playback", json!({ key: v }));
}
}
/// 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(6.0);
super::slide_overlays::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();
row(ui, t, crate::i18n::tr("Slide duration"), |ui| {
duration_slider(app, ui, "slideshowSlide", "slideDuration", s.slide_duration, SLIDE_MIN..=SLIDE_MAX)
});
row(ui, t, crate::i18n::tr("Fade duration"), |ui| {
duration_slider(app, ui, "slideshowFade", "fadeDuration", s.fade_duration, 0.0..=FADE_MAX.min(s.slide_duration))
});
hint(ui, t, crate::i18n::tr("A slide stays up for its duration, its fade in included. A fade of 0 cuts."));
row(ui, t, crate::i18n::tr("Fade"), |ui| {
#[derive(Clone, Copy, PartialEq)]
enum Fade {
Cross,
Black,
White,
Color,
}
let mut f = match s.fade_color {
None => Fade::Cross,
Some([0, 0, 0]) => Fade::Black,
Some([255, 255, 255]) => Fade::White,
Some(_) => Fade::Color,
};
let opts = [(Fade::Cross, "Crossfade"), (Fade::Black, "Through black"), (Fade::White, "Through white"), (Fade::Color, "Through a colour")];
if choices(ui, "settingsSlideshowFade", &opts, &mut f) {
let c = match f {
Fade::Cross => Value::Null,
Fade::Black => json!("#000000"),
Fade::White => json!("#ffffff"),
Fade::Color => json!([64, 64, 64]),
};
let _ = app.run("slideshow.playback", json!({"fadeColor": c}));
}
if f == Fade::Color
&& let Some(mut c) = s.fade_color
{
let r = egui::color_picker::color_edit_button_srgb(ui, &mut c);
register(ui.ctx(), "button:slideshowFadeColor", r.rect);
if r.changed() {
let _ = app.run("slideshow.playback", json!({"fadeColor": c}));
}
}
});
let mut v = s.random_order;
if check(ui, "settings.slideshowRandom", &mut v, "Random order") {
let _ = app.run("slideshow.playback", json!({"randomOrder": v}));
}
let mut v = s.repeat;
if check(ui, "settings.slideshowRepeat", &mut v, "Repeat (off: the slideshow ends after the last photo)") {
let _ = app.run("slideshow.playback", json!({"repeat": v}));
}
let mut v = s.prepare_previews;
if check(ui, "settings.slideshowPrepare", &mut v, "Prepare previews in advance") {
let _ = app.run("slideshow.playback", json!({"preparePreviews": v}));
}
hint(
ui,
t,
crate::i18n::tr(
"Renders the next slides while the current one is up, so fades never wait. Off: each slide renders when it is due (less memory and work).",
),
);
hint(
ui,
t,
crate::i18n::tr("Slideshow in the View menu plays the photos in view from the selected one. These settings are saved with the library."),
);
}
/// Appearance Mode (`button:settingsAppearance-{0,1,2}`: Auto, Dark, Light) above the light and
/// dark theme cards (`radio:theme-{id}`), each with a preview of the chosen theme. The card in
/// use is outlined. Applied on the next frame ([`LightcraftApp::sync_theme`]).
+238
View File
@@ -0,0 +1,238 @@
//! 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 (with the
//! overlays of [`super::slide_overlays`]).
//!
//! 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));
// the overlays and what they show of the photo (its rating, its metadata) change it too
let shown = app.session.catalog.photo(id).filter(|_| app.session.slide_overlays.any()).map_or(0, |p| {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
p.rating.hash(&mut h);
serde_json::to_string(&p.meta).unwrap_or_default().hash(&mut h);
h.finish()
});
let overlays = if app.session.slide_overlays.any() { app.session.slide_overlays.pixel_key() ^ shown.rotate_left(5) } else { 0 };
let key = s.pixel_key() ^ id.0.rotate_left(17) ^ ((pw as u64) << 32 | ph as u64) ^ overlays.rotate_left(29);
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);
}
+265
View File
@@ -0,0 +1,265 @@
//! Settings ▸ Slideshow ▸ Overlays (Lightroom Classic's Slideshow Overlays panel): the identity
//! plate (text or an image file), rating stars, text overlays with tokens and the watermark.
//!
//! Every control goes through `slideshow.overlays` (validated there, saved with the library), like
//! the Layout sections beside it ([`super::slide_layout`]); the live preview next to them shows the
//! overlays on the active photo's slide. Sliders change the settings in memory while dragged and
//! save them when let go; text fields save when the edit ends (Return or leaving the field).
use egui::RichText;
use lightcraft_engine::export::Anchor;
use lightcraft_engine::slide_overlay::{
ANCHORS, OFFSET_MAX, OverlaySettings, PLATE_SIZE_MAX, Placement, RelativeTo, SHADOW_OFFSET_MAX, SHADOW_RADIUS_MAX, STAR_SIZE_MAX, TEXT_SIZE_MAX,
};
use serde_json::{Value, json};
use super::settings::{check, choices, heading, hint, row};
use crate::LightcraftApp;
use crate::text_field::TextField;
use crate::theme::Tokens;
use crate::widgets::register;
#[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.overlays", p);
}
/// A slider (`slider:{id}`) over `lo..=hi`; `wrap(value)` makes the `slideshow.overlays` request.
fn slider(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, value: f64, lo: f64, hi: f64, unit: Unit, wrap: &dyn Fn(f64) -> Value) {
let (k, suffix) = match unit {
Unit::Percent => (100.0, " %"),
Unit::Degrees => (1.0, "°"),
};
let mut v = value * k;
let r = ui.add(egui::Slider::new(&mut v, lo * k..=hi * k).suffix(suffix).max_decimals(1));
register(ui.ctx(), format!("slider:{id}"), r.rect);
let p = wrap(v / k);
if r.changed()
&& let Ok(next) = app.session.slide_overlays.merged(&p)
{
app.session.slide_overlays = next;
}
if r.drag_stopped() || (r.changed() && !r.dragged()) {
set(app, p);
}
}
/// A colour button (`button:{id}`).
fn colour(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, value: [u8; 3], wrap: &dyn Fn([u8; 3]) -> Value) {
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, wrap(c));
}
}
/// A checkbox (`check:settings.{id}`); true when it is on.
fn toggle(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, value: bool, label: &str, wrap: &dyn Fn(bool) -> Value) -> bool {
let mut v = value;
if check(ui, &format!("settings.{id}"), &mut v, label) {
set(app, wrap(v));
}
v
}
/// A one-line text field (`field:{id}`), saved when the edit ends.
fn text(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, value: &str, hint: &str, wrap: &dyn Fn(&str) -> Value) {
let mem = egui::Id::new(("slideshow-overlay-field", id));
let mut s: String = ui.data(|d| d.get_temp(mem)).unwrap_or_else(|| value.to_string());
let widget = format!("field:{id}");
let r = TextField::singleline(&widget, &mut s).hint(crate::i18n::tr(hint)).width(ui.available_width().clamp(80.0, 260.0)).show(ui);
if r.editing {
ui.data_mut(|d| d.insert_temp(mem, s.clone()));
} else {
ui.data_mut(|d| d.remove::<String>(mem));
}
if r.committed() && s != value {
set(app, wrap(&s));
}
}
/// The nine anchors as a 3 × 3 grid of buttons (`button:{id}-{0..8}`); true when one is picked.
fn anchor_grid(ui: &mut egui::Ui, id: &str, value: &mut Anchor) -> bool {
const GLYPHS: [&str; 9] = ["↖", "↑", "↗", "←", "•", "→", "↙", "↓", "↘"];
let mut changed = false;
ui.vertical(|ui| {
ui.spacing_mut().item_spacing = egui::vec2(2.0, 2.0);
for r in 0..3 {
ui.horizontal(|ui| {
for c in 0..3 {
let i = r * 3 + c;
let (Some(a), Some(g)) = (ANCHORS.get(i), GLYPHS.get(i)) else { continue };
if crate::widgets::text_button(ui, &format!("{id}-{i}"), g, *value == *a).clicked() && *value != *a {
*value = *a;
changed = true;
}
}
});
}
});
changed
}
/// Anchor, offsets and what the overlay is anchored to; `wrap` turns placement keys into the request.
fn placement(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, id: &str, p: &Placement, photo_too: bool, wrap: &dyn Fn(Value) -> Value) {
row(ui, t, "Position", |ui| {
let mut a = p.anchor;
if anchor_grid(ui, &format!("{id}Anchor"), &mut a) {
set(app, wrap(json!({"anchor": lightcraft_engine::slide_overlay::anchor_id(a)})));
}
});
if photo_too {
row(ui, t, "Anchored to", |ui| {
let mut r = p.relative_to;
if choices(ui, &format!("{id}RelativeTo"), &[(RelativeTo::Slide, "Slide"), (RelativeTo::Photo, "Photo")], &mut r) {
let v = if r == RelativeTo::Photo { "photo" } else { "slide" };
set(app, wrap(json!({"relativeTo": v})));
}
});
}
row(ui, t, "Offset X", |ui| {
slider(app, ui, &format!("{id}OffsetX"), p.offset_x, -OFFSET_MAX / 2.0, OFFSET_MAX / 2.0, Unit::Percent, &|v| wrap(json!({"offsetX": v})))
});
row(ui, t, "Offset Y", |ui| {
slider(app, ui, &format!("{id}OffsetY"), p.offset_y, -OFFSET_MAX / 2.0, OFFSET_MAX / 2.0, Unit::Percent, &|v| wrap(json!({"offsetY": v})))
});
}
/// The request changing text overlay `i` of `n` by `change` (the others kept).
fn text_change(i: usize, n: usize, change: Value) -> Value {
let list: Vec<Value> = (0..n).map(|k| if k == i { change.clone() } else { json!({}) }).collect();
json!({ "texts": list })
}
/// The Overlays sections.
pub fn sections(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
let s: OverlaySettings = app.session.slide_overlays.clone();
heading(ui, t, "Overlays");
// identity plate
let p = s.identity_plate.clone();
let plate = |v: Value| json!({ "identityPlate": v });
if toggle(app, ui, "slideshowPlate", p.enabled, "Identity plate", &|v| plate(json!({"enabled": v}))) {
let mem = egui::Id::new("slideshow-plate-graphic");
let mut graphic = !p.image.is_empty() || ui.data(|d| d.get_temp::<bool>(mem)).unwrap_or(false);
let before = graphic;
row(ui, t, "Style", |ui| choices(ui, "slideshowPlateStyle", &[(false, "Text"), (true, "Image")], &mut graphic));
if graphic != before {
ui.data_mut(|d| d.insert_temp(mem, graphic));
if !graphic && !p.image.is_empty() {
set(app, plate(json!({"image": ""})));
}
}
if graphic {
row(ui, t, "Image", |ui| {
if app.services.pick_files.is_some()
&& crate::widgets::text_button(ui, "slideshowPlateChoose", "Choose…", false).clicked()
&& let Some(f) = app.services.pick_files.as_mut().and_then(|pick| pick().into_iter().next())
{
set(app, plate(json!({"image": f})));
}
text(app, ui, "slideshowPlateImage", &p.image, "logo.png", &|v| plate(json!({"image": v})));
});
} else {
row(ui, t, "Text", |ui| text(app, ui, "slideshowPlateText", &p.text, "Your name or studio", &|v| plate(json!({"text": v}))));
row(ui, t, "Colour", |ui| colour(app, ui, "slideshowPlateColor", p.color, &|c| plate(json!({"color": c}))));
}
row(ui, t, "Size", |ui| slider(app, ui, "slideshowPlateSize", p.size, 0.01, PLATE_SIZE_MAX, Unit::Percent, &|v| plate(json!({"size": v}))));
row(ui, t, "Opacity", |ui| slider(app, ui, "slideshowPlateOpacity", p.opacity, 0.0, 1.0, Unit::Percent, &|v| plate(json!({"opacity": v}))));
placement(app, ui, t, "slideshowPlate", &p.place, true, &plate);
toggle(app, ui, "slideshowPlateBehind", p.behind, "Render behind image", &|v| plate(json!({"behind": v})));
}
// rating stars
ui.add_space(4.0);
let r = s.rating_stars.clone();
let stars = |v: Value| json!({ "ratingStars": v });
if toggle(app, ui, "slideshowStars", r.enabled, "Rating stars", &|v| stars(json!({"enabled": v}))) {
row(ui, t, "Colour", |ui| colour(app, ui, "slideshowStarsColor", r.color, &|c| stars(json!({"color": c}))));
row(ui, t, "Size", |ui| slider(app, ui, "slideshowStarsSize", r.size, 0.01, STAR_SIZE_MAX, Unit::Percent, &|v| stars(json!({"size": v}))));
placement(app, ui, t, "slideshowStars", &r.place, true, &stars);
}
// text overlays
ui.add_space(6.0);
ui.label(RichText::new(crate::i18n::tr("Text overlays")).font(t.semibold(11.5)).color(t.text));
let n = s.texts.len();
for (i, o) in s.texts.iter().enumerate() {
let id = format!("slideshowText{i}");
let wrap = |v: Value| text_change(i, n, v);
ui.add_space(2.0);
row(ui, t, &format!("{} {}", crate::i18n::tr("Text"), i + 1), |ui| {
text(app, ui, &id, &o.text, "{title} · {exposure}", &|v| wrap(json!({"text": v})));
if crate::widgets::text_button(ui, &format!("{id}Remove"), "Remove", false).clicked() {
set(app, json!({"removeText": i}));
}
});
if app.session.slide_overlays.texts.len() != n {
return; // removed: the indices moved; drawn again next frame
}
row(ui, t, "Size", |ui| {
slider(app, ui, &format!("{id}Size"), o.size, 0.01, TEXT_SIZE_MAX / 2.0, Unit::Percent, &|v| wrap(json!({"size": v})))
});
row(ui, t, "Colour", |ui| colour(app, ui, &format!("{id}Color"), o.color, &|c| wrap(json!({"color": c}))));
row(ui, t, "Opacity", |ui| slider(app, ui, &format!("{id}Opacity"), o.opacity, 0.0, 1.0, Unit::Percent, &|v| wrap(json!({"opacity": v}))));
placement(app, ui, t, &id, &o.place, true, &wrap);
let sh = o.shadow;
if toggle(app, ui, &format!("{id}Shadow"), sh.on, "Shadow", &|v| wrap(json!({"shadow": v}))) {
row(ui, t, "Opacity", |ui| {
slider(app, ui, &format!("{id}ShadowOpacity"), sh.opacity, 0.0, 1.0, Unit::Percent, &|v| wrap(json!({"shadowOpacity": v})))
});
row(ui, t, "Offset", |ui| {
slider(app, ui, &format!("{id}ShadowOffset"), sh.offset, 0.0, SHADOW_OFFSET_MAX, Unit::Percent, &|v| wrap(json!({"shadowOffset": v})))
});
row(ui, t, "Radius", |ui| {
slider(app, ui, &format!("{id}ShadowRadius"), sh.radius, 0.0, SHADOW_RADIUS_MAX, Unit::Percent, &|v| wrap(json!({"shadowRadius": v})))
});
row(ui, t, "Angle", |ui| {
slider(app, ui, &format!("{id}ShadowAngle"), sh.angle, -180.0, 180.0, Unit::Degrees, &|v| wrap(json!({"shadowAngle": v})))
});
}
}
if n < lightcraft_engine::slide_overlay::MAX_TEXTS && crate::widgets::text_button(ui, "slideshowAddText", "Add text overlay", false).clicked() {
set(app, json!({"addText": {}}));
}
hint(
ui,
t,
"Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.",
);
// watermark
ui.add_space(4.0);
let w = s.watermark.clone();
let wm = |v: Value| json!({ "watermark": v });
if toggle(app, ui, "slideshowWatermark", s.show_watermark, "Watermark", &|v| json!({"showWatermark": v})) {
row(ui, t, "Text", |ui| text(app, ui, "slideshowWatermarkText", &w.text, "© Your Name", &|v| wm(json!({"text": v}))));
row(ui, t, "Size", |ui| slider(app, ui, "slideshowWatermarkSize", w.size as f64, 0.01, 0.15, Unit::Percent, &|v| wm(json!({"size": v}))));
row(ui, t, "Opacity", |ui| {
slider(app, ui, "slideshowWatermarkOpacity", w.opacity as f64, 0.05, 1.0, Unit::Percent, &|v| wm(json!({"opacity": v})))
});
row(ui, t, "Position", |ui| {
let mut a = w.anchor;
if anchor_grid(ui, "slideshowWatermarkAnchor", &mut a) {
set(app, wm(json!({"anchor": lightcraft_engine::slide_overlay::anchor_id(a)})));
}
});
let export_wm = app.session.last_export.as_ref().and_then(|e| e.get("watermark")).filter(|v| v.is_object() || v.is_string()).cloned();
if let Some(e) = export_wm
&& crate::widgets::text_button(ui, "slideshowUseExportWatermark", "Use export watermark", false).clicked()
{
set(app, wm(e));
}
hint(ui, t, "The export watermark, drawn on the photo and sized relative to it.");
}
for warning in app.session.slide_overlays.warnings() {
ui.label(RichText::new(warning).size(11.0).color(t.caution));
}
}
+417
View File
@@ -0,0 +1,417 @@
//! The slideshow (View ▸ Slideshow, ⌥⌘↩): the photos in view, full screen, played with the
//! library's Playback settings (`slideshow.playback`: slide and fade durations, fade colour,
//! random order, repeat, preparing previews in advance).
//!
//! The timeline is [`lightcraft_engine::slideshow::Playback`] (no egui, tested on its own); this
//! module renders the slides, draws the frame it asks for and the playback controls.
//!
//! Smooth transitions: every slide is rendered into its own texture ([`Slot::Slide`]) off the UI
//! thread. With "prepare previews" on, the next [`AHEAD`] slides are requested as soon as a slide
//! comes up, so they are ready long before they are due; a fade then only draws two textures
//! that already exist (no render, no upload inside the fade). The timeline does not start a
//! 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`]: 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;
use lightcraft_engine::slideshow::{Playback, Tick};
use lightcraft_pipeline::geometry::Frame;
use serde_json::{Value, json};
use crate::LightcraftApp;
use crate::icons::Icon;
use crate::render::Slot;
use crate::widgets::{icon_button, register};
/// Slides prepared ahead of the one up.
pub const AHEAD: usize = 2;
/// Seconds the playback controls stay after the pointer last moved.
pub const CONTROLS_SECS: f64 = 2.5;
/// A running slideshow.
#[derive(Clone, Debug)]
pub struct Run {
pub play: Playback,
/// The playback controls show until this time (egui time).
pub controls_until: f64,
/// Where the pointer was last frame (the controls appear when it moves).
pub pointer: Option<Pos2>,
/// Slides no render can be made for (gone from the catalog, nothing to read): shown with
/// whatever stand-in there is, never waited for.
pub unrenderable: Vec<PhotoId>,
}
/// `view.slideshow {interval?, seed?, slideDuration?, fadeDuration?, fadeColor?, randomOrder?,
/// repeat?, preparePreviews?}`: play the photos in view from the selected one with the library's
/// playback settings; the keys given change them for this run only (`interval` = slideDuration,
/// `seed` fixes the random order).
pub fn start(app: &mut LightcraftApp, p: &Value, now: f64) -> Result<Value, String> {
let mut overrides = p.as_object().cloned().unwrap_or_default();
if let Some(i) = overrides.remove("interval") {
overrides.insert("slideDuration".into(), i);
}
let seed = match overrides.remove("seed") {
None | Some(Value::Null) => (now.abs() * 1000.0) as u64 ^ 0x5eed_5eed,
Some(v) => v.as_u64().ok_or_else(|| format!("view.slideshow: `seed` must be a whole number, not {v}"))?,
};
let settings = app.session.slideshow.merged(&Value::Object(overrides)).map_err(|e| format!("view.slideshow: {e}"))?;
let photos = app.session.visible_cloned();
let Some(play) = Playback::new(photos, app.session.active(), settings, seed, now) else {
return Err("no photos to show".into());
};
let first = play.current();
if app.session.active() != Some(first) {
let _ = app.run("library.select", json!({"ids": [first.0]}));
}
let (_, slides) = play.position();
let out = json!({"slides": slides, "seed": seed, "settings": play.settings().to_json()});
app.ui.slideshow = Some(Box::new(Run { play, controls_until: 0.0, pointer: None, unrenderable: Vec::new() }));
app.ui.fullscreen = true;
app.ui.zoom = crate::state::Zoom::Fit;
app.ui.tool.clear();
let _ = app.session.end_interaction();
Ok(out)
}
/// End the slideshow (back to the view it started from, on the last photo shown).
pub fn end(app: &mut LightcraftApp) {
app.ui.slideshow = None;
app.ui.fullscreen = false;
app.renderer.release_slides(&[]);
}
/// The slideshow's own commands (the playback controls and their keys).
pub fn command(app: &mut LightcraftApp, id: &str, now: f64) -> Result<Value, String> {
let Some(run) = app.ui.slideshow.as_mut() else { return Err("no slideshow is playing".into()) };
let t = match id {
"slideshow.togglePause" => {
run.play.toggle_pause(now);
return Ok(state(&run.play, now));
}
"slideshow.next" => run.play.step(1, now),
"slideshow.previous" => run.play.step(-1, now),
_ => {
end(app);
return Ok(Value::Null);
}
};
after(app, t);
Ok(app.ui.slideshow.as_ref().map(|r| state(&r.play, now)).unwrap_or(json!({"ended": true})))
}
/// What the slideshow is doing (for `slideshow.*` replies and tests).
pub fn state(play: &Playback, now: f64) -> Value {
let (n, of) = play.position();
let f = play.frame(now);
json!({
"current": play.current().0,
"slide": n,
"of": of,
"paused": play.is_paused(),
"fading": f.from.is_some(),
"progress": f.progress,
"waitingFor": play.waiting_for().map(|p| p.0),
"remaining": play.remaining(now),
})
}
/// Act on what the timeline did: follow the slide with the selection, or end.
fn after(app: &mut LightcraftApp, t: Tick) {
match t {
Tick::Advanced(id) if app.session.active() != Some(id) => {
let _ = app.run("library.select", json!({"ids": [id.0]}));
}
Tick::Ended => end(app),
_ => {}
}
}
/// Can `id` be shown as a slide now: its texture exists (or it can't be rendered at all, and its
/// stand-in is all there will be: playback goes on rather than waiting forever).
pub fn ready(app: &LightcraftApp, id: PhotoId) -> bool {
app.renderer.textures.get(&Slot::Slide(id)).is_some_and(|t| t.photo == id)
|| app.renderer.failure(Slot::Slide(id)).is_some()
|| app.session.catalog.photo(id).is_none()
|| app.ui.slideshow.as_ref().is_some_and(|r| r.unrenderable.contains(&id))
}
/// Advance a running slideshow (every frame, before drawing).
pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) {
let Some(run) = app.ui.slideshow.as_ref() else {
app.renderer.release_slides(&[]);
return;
};
if !app.ui.fullscreen {
end(app);
return;
}
let now = ctx.input(|i| i.time);
let mut play = run.play.clone();
let t = play.tick(now, |id| ready(app, id));
if let Some(run) = app.ui.slideshow.as_mut() {
run.play = play;
}
after(app, t);
let Some(run) = app.ui.slideshow.as_ref() else { return };
let f = run.play.frame(now);
if f.from.is_some() {
ctx.request_repaint(); // a fade draws every frame
} else if !run.play.is_paused() {
ctx.request_repaint_after(std::time::Duration::from_secs_f64(run.play.remaining(now).clamp(0.016, 120.0)));
}
if run.controls_until > now {
ctx.request_repaint_after(std::time::Duration::from_secs_f64((run.controls_until - now).clamp(0.016, CONTROLS_SECS)));
}
}
/// 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.slides_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 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);
let mut v = vec![(f.current, 100)];
if let Some(from) = f.from {
v.push((from, 99));
}
if let Some(next) = play.waiting_for() {
v.push((next, 98));
}
if play.settings().prepare_previews {
for (k, id) in play.upcoming(AHEAD).into_iter().enumerate() {
v.push((id, 97u32.saturating_sub(k as u32)));
}
}
let mut seen = Vec::new();
v.retain(|(id, _)| {
let fresh = !seen.contains(id);
seen.push(*id);
fresh
});
v
}
/// The texture to draw for slide `id`: its own render, else the loupe's or a thumbnail (a slide
/// stepped to with ←/→ shows at once, sharp when its render lands).
fn texture(app: &LightcraftApp, id: PhotoId) -> Option<&crate::render::Tex> {
app.renderer
.textures
.get(&Slot::Slide(id))
.filter(|t| t.photo == id)
.or_else(|| app.renderer.textures.get(&Slot::Main).filter(|t| t.photo == id))
.or_else(|| app.renderer.thumb(id))
}
fn full_uv() -> Rect {
Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0))
}
/// The parts of `outer` outside `inner` (up to four bands).
fn outside(outer: Rect, inner: Rect) -> Vec<Rect> {
let i = inner.intersect(outer);
if !i.is_positive() {
return vec![outer];
}
let mut v = Vec::new();
if i.top() > outer.top() {
v.push(Rect::from_min_max(outer.min, pos2(outer.right(), i.top())));
}
if i.bottom() < outer.bottom() {
v.push(Rect::from_min_max(pos2(outer.left(), i.bottom()), outer.max));
}
if i.left() > outer.left() {
v.push(Rect::from_min_max(pos2(outer.left(), i.top()), pos2(i.left(), i.bottom())));
}
if i.right() < outer.right() {
v.push(Rect::from_min_max(pos2(i.right(), i.top()), pos2(outer.right(), i.bottom())));
}
v
}
/// Draw the slideshow (full screen) and its controls.
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let canvas = ui.max_rect();
app.canvas_rect = Some(canvas);
// (`ui.pointer` positions are relative to it)
app.image_rect = Some(canvas);
let Some(run) = app.ui.slideshow.as_ref() else { return };
let now = ui.input(|i| i.time);
let ppp = ui.ctx().pixels_per_point();
let side = super::detail::texture_side(ui.ctx());
let want = wanted(&run.play, now);
let frame = run.play.frame(now);
let mut unrenderable = Vec::new();
for (id, priority) in &want {
match slide_job(app, *id, canvas, ppp, side) {
Some(job) => app.renderer.request(Slot::Slide(*id), job, *priority),
None => unrenderable.push(*id),
}
}
if let Some(run) = app.ui.slideshow.as_mut() {
for id in unrenderable {
if !run.unrenderable.contains(&id) {
run.unrenderable.push(id);
}
}
}
let keep: Vec<PhotoId> = want.iter().map(|(id, _)| *id).collect();
app.renderer.release_slides(&keep);
let p = ui.painter_at(canvas);
p.rect_filled(canvas, 0.0, Color32::BLACK);
let rect_of = |t: &crate::render::Tex| super::detail::fit_texture_rect(canvas, t.size);
let draw = |id: PhotoId, alpha: f32| -> Option<Rect> {
let t = texture(app, id)?;
let r = rect_of(t);
p.image(t.tex.id(), r, full_uv(), Color32::WHITE.gamma_multiply(alpha.clamp(0.0, 1.0)));
Some(r)
};
let k = frame.progress.clamp(0.0, 1.0);
match (frame.from, frame.fade_color) {
// fade through a colour: out to it, then in from it
(Some(from), Some(c)) => {
let veil = if k < 0.5 {
draw(from, 1.0);
k * 2.0
} else {
draw(frame.current, 1.0);
2.0 - k * 2.0
};
p.rect_filled(canvas, 0.0, Color32::from_rgb(c[0], c[1], c[2]).gamma_multiply(veil.clamp(0.0, 1.0)));
}
// crossfade: where the slides overlap the new one goes over the old at `k`; where only
// the old one is, it fades to the backdrop
(Some(from), None) => {
let new_rect = texture(app, frame.current).map(rect_of);
if let Some(t) = texture(app, from) {
let r = rect_of(t);
match new_rect {
Some(n) => {
p.with_clip_rect(n.intersect(canvas)).image(t.tex.id(), r, full_uv(), Color32::WHITE);
for band in outside(r, n) {
p.with_clip_rect(band).image(t.tex.id(), r, full_uv(), Color32::WHITE.gamma_multiply(1.0 - k));
}
}
None => {
p.image(t.tex.id(), r, full_uv(), Color32::WHITE.gamma_multiply(1.0 - k));
}
}
}
draw(frame.current, k);
}
_ => {
draw(frame.current, 1.0);
}
}
register(ui.ctx(), "slideshow:canvas", canvas);
controls(app, ui, canvas, now);
}
/// The playback controls: previous, play / pause, next, slide n of N, end. They appear when the
/// pointer moves and fade out [`CONTROLS_SECS`] after it stops (or stay while it is over them).
fn controls(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect, now: f64) {
let t = crate::theme::Tokens::get(ui.ctx());
let pos = ui.input(|i| i.pointer.latest_pos());
let pressed = ui.input(|i| i.pointer.any_pressed());
let bar = Rect::from_center_size(pos2(canvas.center().x, canvas.bottom() - 56.0), vec2(300.0, 44.0));
let Some(run) = app.ui.slideshow.as_mut() else { return };
let moved = pos.is_some() && run.pointer.is_some() && pos != run.pointer;
let over = pos.is_some_and(|p| bar.expand(8.0).contains(p));
if moved || pressed || (over && run.controls_until > now) {
run.controls_until = now + CONTROLS_SECS;
}
run.pointer = pos.or(run.pointer);
let left = run.controls_until - now;
if left <= 0.0 {
return;
}
let paused = run.play.is_paused();
let (n, of) = run.play.position();
// fade out over the last half second
let alpha = (left / 0.5).clamp(0.0, 1.0) as f32;
let p = ui.painter_at(canvas);
p.rect_filled(bar, 10.0, Color32::from_black_alpha((200.0 * alpha) as u8));
let mut child =
ui.new_child(egui::UiBuilder::new().max_rect(bar.shrink2(vec2(10.0, 4.0))).layout(egui::Layout::left_to_right(egui::Align::Center)));
child.set_opacity(alpha);
child.spacing_mut().item_spacing.x = 4.0;
let size = vec2(36.0, 36.0);
let mut fire = None;
if icon_button(&mut child, "slideshowPrevious", Icon::ChevronLeft, size, false, true, "Previous slide (←)").clicked() {
fire = Some("slideshow.previous");
}
let (icon, tip) = if paused { (Icon::Play, "Play (Space)") } else { (Icon::Pause, "Pause (Space)") };
if icon_button(&mut child, "slideshowPlayPause", icon, size, false, true, tip).clicked() {
fire = Some("slideshow.togglePause");
}
if icon_button(&mut child, "slideshowNext", Icon::ChevronRight, size, false, true, "Next slide (→)").clicked() {
fire = Some("slideshow.next");
}
let (r, _) = child.allocate_exact_size(vec2(118.0, 36.0), egui::Sense::hover());
let label = format!("{n} / {of}");
child.painter().text(r.center(), Align2::CENTER_CENTER, label, t.font(13.0), Color32::from_gray(230).gamma_multiply(alpha));
if icon_button(&mut child, "slideshowEnd", Icon::Close, size, false, true, "End slideshow (Esc)").clicked() {
fire = Some("slideshow.end");
}
if let Some(id) = fire {
let _ = app.run(id, json!({}));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn crossfade_bands_cover_the_old_slide_outside_the_new_one() {
let outer = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 60.0));
let inner = Rect::from_min_max(pos2(30.0, 0.0), pos2(70.0, 60.0));
let bands = outside(outer, inner);
let area: f32 = bands.iter().map(|b| b.area()).sum();
assert_eq!(area, 100.0 * 60.0 - 40.0 * 60.0);
assert_eq!(outside(outer, Rect::from_min_max(pos2(200.0, 0.0), pos2(300.0, 10.0))), vec![outer], "no overlap: all of it");
assert!(outside(outer, outer.expand(5.0)).is_empty(), "covered");
}
}
+38 -1
View File
@@ -55,6 +55,12 @@ pub enum Slot {
Import(u32),
/// The second window's view of the active photo.
Second,
/// A slideshow slide (the one up, the one fading out, the next ones prepared in advance; see
/// [`crate::panels::slideshow`]).
Slide(PhotoId),
/// The small composed slide in Settings ▸ Slideshow (the active photo with the layout being
/// edited).
SlidePreview,
/// A slider drag's live HDR histogram: a small full-quality render of an HDR edit, beside the
/// drafts (which have no HDR histogram), with no texture ([`Renderer::live_hdr_histogram`]).
HdrHistogram,
@@ -217,6 +223,8 @@ pub struct Renderer {
variant_want: usize,
/// Frames polled so far.
frame: u64,
/// Slideshow slides may be held ([`Self::release_slides`] has something to do).
slides_live: bool,
/// Stage caches cleared to stay in budget (see [`stage_trim_order`]).
stages_trimmed: u64,
/// A budget other than the memory budget's share (tests).
@@ -280,6 +288,7 @@ impl Renderer {
variant_used: HashMap::new(),
variant_want: 0,
frame: 0,
slides_live: false,
stages_trimmed: 0,
#[cfg(test)]
stage_budget_override: None,
@@ -349,7 +358,15 @@ impl Renderer {
fn renders_for_display(slot: Slot) -> bool {
matches!(
slot,
Slot::Main | Slot::Region | Slot::Before | Slot::RegionBefore | Slot::Hover | Slot::Compare(_) | Slot::CompareRegion(_) | Slot::Second
Slot::Main
| Slot::Region
| Slot::Before
| Slot::RegionBefore
| Slot::Hover
| Slot::Compare(_)
| Slot::CompareRegion(_)
| Slot::Second
| Slot::Slide(_)
)
}
@@ -424,6 +441,9 @@ impl Renderer {
/// Request a render for `slot` (no-op if already current or pending at the same priority).
pub fn request(&mut self, slot: Slot, job: RenderJob, priority: u32) {
if matches!(slot, Slot::Slide(_)) {
self.slides_live = true;
}
let mut job = self.job_for(slot, job);
if !self.request_needed(slot, job.key, priority) {
return;
@@ -482,6 +502,23 @@ impl Renderer {
self.pool.submit(slot, key, priority, Box::new(move || lightcraft_engine::memory::in_background(|| job.run())));
}
/// Drop the slideshow slides other than `keep`: their textures and their requests that have
/// not started (a slide that is no longer near is not worth rendering).
pub fn release_slides(&mut self, keep: &[PhotoId]) {
if !self.slides_live {
return;
}
let gone = |s: &Slot| matches!(s, Slot::Slide(id) if !keep.contains(id));
self.textures.retain(|s, _| !gone(s));
let _ = self.pool.reprioritize(|s, p| if gone(s) { None } else { Some(p) });
self.queue.retain(|q| !gone(&q.slot));
// (a render already running finishes unclaimed: its result is dropped)
self.pending.retain(|s, _| !gone(s));
self.request_ids.retain(|s, _| !gone(s));
self.failed.retain(|s, _| !gone(s));
self.slides_live = !keep.is_empty();
}
/// Is a request for `slot` queued or running?
/// Drop every texture and cached stage (another library was opened: photo ids changed meaning).
pub fn forget_all(&mut self) {
+10 -7
View File
@@ -403,13 +403,11 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) {
}
};
for (id, params) in aliased {
// Space pauses / resumes a slideshow
if id == "view.zoomToggle"
&& let Some((interval, _, paused)) = app.ui.slideshow
{
let now = ctx.input(|i| i.time);
app.ui.slideshow = Some((interval, now + interval, !paused));
app.toast(ctx, if paused { "Slideshow resumed" } else { "Slideshow paused" });
// a slideshow: Space pauses / resumes
if id == "view.zoomToggle" && app.ui.slideshow.is_some() {
if let Ok(v) = app.run("slideshow.togglePause", json!({})) {
app.toast(ctx, if v["paused"] == true { "Slideshow paused" } else { "Slideshow resumed" });
}
continue;
}
// flag/rate aliases (Shift+X…) go through the culling path: active photo in Compare/Survey,
@@ -429,6 +427,11 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) {
}
}
for f in fire {
// a slideshow: ←/→ step (a cut)
if app.ui.slideshow.is_some() && matches!(f.as_str(), "library.next" | "library.previous") {
let _ = app.run(if f == "library.next" { "slideshow.next" } else { "slideshow.previous" }, json!({}));
continue;
}
if let Some(rest) = f.strip_prefix("rate:") {
let (n, adv) = rest.split_once(':').unwrap_or(("0", "0"));
cull(app, "photo.rate", json!({"rating": n.parse::<u8>().unwrap_or(0)}), adv == "1");
+2 -3
View File
@@ -719,10 +719,9 @@ pub struct UiState {
/// A keyword being dragged in the Keyword List (onto another to nest it).
#[serde(skip)]
pub dragging_keyword: Option<String>,
/// A running slideshow (full screen): seconds per photo, when the next one is due (egui
/// time), paused.
/// A running slideshow (full screen; see [`crate::panels::slideshow`]).
#[serde(skip)]
pub slideshow: Option<(f64, f64, bool)>,
pub slideshow: Option<Box<crate::panels::slideshow::Run>>,
/// Info overlay on the loupe.
pub info_overlay: InfoOverlay,
/// Navigator mini map in the loupe while zoomed in.
-32
View File
@@ -947,38 +947,6 @@ fn local_folder_listed_twice_opens_and_closes_each_row() {
h.settle(SETTLE);
}
#[test]
fn slideshow_advances_pauses_and_ends() {
let mut h = detail("panel.edit");
let first = h.app.session.active();
exec(&mut h, "view.slideshow", json!({"interval": 0.5}));
assert!(h.app.ui.fullscreen && h.app.ui.slideshow.is_some());
// simulated time runs with the frames
let mut moved = false;
for _ in 0..200 {
h.step();
if h.app.session.active() != first {
moved = true;
break;
}
}
assert!(moved, "the next photo comes up");
// Space pauses: nothing moves
let r = h.request("ui.key", json!({"key": "space"}), T);
assert_eq!(r["ok"], true, "{r}");
assert!(h.app.ui.slideshow.is_some_and(|s| s.2), "paused");
let held = h.app.session.active();
for _ in 0..120 {
h.step();
}
assert_eq!(h.app.session.active(), held);
// Esc ends it
let r = h.request("ui.key", json!({"key": "escape"}), T);
assert_eq!(r["ok"], true, "{r}");
assert!(!h.app.ui.fullscreen && h.app.ui.slideshow.is_none());
h.settle(SETTLE);
}
#[test]
fn geometry_slider_drag_marks_the_grid() {
let mut h = detail("panel.crop");
+432
View File
@@ -0,0 +1,432 @@
//! The slideshow in the app (View ▸ Slideshow): playback with the library's settings, slides
//! prepared ahead off the UI thread, transitions only between ready slides, keys and controls.
//! Simulated time: every headless frame is 1/60 s, so nothing here sleeps.
use std::time::Duration;
use lightcraft_catalog::PhotoId;
use serde_json::json;
use crate::headless::Headless;
use crate::render::Slot;
use crate::{LightcraftApp, Services};
const T: Duration = Duration::from_secs(20);
const SETTLE: Duration = Duration::from_secs(120);
fn grid() -> Headless {
let services = Services { png: None, ..Default::default() };
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services);
let mut h = Headless::new(app, [1000.0, 700.0], 1.0);
h.step();
h
}
fn run(h: &mut Headless, command: &str, params: serde_json::Value) -> serde_json::Value {
let r = h.request("engine.execute", json!({"command": command, "params": params}), T);
assert_eq!(r["ok"], true, "{command}: {r}");
r["result"].clone()
}
fn play(h: &Headless) -> &lightcraft_engine::slideshow::Playback {
&h.app.ui.slideshow.as_ref().expect("a slideshow is playing").play
}
fn has_slide(h: &Headless, id: PhotoId) -> bool {
h.app.renderer.textures.get(&Slot::Slide(id)).is_some_and(|t| t.photo == id)
}
fn slides_held(h: &Headless) -> usize {
h.app.renderer.textures.keys().filter(|s| matches!(s, Slot::Slide(_))).count()
}
#[test]
fn slideshow_advances_pauses_and_ends() {
let mut h = grid();
let first = h.app.session.visible_cloned()[0];
run(&mut h, "view.slideshow", json!({"interval": 0.5, "fadeDuration": 0}));
assert!(h.app.ui.fullscreen && h.app.ui.slideshow.is_some());
assert_eq!(h.app.session.active(), Some(first), "starts at the first photo in view");
assert!(h.step_until(SETTLE, |h| h.app.session.active() != Some(first)), "the next photo comes up and is selected");
// Space pauses: nothing moves
let r = h.request("ui.key", json!({"key": "space"}), T);
assert_eq!(r["ok"], true, "{r}");
assert!(play(&h).is_paused(), "paused");
let held = h.app.session.active();
for _ in 0..120 {
h.step();
}
assert_eq!(h.app.session.active(), held);
// Esc ends it, and the slides are let go
let r = h.request("ui.key", json!({"key": "escape"}), T);
assert_eq!(r["ok"], true, "{r}");
h.step();
assert!(!h.app.ui.fullscreen && h.app.ui.slideshow.is_none());
assert_eq!(slides_held(&h), 0, "slide textures freed");
h.settle(SETTLE);
}
#[test]
fn next_slides_are_prepared_before_they_are_due() {
let mut h = grid();
// a long slide: anything rendered for the next ones is rendered ahead of time
run(&mut h, "view.slideshow", json!({"interval": 60, "fadeDuration": 1, "preparePreviews": true, "seed": 7}));
let ahead = play(&h).upcoming(crate::panels::slideshow::AHEAD);
assert_eq!(ahead.len(), 2);
let start = h.time();
// the slide up and the next two; their renders can finish in any order
let current = play(&h).current();
assert!(
h.step_until(SETTLE, |h| has_slide(h, current) && ahead.iter().all(|id| has_slide(h, *id))),
"the slide up and the next two slides are rendered"
);
assert!(h.time() - start < 60.0, "before the first is due");
assert_eq!(play(&h).current(), current, "still on the first slide");
h.settle(SETTLE);
// off: only the slide up is rendered until the next one is due
let mut h = grid();
run(&mut h, "view.slideshow", json!({"interval": 60, "preparePreviews": false}));
let next = play(&h).upcoming(1)[0];
let current = play(&h).current();
assert!(h.step_until(SETTLE, |h| has_slide(h, current)));
for _ in 0..30 {
h.step();
}
assert!(!has_slide(&h, next) && !h.app.renderer.is_pending(Slot::Slide(next)), "not prepared");
h.settle(SETTLE);
}
#[test]
fn a_fade_only_runs_between_rendered_slides() {
let mut h = grid();
// without previews prepared, each slide renders when it is due: the transition must wait
run(&mut h, "view.slideshow", json!({"interval": 0.5, "fadeDuration": 0.4, "preparePreviews": false}));
let mut fades = 0;
let t0 = std::time::Instant::now();
while fades < 20 && t0.elapsed() < SETTLE {
h.step();
let f = play(&h).frame(h.time());
if let Some(from) = f.from {
fades += 1;
assert!(has_slide(&h, f.current), "fading in a slide that isn't rendered");
assert!(has_slide(&h, from), "fading out a slide that was let go");
}
}
assert!(fades >= 20, "fades ran ({fades})");
h.settle(SETTLE);
}
#[test]
fn arrows_cut_and_repeat_off_ends_after_the_last_slide() {
let mut h = grid();
let photos = h.app.session.visible_cloned();
run(&mut h, "view.slideshow", json!({"interval": 60, "fadeDuration": 2, "repeat": false}));
let r = h.request("ui.key", json!({"key": "right"}), T);
assert_eq!(r["ok"], true, "{r}");
assert_eq!(play(&h).current(), photos[1]);
assert_eq!(h.app.session.active(), Some(photos[1]), "the selection follows");
assert!(play(&h).frame(h.time()).from.is_none(), "a cut, not a fade");
let r = h.request("ui.key", json!({"key": "left"}), T);
assert_eq!(r["ok"], true, "{r}");
assert_eq!(play(&h).current(), photos[0]);
for _ in 1..photos.len() {
run(&mut h, "slideshow.next", json!({}));
}
assert_eq!(play(&h).position(), (photos.len(), photos.len()));
run(&mut h, "slideshow.next", json!({}));
assert!(h.app.ui.slideshow.is_none() && !h.app.ui.fullscreen, "no repeat: past the last one it ends");
h.settle(SETTLE);
}
#[test]
fn random_order_starts_at_the_selection_and_is_fixed_by_the_seed() {
let order = |seed: u64| {
let mut h = grid();
let photos = h.app.session.visible_cloned();
run(&mut h, "library.select", json!({"ids": [photos[3].0]}));
run(&mut h, "view.slideshow", json!({"randomOrder": true, "seed": seed, "interval": 60}));
let mut seen = vec![play(&h).current()];
for _ in 1..photos.len() {
run(&mut h, "slideshow.next", json!({}));
seen.push(play(&h).current());
}
h.settle(SETTLE);
(photos, seen)
};
let (photos, a) = order(11);
assert_eq!(a[0], photos[3]);
let in_view_order: Vec<PhotoId> = photos.iter().copied().filter(|p| *p != photos[3]).collect();
assert_ne!(a[1..].to_vec(), in_view_order, "shuffled");
let mut sorted = a.clone();
sorted.sort();
let mut all = photos.clone();
all.sort();
assert_eq!(sorted, all, "every photo once");
assert_eq!(order(11).1, a, "same seed, same order");
}
#[test]
fn playback_controls_show_on_pointer_movement_and_hide() {
let mut h = grid();
run(&mut h, "view.slideshow", json!({"interval": 60}));
h.step();
let shown = |h: &Headless| h.app.widgets.iter().any(|(w, _)| w == "icon:slideshowPlayPause");
assert!(!shown(&h), "hidden until the pointer moves");
for fx in [0.3, 0.6] {
let r = h.request("ui.hoverWidget", json!({"id": "slideshow:canvas", "fx": fx, "fy": 0.3}), T);
assert_eq!(r["ok"], true, "{r}");
}
h.step();
assert!(shown(&h), "the pointer moved: the controls show");
assert!(h.painted_text().iter().any(|t| t.contains(&format!("1 / {}", h.app.session.visible_cloned().len()))), "slide n of N");
let r = h.request("ui.clickWidget", json!({"id": "icon:slideshowPlayPause"}), T);
assert_eq!(r["ok"], true, "{r}");
assert!(play(&h).is_paused(), "the play / pause button pauses");
// the pointer rests away from the bar: gone after a few seconds
let r = h.request("ui.hoverWidget", json!({"id": "slideshow:canvas", "fx": 0.5, "fy": 0.2}), T);
assert_eq!(r["ok"], true, "{r}");
for _ in 0..(60.0 * (crate::panels::slideshow::CONTROLS_SECS + 0.5)) as usize {
h.step();
}
assert!(!shown(&h), "hidden again");
let r = h.request("ui.hoverWidget", json!({"id": "slideshow:canvas", "fx": 0.55, "fy": 0.25}), T);
assert_eq!(r["ok"], true, "{r}");
h.step();
let r = h.request("ui.clickWidget", json!({"id": "icon:slideshowEnd"}), T);
assert_eq!(r["ok"], true, "{r}");
assert!(h.app.ui.slideshow.is_none() && !h.app.ui.fullscreen, "the end button ends it");
h.settle(SETTLE);
}
#[test]
fn playback_settings_drive_the_slideshow_and_bad_ones_are_refused() {
let mut h = grid();
let r = run(&mut h, "slideshow.playback", json!({"slideDuration": 9, "fadeDuration": 0.5, "repeat": false}));
assert_eq!(r["slideDuration"], 9.0);
let r = h.request("engine.execute", json!({"command": "view.slideshow", "params": {"slideDuration": "slow"}}), T);
assert_ne!(r["ok"], true, "{r}");
assert!(h.app.ui.slideshow.is_none());
let r = h.request("engine.execute", json!({"command": "view.slideshow", "params": {"seed": -1}}), T);
assert_ne!(r["ok"], true, "{r}");
let r = run(&mut h, "view.slideshow", json!({}));
assert_eq!(r["settings"]["slideDuration"], 9.0);
assert_eq!(r["settings"]["repeat"], false);
// `interval` (the older parameter) is this run's slide duration; the saved one stays
run(&mut h, "slideshow.end", json!({}));
let r = run(&mut h, "view.slideshow", json!({"interval": 2}));
assert_eq!(r["settings"]["slideDuration"], 2.0);
assert_eq!(h.app.session.slideshow.slide_duration, 9.0);
// the options open in Settings
run(&mut h, "slideshow.end", json!({}));
run(&mut h, "view.slideshowOptions", json!({}));
h.step();
assert!(h.app.widgets.iter().any(|(w, _)| w == "check:settings.slideshowRandom"));
let r = h.request("ui.clickWidget", json!({"id": "check:settings.slideshowRandom"}), T);
assert_eq!(r["ok"], true, "{r}");
h.step();
assert!(h.app.session.slideshow.random_order, "the checkbox goes through slideshow.playback");
h.settle(SETTLE);
}
#[test]
fn a_photo_deleted_while_it_plays_does_not_stall_the_slideshow() {
let mut h = grid();
let photos = h.app.session.visible_cloned();
run(&mut h, "view.slideshow", json!({"interval": 0.5, "fadeDuration": 0, "preparePreviews": false}));
// the next slide goes away before it is due
let next = play(&h).upcoming(1)[0];
run(&mut h, "photo.delete", json!({"ids": [next.0]}));
run(&mut h, "photo.deletePermanently", json!({"ids": [next.0]}));
assert!(h.app.session.catalog.photo(next).is_none());
assert!(h.step_until(SETTLE, |h| h.app.ui.slideshow.as_ref().is_some_and(|r| r.play.position().0 >= 3)), "it plays on");
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);
}
#[test]
fn overlays_show_during_playback() {
let mut h = grid();
assert!(h.app.session.slides_plain());
// the default layout with an identity plate: playback composes the slide to draw it
run(
&mut h,
"slideshow.overlays",
json!({"identityPlate": {"enabled": true, "text": "LC", "size": 0.12, "color": "#00ff00", "opacity": 1, "anchor": "topLeft", "offsetX": 0.02, "offsetY": 0.02}}),
);
assert!(!h.app.session.slides_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)), "the slide is composed and ready");
h.step();
assert_eq!(h.app.renderer.textures.get(&Slot::Slide(current)).unwrap().size, [1000, 700]);
let img = h.paint();
// the plate's box: 14 px in from the top-left corner, 84 px high
let green = (14..98).flat_map(|y| (14..120).map(move |x| (x, y))).filter(|&(x, y)| {
let c = painted(&img, x, y);
c[1] > 200 && c[0] < 80 && c[2] < 80
});
assert!(green.count() > 200, "the identity plate is on the slide");
h.settle(SETTLE);
}
#[test]
fn settings_shows_the_overlays_and_the_preview_follows_them() {
let mut h = grid();
let first = h.app.session.visible_cloned()[0];
run(&mut h, "library.select", json!({"ids": [first.0]}));
run(&mut h, "view.slideshowOptions", json!({}));
assert!(h.step_until(SETTLE, |h| h.app.renderer.textures.get(&Slot::SlidePreview).is_some_and(|t| t.photo == first)));
let has = |h: &Headless, w: &str| h.app.widgets.iter().any(|(id, _)| id == w);
// scroll down to the Overlays section
let r = h.request("ui.hoverWidget", json!({"id": "check:settings.slideshowRandom"}), T);
assert_eq!(r["ok"], true, "{r}");
for _ in 0..4 {
let r = h.request("ui.scroll", json!({"dy": -800}), T);
assert_eq!(r["ok"], true, "{r}");
for _ in 0..10 {
h.step();
}
}
for w in ["check:settings.slideshowPlate", "check:settings.slideshowStars", "button:slideshowAddText", "check:settings.slideshowWatermark"] {
assert!(has(&h, w), "{w}");
}
let r = h.request("ui.clickWidget", json!({"id": "button:slideshowAddText"}), T);
assert_eq!(r["ok"], true, "{r}");
h.step();
assert_eq!(h.app.session.slide_overlays.texts.len(), 1);
h.step();
assert!(has(&h, "field:slideshowText0"), "the text overlay's template field");
// its rows are below the fold: scroll on down
for _ in 0..4 {
let r = h.request("ui.scroll", json!({"dy": -800}), T);
assert_eq!(r["ok"], true, "{r}");
for _ in 0..10 {
h.step();
}
}
assert!(has(&h, "button:slideshowText0Anchor-4"), "its anchor grid");
let r = h.request("ui.clickWidget", json!({"id": "button:slideshowText0Anchor-0"}), T);
assert_eq!(r["ok"], true, "{r}");
h.step();
assert_eq!(h.app.session.slide_overlays.texts[0].place.anchor, lightcraft_engine::export::Anchor::TopLeft, "picked in the grid");
// removed from its row the text overlay from its row
let r = h.request("ui.clickWidget", json!({"id": "button:slideshowText0Remove"}), T);
assert_eq!(r["ok"], true, "{r}");
h.step();
assert!(h.app.session.slide_overlays.texts.is_empty());
h.step();
let r = h.request("ui.clickWidget", json!({"id": "check:settings.slideshowPlate"}), T);
assert_eq!(r["ok"], true, "{r}");
h.step();
assert!(h.app.session.slide_overlays.identity_plate.enabled);
// the preview is rendered again with the overlays
let before = h.app.renderer.textures.get(&Slot::SlidePreview).map(|t| t.key);
run(&mut h, "slideshow.overlays", json!({"identityPlate": {"text": "Studio", "color": "#ff00ff"}}));
assert!(h.step_until(SETTLE, |h| h.app.renderer.textures.get(&Slot::SlidePreview).map(|t| t.key) != before));
h.settle(SETTLE);
}
+3
View File
@@ -84,6 +84,9 @@ filmstrip is visible.
| `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.overlays"}` | `{identityPlate?: {enabled?, text?, image?, size?, color?, opacity?, anchor?, offsetX?, offsetY?, relativeTo?, behind?}, ratingStars?: {enabled?, color?, size?, anchor?, …}, texts?: [{text?, size?, color?, opacity?, anchor?, offsetX?, offsetY?, relativeTo?, shadow?, shadowOpacity?, shadowOffset?, shadowRadius?, shadowAngle?}], addText?, removeText?, showWatermark?, watermark?}` (see `engine.commands`) | The overlays drawn on every slide, saved with the library: sizes and offsets are fractions of the slide's shorter side, anchors `topLeft`…`bottomRight` of the `slide` or the `photo`; text overlays expand tokens (`{caption}`, `{exposure}`, `{date}`, the file-naming tokens…; unknown tags show as typed); `watermark` takes the export watermark's keys. Replies with the settings and `warnings` |
| `engine.execute {command: "slideshow.preview"}` | `{id?, width?, height?, path?}` | One slide composed with the layout and overlays, as the slideshow plays it: a PNG written to `path`, or returned base64 in `png` without one; `{id, width, height, cell, photo, overlays}` (`cell` and `photo` are `[x0, y0, x1, y1]` in slide pixels; `overlays` lists `{kind, box}` for each drawn overlay). Sides 16–4096; one side given takes the other from the aspect preview |
| `app.quit` | — | Close the app |
White balance (`develop.set` with `wb.temp` / `wb.tint`, `develop.wb`, `develop.wbPick`) is in Kelvin and tint on
+9
View File
@@ -203,6 +203,15 @@ 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. `cmd_slideshow_overlays` sets the
overlays drawn on every slide (identity plate text or image, rating stars, text overlays with tokens such as
`{caption}` or `{exposure}`, the export watermark); its reply lists `warnings` (an unreadable plate image, tags that
aren't tokens), and `cmd_slideshow_preview` returns each overlay's box.
## Resources
`resources/list` / `resources/read` serve JSON snapshots:
+9 -9
View File
@@ -52,10 +52,10 @@ cited here still exists. Formerly docs/parity.md.
| X. Cross-cutting behaviours (BEHAV) | 23 | 6 | 1 | 1 | 8/8 (100%) | 7/10 (70%) |
| Y. Menus | 84 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) |
| Z. Keyboard shortcuts (desktop) | 76 | 4 | 3 | 1 | 49/52 (94%) | 23/25 (92%) |
| Lightroom Classic extras | 34 | 16 | 35 | 8 | — | 24/26 (92%) |
| **Total** | 409 | 53 | 72 | 35 | 193/201 (96%) | 146/159 (92%) |
| Lightroom Classic extras | 36 | 19 | 30 | 8 | — | 24/26 (92%) |
| **Total** | 411 | 56 | 67 | 35 | 193/201 (96%) | 146/159 (92%) |
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.6%** of 534 in-scope rows — P0 98.0% of 201 · P1 95.0% of 159 · P2 50.0% of 173.
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **82.2%** of 534 in-scope rows — P0 98.0% of 201 · P1 95.0% of 159 · P2 52.0% of 173.
<!-- /parity:summary -->
## Top gaps and shortcut notes
@@ -135,7 +135,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
| LR-VIEW-COMPARE | Compare two photos | P1 | ✅ | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `cmd:compare.set`, `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests_compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; a filmstrip click puts the photo into the active side (a photo on the other side swaps), so the active photo is always one of the two shown; a ⌘/Ctrl- or Shift-click on the filmstrip changes the selection as in the grid while the two photos shown stay selected and the active photo is unchanged (#187); 1:1 to 8:1 sharp on both sides (zoom windows, see LR-VIEW-ZOOM); no zoom-link toggle; each pane says “Rendering…” (widget `compare:rendering:<pane>`) or “This photo can't be opened” (`compare:renderFailed:<pane>`) as the loupe does |
| LR-VIEW-SURVEY | Survey view [Classic] | P2 | ✅ | `cmd:view.survey`, `crates/ui-egui/src/panels/compare.rs` | selection tiled (≤ 48), keys act on the active photo, hover × removes |
| LR-VIEW-INFOOVERLAY | Info overlay on the photo | P1 | ✅ | `cmd:view.infoOverlay` | off / file + date + size / exposure + camera; ⌘I cycles (I in full screen; elsewhere I stays the Info panel) |
| LR-VIEW-SLIDESHOW | Slideshow | P2 | ✅ | `cmd:view.slideshow` | View ▸ Slideshow (⌥⌘↩): the photos in view full screen, every 4 s (`interval`), wrapping; Space pauses, ←/→ step, Esc ends |
| LR-VIEW-SLIDESHOW | Slideshow | P2 | ✅ | `cmd:view.slideshow` | View ▸ Slideshow (⌥⌘↩): the photos in view full screen from the selected one, played with the library's playback settings (see LRC-SS-PLAYBACK: fades, random order, repeat, previews prepared ahead; `interval` / any setting overrides one run); Space pauses, ←/→ step (a cut), Esc ends; playback controls on pointer movement |
| LR-VIEW-SECONDWINDOW | Second display window [Classic] | P2 | ✅ | `cmd:view.secondWindow`, `crates/ui-egui/src/panels/second.rs` | Window ▸ Second Window (⌘F11): the active photo fitted in its own native window with its own render (a floating panel where there are no native windows); loupe view only (no grid / compare / survey there) |
| LR-VIEW-CLIPPING | Clipping indicators | P0 | ✅ | `cmd:view.clipping` | |
| LR-VIEW-HISTOGRAM | Histogram | P0 | ✅ | `cmd:view.histogram`, `crates/ui-egui/src/panels/edit.rs`, `crates/pipeline/src/lib.rs` (`hdr_histogram`) | HDR edits: SDR on the left half, the HDR range (0 to 5 stops above SDR white, a fixed scale) on the right with the headroom limit marked; colour bar (white, or Visualize HDR range's colours when that is on) and SDR / HDR labels; no drag-to-adjust on the histogram; it follows slider drags live (a small render beside the drafts, `Slot::HdrHistogram`) and never drops to the SDR layout mid-drag |
@@ -742,13 +742,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-OVERLAYS | Slideshow overlays | P2 | ⬜ | | |
| LRC-SS-BACKDROP | Slideshow backdrop | 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 | 🟡 | `cmd:slideshow.overlays`, `cmd:slideshow.preview`, `path:crates/engine/src/slide_overlay.rs`, `path:crates/ui-egui/src/panels/slide_overlays.rs` | Identity plate (text with size / colour / opacity, or a PNG / JPEG file with its transparency; render behind image), rating stars (our own vector stars, colour, size), text overlays (up to 16) with tokens (`{file}`, `{caption}`, `{title}`, `{date}`, `{exposure}`, `{shutter}`, `{aperture}`, `{focal}`, `{iso}`, `{camera}`, `{lens}`, `{rating}`, `{copyright}` and the other rename tokens; unknown tags show as typed and are reported), size / colour / opacity and a drop shadow (opacity, offset, radius, angle); each at one of nine anchors of the slide or the photo plus an offset, kept on the slide; the export watermark on the photo. Sizes relative to the slide; drawn by the engine over the composed slide (Inter + craft-fonts CJK, the export watermark's text renderer) for playback, the Settings ▸ Slideshow preview and `slideshow.preview`. Missing: dragging overlays on the slide in a Slideshow module editor, font family / style choice (Inter SemiBold only), the Identity Plate Editor's styled multi-run text, right-to-left reordering (Arabic / Hebrew draw in logical order, and without glyphs in Inter) |
| LRC-SS-BACKDROP | Slideshow backdrop | P2 | ✅ | `cmd:slideshow.layout`, `path:crates/slideshow/src/compose.rs` | Background colour, Color Wash (a gradient from a second colour, full at the side its angle points to, at an opacity), Background Image (a library photo filling the slide at an opacity: "Use selected photo" in Settings ▸ Slideshow, `backgroundImage` in `slideshow.layout`; rendered once and shared by the slides). In the web build the composition runs on the main thread when a worker's render comes back |
| LRC-SS-TITLES | Slideshow titles | P2 | ⬜ | | |
| LRC-SS-MUSIC | Slideshow music | P2 | ⬜ | | |
| LRC-SS-PLAYBACK | Slideshow playback | P2 | ⬜ | | |
| LRC-SS-PLAYBACK | Slideshow playback | P2 | 🟡 | `cmd:slideshow.playback`, `cmd:view.slideshowOptions`, `cmd:slideshow.togglePause`, `cmd:slideshow.next`, `cmd:slideshow.previous`, `cmd:slideshow.end` | Playback settings (library preference, `slideshow.playback`; Settings ▸ Slideshow, View ▸ Slideshow Options…): slide duration 0.5–120 s, fade 0–10 s (≤ slide; 0 = cut) as a crossfade or through a colour, random order (seeded shuffle, every photo once per pass, a new order each pass), repeat (off: ends after the last slide), prepare previews in advance (the next 2 slides render off the UI thread; a transition never starts before its slide is rendered — the current one is held). Space / ←/→ (cut) / Esc and a control bar (previous, play/pause, next, n / N, end) that shows on pointer movement. Missing: the Slideshow module view and its "preview in the view" (phase 2 of the module plan), music and sync to music (LRC-SS-MUSIC), pan & zoom, manual-advance mode, choosing the screen to play on, titles on the slides (LRC-SS-TITLES). Slides are composed with the layout, options and backdrop (LRC-SS-LAYOUT / OPTIONS / BACKDROP) and carry the overlays (LRC-SS-OVERLAYS) |
| LRC-SS-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 | ⬜ | | |
+35
View File
@@ -284,6 +284,41 @@
preset pickers in Import and Settings ▸ Import scroll the same way.
- Importing a large collection and opening its library no longer compare every preset with every other.
### Slideshow overlays
- Slides can now carry Lightroom Classic's Slideshow overlays: an identity plate (a line of text in a colour, or an
image file such as a PNG logo with transparency, at a size and opacity, optionally drawn behind the photo), the
photo's rating as stars, and any number of text overlays (up to 16) built from tokens: `{caption}`, `{title}`,
`{file}`, `{date}`, `{exposure}` ("1/250 s at f/8"), `{iso}`, `{focal}`, `{camera}`, `{lens}`, `{rating}` and the
other file-naming tokens, with a size, colour, opacity and an optional soft shadow (opacity, offset, radius, angle).
Each sits at one of nine anchors of the slide or of the photo, moved by an offset, and never leaves the slide.
- The export watermark can be drawn on every slide's photo too ("Use export watermark" takes the one you last
exported with).
- Set them in Settings ▸ Slideshow ▸ Overlays; the slide preview there and View ▸ Slideshow show them. Sizes are a
share of the slide, so slides look the same on any screen. They are saved with the library. Agents use
`slideshow.overlays`; `slideshow.preview` now also returns where each overlay landed.
### 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
pass, a new order each time round), repeat (off: the slideshow ends after the last photo) and preparing previews in
advance (the next two slides render while the current one is up, so a fade never waits or flashes).
- Change them in Settings ▸ Slideshow (View ▸ Slideshow Options…); they are saved with the library. Agents use
`slideshow.playback`; `view.slideshow` takes any of them for one run (`interval` still sets the slide duration).
- While it plays, moving the pointer shows a small control bar: previous, play / pause, next, the slide number and
end. Space pauses, ← / → step (a cut), Esc ends. A slide's time starts once it is shown sharp.
## 2026-10-10
### Dialogs fit the window
+1
View File
@@ -51,6 +51,7 @@ pub const TABLE: &[(&str, Class)] = &[
("faces", Class::Layer(1)),
("geocode", Class::Layer(1)),
("scenes", Class::Layer(1)),
("slideshow", Class::Layer(1)),
("pipeline", Class::Layer(2)),
("gpu", Class::Layer(3)),
("catalog", Class::Layer(3)),