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 |