mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 19:27:01 -04:00
Merge PR #843
# Conflicts: # crates/engine/src/export.rs # docs/parity-checklist.md
This commit is contained in:
13 files changed
+205
-10
No files matched your search
@@ -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
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "プロファイルメニュー",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Печать",
|
||||
|
||||
@@ -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": "Справжнє ім’я",
|
||||
|
||||
@@ -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?": "有未保存的更改,确定退出吗?",
|
||||
|
||||
@@ -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?": "有未儲存的變更,確定要結束嗎?",
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user