# Conflicts:
#	crates/engine/src/export.rs
#	docs/parity-checklist.md
This commit is contained in:
Claude committed 2026-10-11 11:46:15 +00:00
commit c97b2790d3
13 files changed
+205 -10

No files matched your search

+44 -9
View File
@@ -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
};
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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": "プロファイルメニュー",
+2
View File
@@ -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",
+2
View File
@@ -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": "Печать",
+2
View File
@@ -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": "Справжнє ім’я",
+2
View File
@@ -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?": "有未保存的更改,确定退出吗?",
+2
View File
@@ -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?": "有未儲存的變更,確定要結束嗎?",
+23
View File
@@ -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]);
+119
View File
@@ -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<lightcraft_engine::export::Resize>,
) {
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<std::sync::Mutex<(u8, Option<Result<lightcraft_raster::Rgba8, String>>)>>;
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>(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::<bool>(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 {
+1 -1
View File
@@ -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 |