LRC-SS-OVERLAYS: slideshow overlays: identity plate, rating stars, text with tokens, watermark

- Engine: slide_overlay (OverlaySettings in prefs.json, the new slideshow.overlays
  command): identity plate (text, or a PNG/JPEG file with its transparency; size,
  colour, opacity; render behind image), rating stars (own vector stars), up to 16
  text overlays with tokens ({caption}, {exposure}, {file}, {date}, {focal}… plus
  every rename token; unknown tags show as typed and are reported) and a drop
  shadow, each at one of nine anchors of the slide or photo plus an offset, kept on
  the slide; the export watermark on the photo. Sizes relative to the slide's
  shorter side; validated and clamped.
- Drawn by the engine as a second pass over lightcraft_slideshow::compose_with
  (new behind hook for "render behind image"); the watermark's text renderer moved
  to engine text.rs (TextLayout, Inter + craft-fonts CJK) and is shared; glyphs
  off the image are skipped so huge text stays cheap.
- rename::expand_tokens_with lets overlays add tokens without changing rename.
- Playback composes slides when overlays are on; slideshow.preview returns each
  overlay's box. Settings > Slideshow > Overlays section with live preview.
- Docs: parity row (partial), whats-new, control/MCP docs, nine locale catalogs.
- Tests: tokens, nine anchors, star count, plate image opacity and missing file,
  text shadow, resolution independence, hostile input, big-slide cost, CJK with
  craft-fonts, prefs persistence, preview and headless playback pixel checks.
