mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-01 05:57:09 -06:00
Merge PR #821
# 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:
Generated
+11
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(()) };
|
||||
|
||||
@@ -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
@@ -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..];
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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.": "書き出しの透かし。写真の上に、写真に対する比率で描かれます。"
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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.": "Водяной знак экспорта, рисуется на фото и масштабируется относительно него."
|
||||
}
|
||||
|
||||
@@ -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.": "Водяний знак експорту, малюється на фото й масштабується відносно нього."
|
||||
}
|
||||
|
||||
@@ -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.": "导出水印,绘制在照片上,大小相对于照片。"
|
||||
}
|
||||
|
||||
@@ -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.": "匯出浮水印,繪製在照片上,大小相對於照片。"
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)]);
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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`]).
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 | ⬜ | | |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)),
|
||||
|
||||
Reference in New Issue
Block a user