From aa6054cd1ad4e51965ded6efc623e625811e527a Mon Sep 17 00:00:00 2001 From: john01px2022 <121329615+john01px2022@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:58:53 -0700 Subject: [PATCH] LR-EXP-WATERMARK: preview the watermark in the Export dialog (#827) With Watermark on, the Export dialog shows the first photo it will export (its cached thumbnail) with the watermark drawn as the file will have it. The watermark is redrawn on a copy only when it or the output size changes. draw_watermark_preview scales the 6 px minimum text size and the 1 px minimum shadow offset by the preview size over the export size, so a small watermark doesn't look bigger in the preview than in the file. Exports draw with a scale of 1 and are unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/export.rs | 53 +++++++++++--- crates/ui-egui/locales/de.json | 2 + crates/ui-egui/locales/es.json | 2 + crates/ui-egui/locales/fr.json | 2 + crates/ui-egui/locales/ja.json | 2 + crates/ui-egui/locales/pt-br.json | 2 + crates/ui-egui/locales/ru.json | 2 + crates/ui-egui/locales/uk.json | 2 + crates/ui-egui/locales/zh-hans.json | 2 + crates/ui-egui/locales/zh-hant.json | 2 + crates/ui-egui/src/headless.rs | 23 ++++++ crates/ui-egui/src/panels/dialogs.rs | 106 +++++++++++++++++++++++++++ docs/parity-checklist.md | 2 +- 13 files changed, 192 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 6a9e9147c..edc4760cf 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -297,8 +297,20 @@ fn watermark_cell_glyphs( /// Draw `wm` onto `img` (straight alpha blending of the encoded values). pub fn draw_watermark(img: &mut Rgba8, wm: &Watermark) { + blend_watermark(img, wm, 1.0); +} + +/// Draw `wm` onto `img`, a preview of an export whose short edge is `export_short_edge` pixels. +/// The smallest text size and shadow offset scale with the preview, so it looks like the file. +pub fn draw_watermark_preview(img: &mut Rgba8, wm: &Watermark, export_short_edge: usize) { + let short = img.width.min(img.height); + let scale = if export_short_edge == 0 { 1.0 } else { short as f32 / export_short_edge as f32 }; + blend_watermark(img, wm, scale); +} + +fn blend_watermark(img: &mut Rgba8, wm: &Watermark, floor_scale: f32) { let width = img.width; - watermark_coverage(img.width, img.height, wm, crate::fonts::CRAFT_FONTS, |x, y, k, col| { + watermark_coverage(img.width, img.height, wm, crate::fonts::CRAFT_FONTS, floor_scale, |x, y, k, col| { let p = &mut img.data[y * width + x]; for c in 0..3 { p[c] = (p[c] as f32 + (col[c] as f32 - p[c] as f32) * k).round() as u8; @@ -311,14 +323,14 @@ pub fn draw_watermark(img: &mut Rgba8, wm: &Watermark) { pub fn draw_watermark_deep(img: &mut DeepImage, wm: &Watermark) { let (width, trc) = (img.width, img.space.trc()); match &mut img.samples { - DeepSamples::U16(v) => watermark_coverage(img.width, img.height, wm, crate::fonts::CRAFT_FONTS, |x, y, k, col| { + DeepSamples::U16(v) => watermark_coverage(img.width, img.height, wm, crate::fonts::CRAFT_FONTS, 1.0, |x, y, k, col| { let i = (y * width + x) * 3; for c in 0..3 { let p = v[i + c] as f32; v[i + c] = (p + (col[c] as f32 * 257.0 - p) * k).round().clamp(0.0, 65535.0) as u16; } }), - DeepSamples::F32(v) => watermark_coverage(img.width, img.height, wm, crate::fonts::CRAFT_FONTS, |x, y, k, col| { + DeepSamples::F32(v) => watermark_coverage(img.width, img.height, wm, crate::fonts::CRAFT_FONTS, 1.0, |x, y, k, col| { let i = (y * width + x) * 3; for c in 0..3 { let target = trc.decode(col[c] as f32 / 255.0); @@ -330,12 +342,14 @@ pub fn draw_watermark_deep(img: &mut DeepImage, wm: &Watermark) { /// Lay out `wm` on a `width × height` image and call `blend(x, y, coverage × opacity, colour)` for /// every covered pixel (shadow pass first). `craft` is [`crate::fonts::CRAFT_FONTS`] (a parameter -/// so tests can render without it). +/// so tests can render without it). `floor_scale` is this image's size over the exported file's +/// (1 when drawing the file itself): the 6 px minimum text size is a minimum in the file. fn watermark_coverage( width: usize, height: usize, wm: &Watermark, craft: &'static [crate::fonts::CraftFont], + floor_scale: f32, mut blend_px: impl FnMut(usize, usize, f32, [u8; 3]), ) { use ab_glyph::{Font, PxScale, ScaleFont, point}; @@ -355,7 +369,7 @@ fn watermark_coverage( Vec::new() }; let short = width.min(height) as f32; - let px = (wm.size.clamp(0.005, 0.5) * short).max(6.0); + let px = (wm.size.clamp(0.005, 0.5) * short).max(6.0 * floor_scale); let mut glyphs = Vec::new(); let mut x = 0.0f32; let mut y = 0.0f32; @@ -459,7 +473,7 @@ fn watermark_coverage( 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)] }; + if wm.shadow { &[((px * 0.05).max(floor_scale), [0, 0, 0], 0.45), (0.0, wm.color, 1.0)] } else { &[(0.0, wm.color, 1.0)] }; for &(off, col, a) in passes { for (face, g, rotation) in &glyphs { if let Some(o) = fonts.get(*face).and_then(|font| font.outline_glyph(g.clone())) { @@ -1996,6 +2010,27 @@ mod tests { assert!(ExportOptions::from_json(&serde_json::json!({"watermark": ""})).watermark.is_none()); } + /// A preview keeps the export's 6 px minimum text size as a share of the photo: a watermark + /// clamped to 6 px in a 150 px export covers the same share of a 600 px preview. + #[test] + fn watermark_preview_scales_the_minimum_text_size() { + let wm = Watermark { text: "LightCraft".into(), size: 0.01, opacity: 1.0, shadow: false, ..Default::default() }; + let rows = |img: &Rgba8| (0..img.height).filter(|&y| (0..img.width).any(|x| img.get(x, y)[0] > 64)).count() as f32; + let mut export = Rgba8::new(200, 150); + draw_watermark(&mut export, &wm); + let mut preview = Rgba8::new(800, 600); + draw_watermark_preview(&mut preview, &wm, 150); + let mut unscaled = Rgba8::new(800, 600); + draw_watermark(&mut unscaled, &wm); + let (export_share, preview_share) = (rows(&export) / 150.0, rows(&preview) / 600.0); + assert!((preview_share - export_share).abs() < 0.02, "export {export_share}, preview {preview_share}"); + assert!(rows(&unscaled) < rows(&preview) / 2.0, "the unscaled 6 px floor would draw it too small"); + // drawn at the export's own size, the preview is the export + let mut same = Rgba8::new(200, 150); + draw_watermark_preview(&mut same, &wm, 150); + assert_eq!(same.data, export.data); + } + fn vertical_japanese() -> Watermark { let options = ExportOptions::from_json(&json!({"watermark": {"text": "日本語", "vertical": true, "size": 0.1, "shadow": false, "opacity": 1.0}})); @@ -2019,7 +2054,7 @@ mod tests { assert_eq!(watermark_fonts(&[]).len(), 1, "Inter only"); for wm in [vertical_japanese(), Watermark { text: "LightCraft 日本語".into(), ..Watermark::default() }] { let mut covered = 0usize; - watermark_coverage(400, 300, &wm, &[], |_, _, k, _| covered += usize::from(k > 0.0)); + watermark_coverage(400, 300, &wm, &[], 1.0, |_, _, k, _| covered += usize::from(k > 0.0)); assert!(covered > 0, "{:?} draws something", wm.text); } } @@ -2056,7 +2091,7 @@ mod tests { ..Default::default() }; let mut pixels = Vec::new(); - watermark_coverage(400, 300, &wm, craft, |x, y, k, _| { + watermark_coverage(400, 300, &wm, craft, 1.0, |x, y, k, _| { if k > 0.5 { pixels.push((x, y)); } @@ -2081,7 +2116,7 @@ mod tests { ..Default::default() }; let mut pixels = vec![0.0f32; 400 * 300]; - watermark_coverage(400, 300, &wm, craft, |x, y, k, _| pixels[y * 400 + x] = k); + watermark_coverage(400, 300, &wm, craft, 1.0, |x, y, k, _| pixels[y * 400 + x] = k); assert!(pixels.iter().any(|&k| k > 0.0)); pixels }; diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index aa24e0f10..0bbca889e 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -793,6 +793,7 @@ "List Presets": "Vorgaben auflisten", "List Profiles": "Profile auflisten", "List this folder in Local from now on": "Diesen Ordner ab jetzt unter „Lokal“ auflisten", + "Loading preview…": "Vorschau wird geladen…", "Local": "Lokal", "Local folders": "Lokale Ordner", "Local library — no cloud account needed": "Lokale Bibliothek — kein Cloud-Konto erforderlich", @@ -1071,6 +1072,7 @@ "Preview Build Progress": "Fortschritt der Vorschauerstellung", "Preview only": "Nur Vorschau", "Preview size": "Vorschaugröße", + "Preview unavailable": "Vorschau nicht verfügbar", "Previews": "Vorschauen", "Previous Photo": "Vorheriges Foto", "Print": "Drucken", diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index c4f2e8357..5c2aa4e33 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -754,6 +754,7 @@ "List Presets": "Enumerar ajustes preestablecidos", "List Profiles": "Enumerar perfiles", "List this folder in Local from now on": "Mostrar esta carpeta en Local a partir de ahora", + "Loading preview…": "Cargando vista previa…", "Local": "Local", "Local folders": "Carpetas locales", "Local library — no cloud account needed": "Biblioteca local: no requiere cuenta en la nube", @@ -1024,6 +1025,7 @@ "Preview Build Progress": "Progreso de generación de vistas previas", "Preview only": "Solo vista previa", "Preview size": "Tamaño de la vista previa", + "Preview unavailable": "Vista previa no disponible", "Previews": "Vistas previas", "Previous Photo": "Foto anterior", "Print": "Imprimir", diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index f3422bc0a..72bed4d81 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -754,6 +754,7 @@ "List Presets": "Lister les préréglages", "List Profiles": "Lister les profils", "List this folder in Local from now on": "Afficher désormais ce dossier dans Local", + "Loading preview…": "Chargement de l'aperçu…", "Local": "Local", "Local folders": "Dossiers locaux", "Local library — no cloud account needed": "Bibliothèque locale : aucun compte cloud nécessaire", @@ -1024,6 +1025,7 @@ "Preview Build Progress": "Progression de la génération des aperçus", "Preview only": "Aperçu uniquement", "Preview size": "Taille de l'aperçu", + "Preview unavailable": "Aperçu indisponible", "Previews": "Aperçus", "Previous Photo": "Photo précédente", "Print": "Imprimer", diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index a7a3a32ef..56e1b9e63 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -561,6 +561,7 @@ "List History": "履歴を一覧表示", "List Presets": "プリセットを一覧表示", "List Profiles": "プロファイルを一覧表示", + "Loading preview…": "プレビューを読み込み中…", "Locate Photo": "写真の場所を指定", "Lock": "ロック", "Lock Crop Aspect": "切り抜きの縦横比を固定", @@ -640,6 +641,7 @@ "Press keys…": "キーを押してください…", "Preview Build Progress": "プレビュー作成の進行状況", "Preview only: editing the camera's embedded JPEG": "プレビューのみ:カメラの埋め込みJPEGを編集中", + "Preview unavailable": "プレビューを表示できません", "Previous Photo": "前の写真", "Profile Browser": "プロファイルブラウザー", "Profile Menu": "プロファイルメニュー", diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index c3d9a8d78..911a56f49 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -762,6 +762,7 @@ "List Presets": "Listar predefinições", "List Profiles": "Listar perfis", "List this folder in Local from now on": "Listar esta pasta em Local a partir de agora", + "Loading preview…": "Carregando prévia…", "Local": "Local", "Local folders": "Pastas locais", "Local library — no cloud account needed": "Biblioteca local — não precisa de conta na nuvem", @@ -1035,6 +1036,7 @@ "Preview only": "Somente pré-visualização", "Preview only: editing the camera's embedded JPEG": "Somente prévia: editando o JPEG incorporado da câmera", "Preview size": "Tamanho da pré-visualização", + "Preview unavailable": "Prévia indisponível", "Previews": "Pré-visualizações", "Previous Photo": "Foto anterior", "Print": "Imprimir", diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index bf9974740..7ca4e25c9 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -627,6 +627,7 @@ "List Presets": "Список пресетов", "List Profiles": "Список профилей", "List this folder in Local from now on": "Показывать эту папку в «Локальном» и дальше", + "Loading preview…": "Загрузка просмотра…", "Local": "Локальное", "Local folders": "Локальные папки", "Local library — no cloud account needed": "Локальная библиотека — учётная запись в облаке не нужна", @@ -855,6 +856,7 @@ "Preview Build Progress": "Ход создания просмотров", "Preview only": "Только просмотр", "Preview size": "Размер просмотра", + "Preview unavailable": "Просмотр недоступен", "Previews": "Просмотры", "Previous Photo": "Предыдущее фото", "Print": "Печать", diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index c73c05b93..0b1107418 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -109,6 +109,7 @@ "Lines +/−": "Рядки +/−", "Lines added": "Додані рядки", "Lines deleted": "Видалені рядки", + "Loading preview…": "Завантаження перегляду…", "Lock": "Заблокувати", "Merged PRs": "Об’єднані PR", "Model": "Модель", @@ -120,6 +121,7 @@ "No matching commands": "Немає відповідних команд", "No model credits were built into this copy.": "У цю копію не вбудовано дані про використані моделі.", "Press keys…": "Натисніть клавіші…", + "Preview unavailable": "Перегляд недоступний", "Radial": "Радіальний", "Random": "Випадковий", "Real name": "Справжнє ім’я", diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 21fa4e4e9..ae3a905b8 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -548,6 +548,7 @@ "Lines +/−": "行数 +/−", "Lines added": "新增行数", "Lines deleted": "删除行数", + "Loading preview…": "正在加载预览…", "Lock": "锁定", "Lock Crop Aspect": "锁定裁剪长宽比", "Locked": "已锁定", @@ -594,6 +595,7 @@ "Presets and profiles": "预设和配置文件", "Press keys…": "请按键…", "Preview only: editing the camera's embedded JPEG": "仅预览:正在编辑相机内嵌的 JPEG", + "Preview unavailable": "预览不可用", "Proof copy created": "已创建校样副本", "Quit Anyway": "仍然退出", "Quit with unsaved changes?": "有未保存的更改,确定退出吗?", diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index d2e0a30c2..f9a5d26b4 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -548,6 +548,7 @@ "Lines +/−": "行數 +/−", "Lines added": "新增行數", "Lines deleted": "刪除行數", + "Loading preview…": "正在載入預覽…", "Lock": "鎖定", "Lock Crop Aspect": "鎖定裁切長寬比", "Locked": "已鎖定", @@ -594,6 +595,7 @@ "Presets and profiles": "預設集與描述檔", "Press keys…": "請按鍵…", "Preview only: editing the camera's embedded JPEG": "僅預覽:正在編輯相機內嵌的 JPEG", + "Preview unavailable": "無法使用預覽", "Proof copy created": "已建立校樣副本", "Quit Anyway": "仍要結束", "Quit with unsaved changes?": "有未儲存的變更,確定要結束嗎?", diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index d0104f7de..89ac1542e 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -1762,6 +1762,29 @@ mod tests { assert!(w1 < 700.0, "{w1}"); } + /// With a watermark on, the Export dialog shows the first photo with the watermark on it. + #[test] + fn export_dialog_previews_the_watermark() { + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + h.request("engine.execute", json!({"command": "dialog.export", "params": {}}), t); + let preview = |h: &mut Headless| { + let r = h.request("ui.widgets", json!({}), t); + r["result"].as_array().and_then(|a| a.iter().find(|w| w["id"] == "image:exportWatermarkPreview")).map(|w| w["rect"].clone()) + }; + h.settle(SETTLE); + assert!(preview(&mut h).is_none(), "no preview without a watermark"); + if let Some(crate::state::Dialog::Export { opts, .. }) = &mut h.app.ui.dialog { + opts.watermark = Some(lightcraft_engine::export::Watermark { text: "© LightCraft".into(), ..Default::default() }); + } + let t0 = Instant::now(); + while preview(&mut h).is_none() && t0.elapsed() < Duration::from_secs(30) { + h.step(); + } + let rect = preview(&mut h).expect("the watermark preview is shown"); + assert!(rect[2].as_f64().unwrap() > 50.0 && rect[3].as_f64().unwrap() > 50.0, "{rect}"); + } + #[test] fn export_dialog_runs_in_the_background() { let mut h = demo([1200.0, 900.0]); diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index aa287c952..bf70eeb63 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -54,6 +54,104 @@ pub(crate) fn rename_preview( (read(&rows), total) } +/// Largest size of the Export dialog's watermark preview (points; fits the 380 pt dialog). +const WM_PREVIEW_MAX: egui::Vec2 = egui::vec2(348.0, 200.0); + +/// The Export dialog's watermark preview: the first exported photo's thumbnail with the watermark +/// drawn on it as the file will have it at size `resize` (`None` = full size). The thumbnail +/// (cached, else rendered) is made on a worker thread once per photo and catalog change; the +/// watermark is redrawn on a copy only when it or the output size changes, which is quick at +/// thumbnail size, so the preview follows the sliders. +fn watermark_preview( + app: &mut LightcraftApp, + ui: &mut egui::Ui, + wm: &lightcraft_engine::export::Watermark, + resize: Option, +) { + use std::hash::{Hash, Hasher}; + type Base = std::sync::Arc>>>; + type Shown = (u64, lightcraft_engine::export::Watermark, usize, egui::TextureHandle); + let t = Tokens::get(ui.ctx()); + let Some(&id) = app.session.targets(&json!({})).first() else { return }; + let Some(photo) = app.session.catalog.photo(id).cloned() else { return }; + let (out_w, out_h) = lightcraft_engine::export::output_size(&photo, &lightcraft_engine::export::ExportOptions { resize, ..Default::default() }); + let export_short = out_w.min(out_h); + let mut h = std::collections::hash_map::DefaultHasher::new(); + (id, app.session.catalog.revision).hash(&mut h); + let key = h.finish(); + let ctx = ui.ctx().clone(); + let (base_id, shown_id) = (egui::Id::new("export-wm-preview-base"), egui::Id::new("export-wm-preview-shown")); + let base = match ctx.data(|d| d.get_temp::<(u64, Base)>(base_id)) { + Some((k, base)) if k == key => base, + _ => { + let base: Base = Default::default(); + match app.session.thumb_job(id, 512) { + Some(job) => { + let (out, repaint) = (base.clone(), ctx.clone()); + let work = move || { + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| job.run().rendered.map(|r| r.image))) + .unwrap_or_else(|_| Err("the preview failed unexpectedly".into())); + *out.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(r); + repaint.request_repaint(); + }; + #[cfg(not(target_arch = "wasm32"))] + if let Err(e) = std::thread::Builder::new().name("lc-watermark-preview".into()).spawn(work) { + log::warn!("watermark preview: {e}"); + } + #[cfg(target_arch = "wasm32")] + work(); + } + None => *base.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Err("no such photo".into())), + } + ctx.data_mut(|d| d.insert_temp(base_id, (key, base.clone()))); + base + } + }; + let shown = ctx.data(|d| d.get_temp::(shown_id)); + let tex = match shown { + Some((k, w, s, tex)) if k == key && &w == wm && s == export_short => tex, + _ => { + let photo_px = match &*base.lock().unwrap_or_else(std::sync::PoisonError::into_inner) { + Some(Ok(img)) => Ok(img.clone()), + Some(Err(e)) => Err(Some(e.clone())), + None => Err(None), + }; + let mut img = match photo_px { + Ok(img) => img, + Err(e) => { + let note = if e.is_some() { "Preview unavailable" } else { "Loading preview…" }; + let r = ui.label(egui::RichText::new(crate::i18n::tr(note)).color(t.text_dim)); + if let Some(e) = e { + r.on_hover_text(e); + } + return; + } + }; + lightcraft_engine::export::draw_watermark_preview(&mut img, wm, export_short); + // shown through the monitor profile like every other preview + if let Some(d) = app.renderer.display() + && let Err(e) = d.profile.from_srgb(&mut img) + { + log::warn!("display profile: {e}"); + } + let color = std::sync::Arc::new(egui::ColorImage::from_rgba_unmultiplied([img.width, img.height], &img.as_bytes())); + let tex = match shown { + Some((.., mut tex)) => { + tex.set(color, egui::TextureOptions::LINEAR); + tex + } + None => ctx.load_texture("export-wm-preview", color, egui::TextureOptions::LINEAR), + }; + ctx.data_mut(|d| d.insert_temp(shown_id, (key, wm.clone(), export_short, tex.clone()))); + tex + } + }; + let size = tex.size_vec2(); + let scale = (WM_PREVIEW_MAX.x.min(ui.available_width()) / size.x).min(WM_PREVIEW_MAX.y / size.y); + let r = ui.vertical_centered(|ui| ui.add(egui::Image::new((tex.id(), size * scale)))).inner; + crate::widgets::register(ui.ctx(), "image:exportWatermarkPreview", r.rect); +} + /// About dialog tabs: (widget id suffix, label). The credits come from `crate::credits`. pub const ABOUT_TABS: &[(&str, &str)] = &[("about", "About"), ("contributors", "Contributors"), ("models", "Models")]; @@ -629,6 +727,10 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { if ui.checkbox(&mut wm_on, crate::i18n::tr("Watermark")).changed() { opts.watermark = wm_on.then(|| lightcraft_engine::export::Watermark { text: "© ".into(), ..Default::default() }); } + let wm_shown = opts.watermark.clone(); + if let Some(wm) = &wm_shown { + watermark_preview(app, ui, wm, (!*full_size).then_some(*resize)); + } if let Some(wm) = &mut opts.watermark { // text, or a graphic (a logo with transparency) let mut graphic = !wm.image.is_empty() || ui.data(|d| d.get_temp::(egui::Id::new("wm-graphic"))).unwrap_or(false); @@ -681,6 +783,10 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ui.checkbox(&mut wm.shadow, crate::i18n::tr("Shadow")); } } + if opts.watermark != wm_shown { + // the preview above was drawn with the old settings + ui.ctx().request_repaint(); + } } ui.add_space(4.0); if opts.format == F::Tiff { diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 099411089..569aca39d 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -391,7 +391,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EXP-HDR | HDR output | P2 | 🟡 | `cmd:app.export`, `crates/ui-egui/src/panels/dialogs.rs` | HDR output checkbox in the export dialog: gain map JPEG, PQ AVIF, float TIFF; no HDR presets yet | | LR-EXP-SHARPEN | Output sharpening | P1 | ✅ | `cmd:app.export` (`sharpen`, `sharpenAmount`) | | | LR-EXP-METADATA | Metadata policy | P1 | ✅ | `cmd:app.export` (`metadata`, `removeLocation`) | | -| LR-EXP-WATERMARK | Watermark | P1 | ✅ | `cmd:app.export` (`watermark`: text, or `{text, vertical, size, opacity, anchor, inset, color, shadow, image, imageWidth}`), `crates/engine/src/export.rs` (`Watermark`, `WATERMARK_PARAMS`) | text (`size` = height as a fraction of the short edge, 0.005..0.5, default 0.035; `color` sRGB `[r,g,b]`; `shadow`; Japanese vertical glyph forms and origins, tested by `biz_ud_vertical_watermarks_place_punctuation_at_the_top_right`; extended grapheme cells keep decomposed dakuten/handakuten and Latin accents together, tested by `vertical_watermarks_keep_combining_marks_in_one_cell` and `upright_watermark_cell_retains_multiple_glyphs` (see `docs/vertical-watermarks.md`)) or a graphic with transparency (`image` path, `imageWidth` = fraction of the photo's width, 0.01..1, converted to the output colour space); `anchor`, `inset` (fraction of the short edge, 0..0.4), `opacity` 0..1. Unknown keys and out-of-range values are errors (issue #183) | +| LR-EXP-WATERMARK | Watermark | P1 | ✅ | `cmd:app.export` (`watermark`: text, or `{text, vertical, size, opacity, anchor, inset, color, shadow, image, imageWidth}`), `crates/engine/src/export.rs` (`Watermark`, `WATERMARK_PARAMS`) | text (`size` = height as a fraction of the short edge, 0.005..0.5, default 0.035; `color` sRGB `[r,g,b]`; `shadow`; Japanese vertical glyph forms and origins, tested by `biz_ud_vertical_watermarks_place_punctuation_at_the_top_right`; extended grapheme cells keep decomposed dakuten/handakuten and Latin accents together, tested by `vertical_watermarks_keep_combining_marks_in_one_cell` and `upright_watermark_cell_retains_multiple_glyphs` (see `docs/vertical-watermarks.md`)) or a graphic with transparency (`image` path, `imageWidth` = fraction of the photo's width, 0.01..1, converted to the output colour space); `anchor`, `inset` (fraction of the short edge, 0..0.4), `opacity` 0..1. Unknown keys and out-of-range values are errors (issue #183). The Export dialog previews it on the first exported photo's thumbnail (`draw_watermark_preview` scales the 6 px minimum text size to the preview so it matches the file), tested by `watermark_preview_scales_the_minimum_text_size` and `export_dialog_previews_the_watermark` | | LR-EXP-NAMING | File naming | P1 | ✅ | `cmd:app.export` (`naming`, `startNumber`), `crates/engine/src/rename.rs` (`expand_tokens`) | one template language for export, Rename Photos and import renaming: `{name}`, `{num}` (number at the end of the name), `{seq}` / `{seq:N}`, `{date}` / `{date:%Y-%m-%d}`, `{folder}`, `{camera}`, `{lens}`, `{iso}`, `{rating}`, `{title}`, `{creator}`, `{ext}`; free-form template rather than a list of named schemes | | LR-EXP-LOCATION | Destination folder | P0 | ✅ | `cmd:app.export` (`dir`, `subfolder`, `conflict`: unique / overwrite / skip), `cmd:export.checkTarget`, `crates/ui-egui/src/panels/dialogs.rs` (Choose… folder picker), `crates/engine/src/originals.rs`, `crates/catalog/src/safe_file.rs` | folder picker on the desktop; existing files get `-2`, `-3`… by default; the policy covers a file and its sidecars together; a catalogued original (or its sidecar) is never written over, whatever the policy or an exact `path` (export, `ui.render`, MCP `render_photo`, merge `previewPath`, `lightcraft-cli render`); files are written to a temp file and renamed into place (issue #93; not synced file by file — exports can be made again, issue #134) | | LR-EXP-PREVIOUS | Export with previous settings | P0 | ✅ | `cmd:app.exportPrevious`, `cmd:dialog.export` | last options persist in prefs.json; dialog prefilled; no named export presets yet |