This commit is contained in:
Jason Baldwin
2026-10-10 22:54:40 +02:00
parent ae033aaf32
commit 12dc533396
30 changed files with 2290 additions and 216 deletions
+30 -3
View File
@@ -2,8 +2,9 @@
//! random order, repeat, preparing previews in advance). `slideshow.layout`: how a slide looks
//! (Layout, Options and Backdrop: margins, aspect preview, zoom to fill, stroke, shadow, backdrop
//! colour, colour wash, background image). Both are library preferences, saved in the library's
//! `prefs.json`; the app's slideshow (View ▸ Slideshow) plays with them. `slideshow.preview`
//! composes one slide as a PNG.
//! `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};
@@ -38,6 +39,20 @@ fn layout(s: &mut Session, p: &Value) -> Result<Value> {
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)> {
@@ -89,6 +104,9 @@ fn preview(s: &mut Session, p: &Value) -> Result<Value> {
"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)?;
@@ -135,12 +153,21 @@ pub fn specs() -> Vec<CommandSpec> {
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 → {id, width, height, cell: [x0,y0,x1,y1], photo: [x0,y0,x1,y1] (the visible photo, slide pixels), path | png (base64 PNG)}",
"{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
),
+28 -162
View File
@@ -242,58 +242,8 @@ impl Default for Watermark {
}
}
/// 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) {
@@ -306,6 +256,23 @@ pub fn draw_watermark(img: &mut Rgba8, wm: &Watermark) {
});
}
/// 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, |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) {
@@ -338,7 +305,6 @@ fn watermark_coverage(
craft: &'static [crate::fonts::CraftFont],
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;
@@ -347,97 +313,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);
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);
let inset = wm.inset.clamp(0.0, 0.4) * short;
let (w, h) = (width as f32, height as f32);
use Anchor::*;
@@ -452,33 +331,20 @@ fn watermark_coverage(
BottomLeft | Bottom | BottomRight => h - inset - th,
};
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(1.0), [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),
};
blend((ox + off + sx) as i32, (oy + off + sy) as i32, cov, col, a * alpha);
});
layout.draw_in(ox + off, oy + off, width as f32, height as f32, |gx, gy, cov| {
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);
});
}
}
/// 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);
@@ -833,7 +699,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 {
+5
View File
@@ -56,9 +56,11 @@ 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;
@@ -278,6 +280,8 @@ pub struct Session {
/// 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).
@@ -364,6 +368,7 @@ impl Session {
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,
+4
View File
@@ -162,6 +162,8 @@ struct PrefsFile {
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 {
@@ -346,6 +348,7 @@ impl Session {
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 {
@@ -584,6 +587,7 @@ impl Session {
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(()) };
+40 -23
View File
@@ -245,6 +245,19 @@ pub fn expand(template: &str, p: &Photo, seq: usize) -> String {
/// The template's tokens replaced for `p` (no extension added, nothing sanitized). A bare `{seq}`
/// is zero-padded to `seq_width` digits.
pub fn expand_tokens(template: &str, p: &Photo, seq: usize, seq_width: usize) -> String {
expand_tokens_with(template, p, seq, seq_width, &|_, _, _| None)
}
/// [`expand_tokens`] with more tokens: `extra(name, arg, tag)` (the name lower-cased, the text
/// after `:`, the whole tag without braces) answers a token first; `None` leaves it to the rename
/// tokens (and an unknown one stays as typed). Slideshow text overlays add their tokens this way.
pub fn expand_tokens_with(
template: &str,
p: &Photo,
seq: usize,
seq_width: usize,
extra: &dyn Fn(&str, Option<&str>, &str) -> Option<String>,
) -> String {
let (stem, ext) = split_ext(&p.file_name);
let mut out = String::new();
let mut rest = template;
@@ -257,31 +270,35 @@ pub fn expand_tokens(template: &str, p: &Photo, seq: usize, seq_width: usize) ->
};
let tok = &rest[i + 1..i + j];
let (name, arg) = tok.split_once(':').map(|(a, b)| (a, Some(b))).unwrap_or((tok, None));
let v = match name.trim().to_ascii_lowercase().as_str() {
"name" | "filename" => stem.to_string(),
"num" => {
let digits = stem.chars().rev().take_while(char::is_ascii_digit).count();
stem[stem.len() - digits..].to_string()
}
"seq" | "n" => {
let w: usize = arg.and_then(|a| a.parse().ok()).unwrap_or(seq_width).min(9);
format!("{seq:0w$}")
}
"date" => format_date(p.date(), arg.unwrap_or("%Y%m%d")),
"folder" => match &p.source {
Source::File { path } => {
Path::new(path).parent().and_then(Path::file_name).map(|f| f.to_string_lossy().to_string()).unwrap_or_default()
let lower = name.trim().to_ascii_lowercase();
let v = match extra(&lower, arg, tok) {
Some(v) => v,
None => match lower.as_str() {
"name" | "filename" => stem.to_string(),
"num" => {
let digits = stem.chars().rev().take_while(char::is_ascii_digit).count();
stem[stem.len() - digits..].to_string()
}
Source::Demo { .. } => String::new(),
"seq" | "n" => {
let w: usize = arg.and_then(|a| a.parse().ok()).unwrap_or(seq_width).min(9);
format!("{seq:0w$}")
}
"date" => format_date(p.date(), arg.unwrap_or("%Y%m%d")),
"folder" => match &p.source {
Source::File { path } => {
Path::new(path).parent().and_then(Path::file_name).map(|f| f.to_string_lossy().to_string()).unwrap_or_default()
}
Source::Demo { .. } => String::new(),
},
"camera" => p.meta.camera.clone(),
"lens" => p.meta.lens.clone(),
"iso" => p.meta.iso.map(|v| v.to_string()).unwrap_or_default(),
"rating" => p.rating.to_string(),
"title" => p.meta.title.clone(),
"creator" => p.meta.creator.clone(),
"ext" => ext.to_string(),
_ => format!("{{{tok}}}"),
},
"camera" => p.meta.camera.clone(),
"lens" => p.meta.lens.clone(),
"iso" => p.meta.iso.map(|v| v.to_string()).unwrap_or_default(),
"rating" => p.rating.to_string(),
"title" => p.meta.title.clone(),
"creator" => p.meta.creator.clone(),
"ext" => ext.to_string(),
_ => format!("{{{tok}}}"),
};
out.push_str(&v);
rest = &rest[i + j + 1..];
+28 -7
View File
@@ -1,6 +1,7 @@
//! Slideshow slides in the engine: the library's slide layout ([`SlideSettings`], Lightroom
//! Classic's Layout, Options and Backdrop panels; the `slideshow.layout` command) and render jobs
//! that compose a photo into its slide ([`SlideCompose`], [`crate::media::RenderJob::with_slide`]).
//! 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.
@@ -50,6 +51,8 @@ pub struct SlideCompose {
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 {
@@ -59,12 +62,19 @@ impl SlideCompose {
^ (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);
lightcraft_slideshow::compose(photo, bg.as_deref(), &self.settings, self.width, self.height)
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
}
}
@@ -83,9 +93,16 @@ impl crate::Session {
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).
pub fn slide_compose(&mut self, settings: &SlideSettings, width: usize, height: usize) -> Arc<SlideCompose> {
/// (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);
@@ -96,7 +113,11 @@ impl crate::Session {
let job = self.render_job(id, w, h, false, true)?;
Some(Backdrop { photo: id, job: Box::new(job), cache: self.slide_backdrop.clone() })
});
Arc::new(SlideCompose { settings, width, height, backdrop })
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
@@ -126,7 +147,7 @@ impl crate::Session {
(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);
let compose = self.slide_compose(settings, width, height, Some(id));
Some(job.with_slide(compose))
}
File diff suppressed because it is too large Load Diff
+90
View File
@@ -137,3 +137,93 @@ fn a_background_image_and_the_slide_key() {
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");
}
}
+207
View File
@@ -0,0 +1,207 @@
//! 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).
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);
});
}
}
}
}
+29 -1
View File
@@ -204,6 +204,20 @@ fn blurred_step(n: usize, a: f64, b: f64, sigma: f64) -> Vec<f32> {
/// 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;
@@ -237,6 +251,7 @@ pub fn compose(photo: &Rgba8, backdrop_image: Option<&Rgba8>, s: &SlideSettings,
}
});
}
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;
@@ -261,7 +276,7 @@ pub fn compose(photo: &Rgba8, backdrop_image: Option<&Rgba8>, s: &SlideSettings,
if has_photo {
draw_image(&mut out, photo, &l.photo, &l.cell, 1.0);
}
out
(out, l)
}
#[cfg(test)]
@@ -459,6 +474,19 @@ mod tests {
}
}
#[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 {
+1 -1
View File
@@ -19,5 +19,5 @@
mod compose;
mod settings;
pub use compose::{Layout, Rect, compose, layout, photo_size};
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};
+13 -1
View File
@@ -2057,5 +2057,17 @@
"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."
"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."
}
+13 -1
View File
@@ -1902,5 +1902,17 @@
"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."
"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."
}
+13 -1
View File
@@ -1902,5 +1902,17 @@
"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."
"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."
}
+13 -1
View File
@@ -1904,5 +1904,17 @@
"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.": "選択した写真をスライドとして表示します。再生では画面全体に表示され、縦横比プレビューはこのプレビューだけを変更します。"
"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.": "書き出しの透かし。写真の上に、写真に対する比率で描かれます。"
}
+13 -1
View File
@@ -1902,5 +1902,17 @@
"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."
"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."
}
+14 -1
View File
@@ -1616,5 +1616,18 @@
"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.": "Выбранное фото в виде слайда. При воспроизведении слайд заполняет экран; предпросмотр пропорций меняет только этот предпросмотр."
"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.": "Водяной знак экспорта, рисуется на фото и масштабируется относительно него.",
"© Your Name": "© Ваше имя"
}
+13 -1
View File
@@ -2096,5 +2096,17 @@
"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.": "Вибране фото як слайд. Під час відтворення слайд заповнює екран; попередній перегляд пропорцій змінює лише цей перегляд."
"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.": "Водяний знак експорту, малюється на фото й масштабується відносно нього."
}
+13 -1
View File
@@ -1902,5 +1902,17 @@
"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.": "将所选照片显示为幻灯片。播放时会填满屏幕;长宽比预览只改变此预览。"
"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.": "导出水印,绘制在照片上,大小相对于照片。"
}
+13 -1
View File
@@ -1902,5 +1902,17 @@
"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.": "將所選照片顯示為投影片。播放時會填滿螢幕;長寬比預覽只會改變此預覽。"
"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.": "匯出浮水印,繪製在照片上,大小相對於照片。"
}
+1
View File
@@ -28,6 +28,7 @@ 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;
+2
View File
@@ -657,6 +657,8 @@ fn slideshow_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
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);
+12 -2
View File
@@ -1,5 +1,6 @@
//! 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.
//! 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
@@ -177,7 +178,16 @@ pub fn preview(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
};
let ppp = ui.ctx().pixels_per_point();
let (pw, ph) = (((PREVIEW_W * ppp).round() as usize).max(16), ((h * ppp).round() as usize).max(16));
let key = s.pixel_key() ^ id.0.rotate_left(17) ^ ((pw as u64) << 32 | ph as u64);
// 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));
+265
View File
@@ -0,0 +1,265 @@
//! Settings ▸ Slideshow ▸ Overlays (Lightroom Classic's Slideshow Overlays panel): the identity
//! plate (text or an image file), rating stars, text overlays with tokens and the watermark.
//!
//! Every control goes through `slideshow.overlays` (validated there, saved with the library), like
//! the Layout sections beside it ([`super::slide_layout`]); the live preview next to them shows the
//! overlays on the active photo's slide. Sliders change the settings in memory while dragged and
//! save them when let go; text fields save when the edit ends (Return or leaving the field).
use egui::RichText;
use lightcraft_engine::export::Anchor;
use lightcraft_engine::slide_overlay::{
ANCHORS, OFFSET_MAX, OverlaySettings, PLATE_SIZE_MAX, Placement, RelativeTo, SHADOW_OFFSET_MAX, SHADOW_RADIUS_MAX, STAR_SIZE_MAX, TEXT_SIZE_MAX,
};
use serde_json::{Value, json};
use super::settings::{check, choices, heading, hint, row};
use crate::LightcraftApp;
use crate::text_field::TextField;
use crate::theme::Tokens;
use crate::widgets::register;
#[derive(Clone, Copy)]
enum Unit {
/// A fraction shown as a percentage (of the slide's shorter side, or an opacity).
Percent,
Degrees,
}
fn set(app: &mut LightcraftApp, p: Value) {
let _ = app.run("slideshow.overlays", p);
}
/// A slider (`slider:{id}`) over `lo..=hi`; `wrap(value)` makes the `slideshow.overlays` request.
fn slider(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, value: f64, lo: f64, hi: f64, unit: Unit, wrap: &dyn Fn(f64) -> Value) {
let (k, suffix) = match unit {
Unit::Percent => (100.0, " %"),
Unit::Degrees => (1.0, "°"),
};
let mut v = value * k;
let r = ui.add(egui::Slider::new(&mut v, lo * k..=hi * k).suffix(suffix).max_decimals(1));
register(ui.ctx(), format!("slider:{id}"), r.rect);
let p = wrap(v / k);
if r.changed()
&& let Ok(next) = app.session.slide_overlays.merged(&p)
{
app.session.slide_overlays = next;
}
if r.drag_stopped() || (r.changed() && !r.dragged()) {
set(app, p);
}
}
/// A colour button (`button:{id}`).
fn colour(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, value: [u8; 3], wrap: &dyn Fn([u8; 3]) -> Value) {
let mut c = value;
let r = egui::color_picker::color_edit_button_srgb(ui, &mut c);
register(ui.ctx(), format!("button:{id}"), r.rect);
if r.changed() {
set(app, wrap(c));
}
}
/// A checkbox (`check:settings.{id}`); true when it is on.
fn toggle(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, value: bool, label: &str, wrap: &dyn Fn(bool) -> Value) -> bool {
let mut v = value;
if check(ui, &format!("settings.{id}"), &mut v, label) {
set(app, wrap(v));
}
v
}
/// A one-line text field (`field:{id}`), saved when the edit ends.
fn text(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, value: &str, hint: &str, wrap: &dyn Fn(&str) -> Value) {
let mem = egui::Id::new(("slideshow-overlay-field", id));
let mut s: String = ui.data(|d| d.get_temp(mem)).unwrap_or_else(|| value.to_string());
let widget = format!("field:{id}");
let r = TextField::singleline(&widget, &mut s).hint(crate::i18n::tr(hint)).width(ui.available_width().clamp(80.0, 260.0)).show(ui);
if r.editing {
ui.data_mut(|d| d.insert_temp(mem, s.clone()));
} else {
ui.data_mut(|d| d.remove::<String>(mem));
}
if r.committed() && s != value {
set(app, wrap(&s));
}
}
/// The nine anchors as a 3 × 3 grid of buttons (`button:{id}-{0..8}`); true when one is picked.
fn anchor_grid(ui: &mut egui::Ui, id: &str, value: &mut Anchor) -> bool {
const GLYPHS: [&str; 9] = ["↖", "↑", "↗", "←", "•", "→", "↙", "↓", "↘"];
let mut changed = false;
ui.vertical(|ui| {
ui.spacing_mut().item_spacing = egui::vec2(2.0, 2.0);
for r in 0..3 {
ui.horizontal(|ui| {
for c in 0..3 {
let i = r * 3 + c;
let (Some(a), Some(g)) = (ANCHORS.get(i), GLYPHS.get(i)) else { continue };
if crate::widgets::text_button(ui, &format!("{id}-{i}"), g, *value == *a).clicked() && *value != *a {
*value = *a;
changed = true;
}
}
});
}
});
changed
}
/// Anchor, offsets and what the overlay is anchored to; `wrap` turns placement keys into the request.
fn placement(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, id: &str, p: &Placement, photo_too: bool, wrap: &dyn Fn(Value) -> Value) {
row(ui, t, "Position", |ui| {
let mut a = p.anchor;
if anchor_grid(ui, &format!("{id}Anchor"), &mut a) {
set(app, wrap(json!({"anchor": lightcraft_engine::slide_overlay::anchor_id(a)})));
}
});
if photo_too {
row(ui, t, "Anchored to", |ui| {
let mut r = p.relative_to;
if choices(ui, &format!("{id}RelativeTo"), &[(RelativeTo::Slide, "Slide"), (RelativeTo::Photo, "Photo")], &mut r) {
let v = if r == RelativeTo::Photo { "photo" } else { "slide" };
set(app, wrap(json!({"relativeTo": v})));
}
});
}
row(ui, t, "Offset X", |ui| {
slider(app, ui, &format!("{id}OffsetX"), p.offset_x, -OFFSET_MAX / 2.0, OFFSET_MAX / 2.0, Unit::Percent, &|v| wrap(json!({"offsetX": v})))
});
row(ui, t, "Offset Y", |ui| {
slider(app, ui, &format!("{id}OffsetY"), p.offset_y, -OFFSET_MAX / 2.0, OFFSET_MAX / 2.0, Unit::Percent, &|v| wrap(json!({"offsetY": v})))
});
}
/// The request changing text overlay `i` of `n` by `change` (the others kept).
fn text_change(i: usize, n: usize, change: Value) -> Value {
let list: Vec<Value> = (0..n).map(|k| if k == i { change.clone() } else { json!({}) }).collect();
json!({ "texts": list })
}
/// The Overlays sections.
pub fn sections(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
let s: OverlaySettings = app.session.slide_overlays.clone();
heading(ui, t, "Overlays");
// identity plate
let p = s.identity_plate.clone();
let plate = |v: Value| json!({ "identityPlate": v });
if toggle(app, ui, "slideshowPlate", p.enabled, "Identity plate", &|v| plate(json!({"enabled": v}))) {
let mem = egui::Id::new("slideshow-plate-graphic");
let mut graphic = !p.image.is_empty() || ui.data(|d| d.get_temp::<bool>(mem)).unwrap_or(false);
let before = graphic;
row(ui, t, "Style", |ui| choices(ui, "slideshowPlateStyle", &[(false, "Text"), (true, "Image")], &mut graphic));
if graphic != before {
ui.data_mut(|d| d.insert_temp(mem, graphic));
if !graphic && !p.image.is_empty() {
set(app, plate(json!({"image": ""})));
}
}
if graphic {
row(ui, t, "Image", |ui| {
if app.services.pick_files.is_some()
&& crate::widgets::text_button(ui, "slideshowPlateChoose", "Choose…", false).clicked()
&& let Some(f) = app.services.pick_files.as_mut().and_then(|pick| pick().into_iter().next())
{
set(app, plate(json!({"image": f})));
}
text(app, ui, "slideshowPlateImage", &p.image, "logo.png", &|v| plate(json!({"image": v})));
});
} else {
row(ui, t, "Text", |ui| text(app, ui, "slideshowPlateText", &p.text, "Your name or studio", &|v| plate(json!({"text": v}))));
row(ui, t, "Colour", |ui| colour(app, ui, "slideshowPlateColor", p.color, &|c| plate(json!({"color": c}))));
}
row(ui, t, "Size", |ui| slider(app, ui, "slideshowPlateSize", p.size, 0.01, PLATE_SIZE_MAX, Unit::Percent, &|v| plate(json!({"size": v}))));
row(ui, t, "Opacity", |ui| slider(app, ui, "slideshowPlateOpacity", p.opacity, 0.0, 1.0, Unit::Percent, &|v| plate(json!({"opacity": v}))));
placement(app, ui, t, "slideshowPlate", &p.place, true, &plate);
toggle(app, ui, "slideshowPlateBehind", p.behind, "Render behind image", &|v| plate(json!({"behind": v})));
}
// rating stars
ui.add_space(4.0);
let r = s.rating_stars.clone();
let stars = |v: Value| json!({ "ratingStars": v });
if toggle(app, ui, "slideshowStars", r.enabled, "Rating stars", &|v| stars(json!({"enabled": v}))) {
row(ui, t, "Colour", |ui| colour(app, ui, "slideshowStarsColor", r.color, &|c| stars(json!({"color": c}))));
row(ui, t, "Size", |ui| slider(app, ui, "slideshowStarsSize", r.size, 0.01, STAR_SIZE_MAX, Unit::Percent, &|v| stars(json!({"size": v}))));
placement(app, ui, t, "slideshowStars", &r.place, true, &stars);
}
// text overlays
ui.add_space(6.0);
ui.label(RichText::new(crate::i18n::tr("Text overlays")).font(t.semibold(11.5)).color(t.text));
let n = s.texts.len();
for (i, o) in s.texts.iter().enumerate() {
let id = format!("slideshowText{i}");
let wrap = |v: Value| text_change(i, n, v);
ui.add_space(2.0);
row(ui, t, &format!("{} {}", crate::i18n::tr("Text"), i + 1), |ui| {
text(app, ui, &id, &o.text, "{title} · {exposure}", &|v| wrap(json!({"text": v})));
if crate::widgets::text_button(ui, &format!("{id}Remove"), "Remove", false).clicked() {
set(app, json!({"removeText": i}));
}
});
if app.session.slide_overlays.texts.len() != n {
return; // removed: the indices moved; drawn again next frame
}
row(ui, t, "Size", |ui| {
slider(app, ui, &format!("{id}Size"), o.size, 0.01, TEXT_SIZE_MAX / 2.0, Unit::Percent, &|v| wrap(json!({"size": v})))
});
row(ui, t, "Colour", |ui| colour(app, ui, &format!("{id}Color"), o.color, &|c| wrap(json!({"color": c}))));
row(ui, t, "Opacity", |ui| slider(app, ui, &format!("{id}Opacity"), o.opacity, 0.0, 1.0, Unit::Percent, &|v| wrap(json!({"opacity": v}))));
placement(app, ui, t, &id, &o.place, true, &wrap);
let sh = o.shadow;
if toggle(app, ui, &format!("{id}Shadow"), sh.on, "Shadow", &|v| wrap(json!({"shadow": v}))) {
row(ui, t, "Opacity", |ui| {
slider(app, ui, &format!("{id}ShadowOpacity"), sh.opacity, 0.0, 1.0, Unit::Percent, &|v| wrap(json!({"shadowOpacity": v})))
});
row(ui, t, "Offset", |ui| {
slider(app, ui, &format!("{id}ShadowOffset"), sh.offset, 0.0, SHADOW_OFFSET_MAX, Unit::Percent, &|v| wrap(json!({"shadowOffset": v})))
});
row(ui, t, "Radius", |ui| {
slider(app, ui, &format!("{id}ShadowRadius"), sh.radius, 0.0, SHADOW_RADIUS_MAX, Unit::Percent, &|v| wrap(json!({"shadowRadius": v})))
});
row(ui, t, "Angle", |ui| {
slider(app, ui, &format!("{id}ShadowAngle"), sh.angle, -180.0, 180.0, Unit::Degrees, &|v| wrap(json!({"shadowAngle": v})))
});
}
}
if n < lightcraft_engine::slide_overlay::MAX_TEXTS && crate::widgets::text_button(ui, "slideshowAddText", "Add text overlay", false).clicked() {
set(app, json!({"addText": {}}));
}
hint(
ui,
t,
"Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.",
);
// watermark
ui.add_space(4.0);
let w = s.watermark.clone();
let wm = |v: Value| json!({ "watermark": v });
if toggle(app, ui, "slideshowWatermark", s.show_watermark, "Watermark", &|v| json!({"showWatermark": v})) {
row(ui, t, "Text", |ui| text(app, ui, "slideshowWatermarkText", &w.text, "© Your Name", &|v| wm(json!({"text": v}))));
row(ui, t, "Size", |ui| slider(app, ui, "slideshowWatermarkSize", w.size as f64, 0.01, 0.15, Unit::Percent, &|v| wm(json!({"size": v}))));
row(ui, t, "Opacity", |ui| {
slider(app, ui, "slideshowWatermarkOpacity", w.opacity as f64, 0.05, 1.0, Unit::Percent, &|v| wm(json!({"opacity": v})))
});
row(ui, t, "Position", |ui| {
let mut a = w.anchor;
if anchor_grid(ui, "slideshowWatermarkAnchor", &mut a) {
set(app, wm(json!({"anchor": lightcraft_engine::slide_overlay::anchor_id(a)})));
}
});
let export_wm = app.session.last_export.as_ref().and_then(|e| e.get("watermark")).filter(|v| v.is_object() || v.is_string()).cloned();
if let Some(e) = export_wm
&& crate::widgets::text_button(ui, "slideshowUseExportWatermark", "Use export watermark", false).clicked()
{
set(app, wm(e));
}
hint(ui, t, "The export watermark, drawn on the photo and sized relative to it.");
}
for warning in app.session.slide_overlays.warnings() {
ui.label(RichText::new(warning).size(11.0).color(t.caution));
}
}
+1 -1
View File
@@ -182,7 +182,7 @@ fn slide_job(app: &mut LightcraftApp, id: PhotoId, area: Rect, ppp: f32, texture
let photo = app.session.catalog.photo(id)?.clone();
let frame = Frame::with_lens(photo.width.max(1) as usize, photo.height.max(1) as usize, &photo.develop, true, photo.embedded_lens.as_ref());
let native = super::detail::output_px(&frame);
if !app.session.slide_layout.is_plain() {
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));
+83
View File
@@ -344,3 +344,86 @@ fn settings_shows_the_slide_layout_and_a_live_preview() {
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);
}
+2 -1
View File
@@ -50,7 +50,8 @@ mode ([mcp.md](mcp.md)) is a thin layer over this channel. Implementation:
| `engine.execute {command: "activity.cancel"}` | `{id}` or `{all: true}` | Asks the task (or every cancellable one) to stop at its next file or photo → `{cancelled: n}`; an unknown id (it may have just finished) or a task that can't be cancelled is an error |
| `ui.render` | `{id?, size?, path?}` | Render a photo (PNG to `path`), `{width, height}` |
| `engine.execute {command: "slideshow.layout"}` | `{margins?, marginLeft?, …, zoomToFill?, strokeBorder?, castShadow?, backdropColor?, colorWash?, backgroundImage?, …}` (see `engine.commands`) | The slideshow's slide layout (Layout, Options, Backdrop), saved with the library; omitted keys keep their value, `{}` reads it; out-of-range numbers are clamped, a wrong type or unknown key is refused and nothing changes |
| `engine.execute {command: "slideshow.preview"}` | `{id?, width?, height?, path?}` | One slide composed with the layout, as the slideshow plays it: a PNG written to `path`, or returned base64 in `png` without one; `{id, width, height, cell, photo}` (`cell` and `photo` are `[x0, y0, x1, y1]` in slide pixels). Sides 16–4096; one side given takes the other from the aspect preview |
| `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 |
Image navigation is also available directly as the UI command `view.navigate`, with
+4 -1
View File
@@ -200,7 +200,10 @@ Slideshow slides, for example: `cmd_slideshow_layout` reads or changes the slide
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.
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
+5 -5
View File
@@ -52,10 +52,10 @@ cited here still exists. Formerly docs/parity.md.
| X. Cross-cutting behaviours (BEHAV) | 18 | 6 | 1 | 1 | 8/8 (100%) | 6/9 (67%) |
| Y. Menus | 82 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) |
| Z. Keyboard shortcuts (desktop) | 75 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) |
| Lightroom Classic extras | 35 | 17 | 31 | 9 | — | 23/25 (92%) |
| **Total** | 399 | 51 | 69 | 36 | 192/200 (96%) | 143/155 (92%) |
| Lightroom Classic extras | 35 | 18 | 30 | 9 | — | 23/25 (92%) |
| **Total** | 399 | 52 | 68 | 36 | 192/200 (96%) | 143/155 (92%) |
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.8%** of 519 in-scope rows — P0 98.0% of 200 · P1 95.2% of 155 · P2 49.1% of 163.
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.9%** of 519 in-scope rows — P0 98.0% of 200 · P1 95.2% of 155 · P2 49.4% of 163.
<!-- /parity:summary -->
## Top gaps and shortcut notes
@@ -730,11 +730,11 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06-
| LRC-SS-TEMPLATES | Slideshow templates | P2 | ⬜ | | |
| LRC-SS-OPTIONS | Slideshow options | P2 | ✅ | `cmd:slideshow.layout`, `cmd:slideshow.preview`, `path:crates/slideshow/src/compose.rs` | Zoom to Fill Frame (the photo covers its image cell, cropped to it; off: fits inside), Stroke Border (colour, width as a share of the slide's shorter side, drawn around the photo), Cast Shadow (opacity, offset, radius, angle; a blurred offset rectangle under the photo and its stroke). Composed by `lightcraft-slideshow` (CPU, no UI) for playback, the Settings ▸ Slideshow preview and `slideshow.preview`; edited in Settings ▸ Slideshow until the Slideshow module view exists |
| LRC-SS-LAYOUT | Slideshow layout | P2 | 🟡 | `cmd:slideshow.layout`, `cmd:slideshow.preview`, `path:crates/ui-egui/src/panels/slide_layout.rs` | Margins left / right / top / bottom (linked or each on its own; shares of the slide's shorter side, so any screen or output size looks the same), Show Guides, Aspect Preview (Screen, 16:9, 4:3). Missing: the Slideshow module's slide editor — guides are drawn only in the Settings ▸ Slideshow preview and can't be dragged to set the margins; the aspect preview applies to that preview and `slideshow.preview` (playback always fills the screen, as in Classic) |
| LRC-SS-OVERLAYS | Slideshow overlays | P2 | ⬜ | | |
| LRC-SS-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 | 🟡 | `cmd:slideshow.playback`, `cmd:view.slideshowOptions`, `cmd:slideshow.togglePause`, `cmd:slideshow.next`, `cmd:slideshow.previous`, `cmd:slideshow.end` | Playback settings (library preference, `slideshow.playback`; Settings ▸ Slideshow, View ▸ Slideshow Options…): slide duration 0.5–120 s, fade 0–10 s (≤ slide; 0 = cut) as a crossfade or through a colour, random order (seeded shuffle, every photo once per pass, a new order each pass), repeat (off: ends after the last slide), prepare previews in advance (the next 2 slides render off the UI thread; a transition never starts before its slide is rendered — the current one is held). Space / ←/→ (cut) / Esc and a control bar (previous, play/pause, next, n / N, end) that shows on pointer movement. Missing: the Slideshow module view and its "preview in the view" (phase 2 of the module plan), music and sync to music (LRC-SS-MUSIC), pan & zoom, manual-advance mode, choosing the screen to play on, overlays and titles on the slides (LRC-SS-OVERLAYS, LRC-SS-TITLES). Slides are composed with the layout, options and backdrop (LRC-SS-LAYOUT / OPTIONS / BACKDROP) |
| LRC-SS-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 | ⬜ | | |
+13
View File
@@ -2,6 +2,19 @@
## 2026-10-10
### 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),