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