diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 6a4edbebc..ad1d7e560 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -329,8 +329,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; @@ -343,14 +355,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); @@ -362,12 +374,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}; @@ -387,7 +401,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; @@ -494,7 +508,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())) { @@ -2140,6 +2154,27 @@ mod tests { assert_eq!(ExportOptions::from_json(&json!({"watermark": {"text": "x"}})).watermark.map(|w| w.rotation), Some(0)); } + /// 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}})); @@ -2163,7 +2198,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); } } @@ -2201,7 +2236,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)); } @@ -2227,7 +2262,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 7d71ac67a..79cedb17d 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -798,6 +798,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", @@ -1078,6 +1079,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 e8735148a..04922c364 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -759,6 +759,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", @@ -1031,6 +1032,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 13427a06f..5d603051e 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -756,6 +756,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", @@ -1026,6 +1027,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 65e36dede..1557214ad 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -565,6 +565,7 @@ "List History": "履歴を一覧表示", "List Presets": "プリセットを一覧表示", "List Profiles": "プロファイルを一覧表示", + "Loading preview…": "プレビューを読み込み中…", "Locate Photo": "写真の場所を指定", "Lock": "ロック", "Lock Crop Aspect": "切り抜きの縦横比を固定", @@ -646,6 +647,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 80e5e75e7..a0102d383 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -767,6 +767,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", @@ -1042,6 +1043,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 4e529e00d..90ee86b68 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -632,6 +632,7 @@ "List Presets": "Список пресетов", "List Profiles": "Список профилей", "List this folder in Local from now on": "Показывать эту папку в «Локальном» и дальше", + "Loading preview…": "Загрузка просмотра…", "Local": "Локальное", "Local folders": "Локальные папки", "Local library — no cloud account needed": "Локальная библиотека — учётная запись в облаке не нужна", @@ -862,6 +863,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 df0323074..be88c0745 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -110,6 +110,7 @@ "Lines +/−": "Рядки +/−", "Lines added": "Додані рядки", "Lines deleted": "Видалені рядки", + "Loading preview…": "Завантаження перегляду…", "Lock": "Заблокувати", "Merged PRs": "Об’єднані PR", "Model": "Модель", @@ -121,6 +122,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 09d20db93..fe81aeaa1 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -552,6 +552,7 @@ "Lines +/−": "行数 +/−", "Lines added": "新增行数", "Lines deleted": "删除行数", + "Loading preview…": "正在加载预览…", "Lock": "锁定", "Lock Crop Aspect": "锁定裁剪长宽比", "Locked": "已锁定", @@ -600,6 +601,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 0b4a54844..f4045da3f 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -552,6 +552,7 @@ "Lines +/−": "行數 +/−", "Lines added": "新增行數", "Lines deleted": "刪除行數", + "Loading preview…": "正在載入預覽…", "Lock": "鎖定", "Lock Crop Aspect": "鎖定裁切長寬比", "Locked": "已鎖定", @@ -600,6 +601,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 8d6923f1d..3bdae5a93 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 6e9bdadf1..fa95daad7 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -54,6 +54,117 @@ pub(crate) fn rename_preview( (read(&rows), total) } +/// The Export dialog's watermark preview: the first exported photo, as wide as the dialog whatever +/// its shape, with the watermark drawn on it as the file will have it at size `resize` (`None` = +/// full size). On a worker thread, once per photo, catalog change and size step, the cached +/// thumbnail comes first (at once, a little soft) and then a render at the preview's own pixel size +/// replaces it. The watermark is redrawn on a copy only when it, the output size or the picture +/// changes, which is quick at this 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}; + // (pictures received so far: 1 the thumbnail, 2 the sharp render; the latest, or why none came) + type Base = std::sync::Arc>)>>; + type Shown = (u64, u8, 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); + // pixels across the dialog, in steps so a scroll bar or a point of layout doesn't re-render + let step = 128.0; + let px = ((ui.available_width() * ui.ctx().pixels_per_point() / step).ceil() * step).clamp(step, 2048.0) as usize; + let mut h = std::collections::hash_map::DefaultHasher::new(); + (id, app.session.catalog.revision, px).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(); + // the thumbnail, then as wide as the dialog (a tall photo up to three times that high) + let jobs: Vec<_> = [app.session.thumb_job(id, 512), app.session.render_job(id, px, px * 3, false, true)].into_iter().flatten().collect(); + // the web build has no worker thread: a full render there would hold up the page + #[cfg(target_arch = "wasm32")] + let jobs: Vec<_> = jobs.into_iter().take(1).collect(); + if jobs.is_empty() { + *base.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = (1, Some(Err("no such photo".into()))); + } else { + let (out, repaint) = (base.clone(), ctx.clone()); + let work = move || { + for job in jobs { + 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())); + let mut got = out.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + // a failed sharp render keeps the thumbnail + if r.is_ok() || !matches!(got.1, Some(Ok(_))) { + *got = (got.0.saturating_add(1), 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(); + } + ctx.data_mut(|d| d.insert_temp(base_id, (key, base.clone()))); + base + } + }; + let shown = ctx.data(|d| d.get_temp::(shown_id)); + let stage = base.lock().unwrap_or_else(std::sync::PoisonError::into_inner).0; + let tex = match shown { + Some((k, g, w, s, tex)) if k == key && g == stage && &w == wm && s == export_short => tex, + _ => { + let photo_px = match &base.lock().unwrap_or_else(std::sync::PoisonError::into_inner).1 { + 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, stage, wm.clone(), export_short, tex.clone()))); + tex + } + }; + let size = tex.size_vec2(); + let scale = ui.available_width() / size.x.max(1.0); + 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")]; @@ -663,6 +774,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); @@ -727,6 +842,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 8a2e2ef72..cf1132e84 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, insetX, insetY, inset, rotation, 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` (nine positions), `insetX` / `insetY` (margins from the side and from the top/bottom edges, each a fraction of the short edge, 0..0.4; the Export dialog's Horizontal and Vertical inset sliders, as in Lightroom Classic; the older single `inset` still reads as both; tested by `watermark_insets_are_horizontal_and_vertical`), `rotation` (0, 90, 180 or 270 degrees clockwise, the Export dialog's Rotate row, as Lightroom Classic's rotate buttons; sized before it is turned, placed and inset by the turned box; tested by `watermark_rotation_turns_the_text_in_its_corner` and `graphic_watermark_is_placed_scaled_and_blended`), `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, insetX, insetY, inset, rotation, 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` (nine positions), `insetX` / `insetY` (margins from the side and from the top/bottom edges, each a fraction of the short edge, 0..0.4; the Export dialog's Horizontal and Vertical inset sliders, as in Lightroom Classic; the older single `inset` still reads as both; tested by `watermark_insets_are_horizontal_and_vertical`), `rotation` (0, 90, 180 or 270 degrees clockwise, the Export dialog's Rotate row, as Lightroom Classic's rotate buttons; sized before it is turned, placed and inset by the turned box; tested by `watermark_rotation_turns_the_text_in_its_corner` and `graphic_watermark_is_placed_scaled_and_blended`), `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 |