mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
Merge PR #1499: Fix graphic watermark aspect/orientation and HDR JPEG file-s
# Conflicts: # docs/parity-checklist.md
This commit is contained in:
+174
-34
@@ -501,11 +501,12 @@ pub(crate) fn watermark_logo(path: &str) -> Option<std::sync::Arc<Rgba8>> {
|
||||
}
|
||||
|
||||
/// A graphic file (a PNG with transparency, typically) as 8-bit sRGB with its alpha: the
|
||||
/// watermark logo, the loupe's layout image. The error says why it couldn't be read or decoded.
|
||||
/// watermark logo, the loupe's layout image. Applies EXIF orientation before either consumer sizes it.
|
||||
/// The error says why it couldn't be read or decoded.
|
||||
pub fn read_graphic(path: &str) -> Result<Rgba8, String> {
|
||||
let bytes = std::fs::read(path).map_err(|e| format!("can't read {path}: {e}"))?;
|
||||
let decoded = lightcraft_codecs::decode(&bytes, Default::default()).map_err(|e| format!("can't decode {path}: {e}"))?;
|
||||
Ok(decoded.to_srgb8())
|
||||
Ok(decoded.to_srgb8().into_oriented(lightcraft_geom::Orientation::from_exif(decoded.orientation)))
|
||||
}
|
||||
|
||||
/// The graphic watermark: scaled (premultiplied, so transparent edges stay clean) to
|
||||
@@ -518,20 +519,27 @@ fn logo_coverage(width: usize, height: usize, wm: &Watermark, mut blend_px: impl
|
||||
if width == 0 || height == 0 || logo.width == 0 || logo.height == 0 {
|
||||
return;
|
||||
}
|
||||
let lw = ((wm.image_width.clamp(0.01, 1.0) * width as f32).round() as usize).clamp(1, width);
|
||||
let lh = ((lw as f32 * logo.height as f32 / logo.width as f32).round() as usize).clamp(1, height);
|
||||
let turns = wm.quarter_turns();
|
||||
// Reserve the inset on anchored axes before fitting the turned box. Centered axes
|
||||
// ignore insets, just as placement below does. One scale preserves the graphic's shape.
|
||||
let short = width.min(height) as f32;
|
||||
let (ix, iy) = (wm.inset_x.clamp(0.0, 0.4) * short, wm.inset_y.clamp(0.0, 0.4) * short);
|
||||
use Anchor::*;
|
||||
let available_w = width as f64 - if matches!(wm.anchor, Top | Center | Bottom) { 0.0 } else { ix as f64 };
|
||||
let available_h = height as f64 - if matches!(wm.anchor, Left | Center | Right) { 0.0 } else { iy as f64 };
|
||||
let (max_w, max_h) = if turns % 2 == 1 { (available_h, available_w) } else { (available_w, available_h) };
|
||||
let scale =
|
||||
(wm.image_width.clamp(0.01, 1.0) as f64 * width as f64 / logo.width as f64).min(max_w / logo.width as f64).min(max_h / logo.height as f64);
|
||||
let lw = (logo.width as f64 * scale).round().max(1.0) as usize;
|
||||
let lh = (logo.height as f64 * scale).round().max(1.0) as usize;
|
||||
let pre: lightcraft_raster::Image<[f32; 4]> = lightcraft_raster::Image::from_fn(logo.width, logo.height, |x, y| {
|
||||
let p = logo.get(x, y);
|
||||
let a = p[3] as f32 / 255.0;
|
||||
[p[0] as f32 * a, p[1] as f32 * a, p[2] as f32 * a, a]
|
||||
});
|
||||
let scaled = lightcraft_raster::resample::resize(&pre, lw, lh, lightcraft_raster::resample::Filter::Mitchell);
|
||||
let short = width.min(height) as f32;
|
||||
let (ix, iy) = (wm.inset_x.clamp(0.0, 0.4) * short, wm.inset_y.clamp(0.0, 0.4) * short);
|
||||
let turns = wm.quarter_turns();
|
||||
let (bw, bh) = if turns % 2 == 1 { (lh, lw) } else { (lw, lh) };
|
||||
let (w, h, lwf, lhf) = (width as f32, height as f32, bw as f32, bh as f32);
|
||||
use Anchor::*;
|
||||
let ox = match wm.anchor {
|
||||
TopLeft | Left | BottomLeft => ix,
|
||||
Top | Center | Bottom => (w - lwf) / 2.0,
|
||||
@@ -658,8 +666,8 @@ pub struct ExportOptions {
|
||||
/// format's default (TIFF 16, everything else 8). See [`ExportOptions::effective_depth`].
|
||||
pub bit_depth: Option<u8>,
|
||||
/// HDR output for photos edited in HDR: JPEG as an ISO 21496-1 gain map JPEG (the SDR
|
||||
/// rendition plus a gain map, so the file looks right everywhere; quality as set, `limit_kb`
|
||||
/// not applied), AVIF as 10-bit Rec. 2020 PQ, PNG as 16-bit Rec. 2020 PQ (`cICP`; viewers
|
||||
/// rendition plus a gain map, so the file looks right everywhere; `limit_kb` includes both
|
||||
/// images and their metadata), AVIF as 10-bit Rec. 2020 PQ, PNG as 16-bit Rec. 2020 PQ (`cICP`; viewers
|
||||
/// without cICP support show it as sRGB, washed out), JPEG XL as 16-bit Rec. 2020 PQ, 32-bit float
|
||||
/// TIFF with the highlights above SDR white kept. Other formats, and photos without an HDR
|
||||
/// edit, are SDR.
|
||||
@@ -1392,29 +1400,7 @@ pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metada
|
||||
ExportFormat::Jpeg => {
|
||||
// 4:4:4 normally; 4:2:0 when targeting a file size (much smaller at equal visual quality).
|
||||
let sub = if o.limit_kb.is_some() { ChromaSubsampling::S420 } else { ChromaSubsampling::S444 };
|
||||
let jpeg = |q: u8| encode::encode_jpeg(&e, q, sub, &meta);
|
||||
match o.limit_kb {
|
||||
Some(kb) => {
|
||||
let limit = kb as usize * 1024;
|
||||
// Binary search for the highest quality that fits.
|
||||
let (mut lo, mut hi) = (1u8, o.quality);
|
||||
let mut best = None;
|
||||
while lo <= hi {
|
||||
let mid = lo + (hi - lo) / 2;
|
||||
let b = jpeg(mid).map_err(|e| e.to_string())?;
|
||||
if b.len() <= limit {
|
||||
best = Some(b);
|
||||
lo = mid + 1;
|
||||
} else if mid == 1 {
|
||||
break;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
return best.ok_or_else(|| format!("cannot fit within {kb} KB"));
|
||||
}
|
||||
None => jpeg(o.quality),
|
||||
}
|
||||
return encode_jpeg_with_limit(o, |q| encode::encode_jpeg(&e, q, sub, &meta));
|
||||
}
|
||||
ExportFormat::Png => encode::encode_png(&e, &meta),
|
||||
ExportFormat::Tiff => encode::encode_tiff(&e, o.tiff_compression, &meta),
|
||||
@@ -1428,6 +1414,27 @@ pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metada
|
||||
r.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Apply the same file-size policy to ordinary JPEGs and complete HDR gain map JPEGs.
|
||||
fn encode_jpeg_with_limit(o: &ExportOptions, jpeg: impl Fn(u8) -> lightcraft_codecs::Result<Vec<u8>>) -> Result<Vec<u8>, String> {
|
||||
let Some(kb) = o.limit_kb else { return jpeg(o.quality).map_err(|e| e.to_string()) };
|
||||
let limit = u64::from(kb) * 1024;
|
||||
let (mut lo, mut hi) = (1u8, o.quality.min(100));
|
||||
let mut best = None;
|
||||
while lo <= hi {
|
||||
let mid = lo + (hi - lo) / 2;
|
||||
let b = jpeg(mid).map_err(|e| e.to_string())?;
|
||||
if b.len() as u64 <= limit {
|
||||
best = Some(b);
|
||||
lo = mid + 1;
|
||||
} else if mid == 1 {
|
||||
break;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
best.ok_or_else(|| format!("cannot fit within {kb} KB"))
|
||||
}
|
||||
|
||||
/// Output sharpening and the watermark on an 8-bit render in `o.effective_space()`.
|
||||
fn finish_8bit(img: &Rgba8, o: &ExportOptions) -> Rgba8 {
|
||||
let mut img = img.clone();
|
||||
@@ -1475,7 +1482,7 @@ pub fn encode_gain_map_jpeg(sdr: &Rgba8, hdr: &DeepImage, o: &ExportOptions, met
|
||||
let xmp = meta.map(|m| lightcraft_meta::write_xmp(m, None));
|
||||
let em = EncodeMeta { icc: Some(&profile), exif: exif.as_deref(), xmp: xmp.as_deref(), ppi: Some(o.ppi) };
|
||||
let sub = if o.limit_kb.is_some() { ChromaSubsampling::S420 } else { ChromaSubsampling::S444 };
|
||||
gainmap::encode_jpeg(&EncodeImage::rgba8(&base), &map, &gm, o.quality, sub, &em).map_err(|e| e.to_string())
|
||||
encode_jpeg_with_limit(o, |q| gainmap::encode_jpeg(&EncodeImage::rgba8(&base), &map, &gm, q, sub, &em))
|
||||
}
|
||||
|
||||
/// The codecs' name of an output space (for its ICC profile).
|
||||
@@ -2425,6 +2432,139 @@ mod tests {
|
||||
assert!(b.len() <= kb as usize * 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_jpeg_size_limit_respected() {
|
||||
let img = Rgba8::from_fn(256, 192, |x, y| {
|
||||
let n = (x as u32).wrapping_mul(73856093) ^ (y as u32).wrapping_mul(19349663);
|
||||
[n as u8, (n >> 8) as u8, (n >> 16) as u8, 255]
|
||||
});
|
||||
let hdr = DeepImage {
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
space: OutputSpace::Srgb,
|
||||
samples: DeepSamples::F32(
|
||||
img.data.iter().flat_map(|p| p[..3].iter().map(|v| lightcraft_color::transfer::decode_srgb8(*v) * 4.0)).collect(),
|
||||
),
|
||||
};
|
||||
let opts = ExportOptions { hdr: true, quality: 100, limit_kb: Some(u32::MAX), ..Default::default() };
|
||||
let full = encode_gain_map_jpeg(&img, &hdr, &opts, None).unwrap();
|
||||
let kb = (full.len() / 2048) as u32;
|
||||
let limited = encode_gain_map_jpeg(&img, &hdr, &ExportOptions { limit_kb: Some(kb), ..opts.clone() }, None).unwrap();
|
||||
assert!(limited.len() <= kb as usize * 1024, "{} bytes exceeds {kb} KB", limited.len());
|
||||
let decoded = lightcraft_codecs::gainmap::read_jpeg(&limited).unwrap();
|
||||
assert!(decoded.meta.alternate_headroom > 1.0, "the gain map must survive size limiting");
|
||||
let impossible = encode_gain_map_jpeg(&img, &hdr, &ExportOptions { limit_kb: Some(1), ..opts }, None);
|
||||
assert!(impossible.is_err_and(|e| e.contains("cannot fit within 1 KB")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphic_watermark_honours_exif_orientation() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-wm-orientation-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let logo = Rgba8::from_fn(40, 20, |x, _| if x < 20 { [255, 0, 0, 255] } else { [0, 0, 255, 255] });
|
||||
for orientation in [2, 6] {
|
||||
let meta = Metadata { orientation: Some(lightcraft_geom::Orientation::from_exif(orientation)), ..Default::default() };
|
||||
let exif = lightcraft_meta::write_exif(&meta);
|
||||
let bytes = lightcraft_codecs::encode_jpeg(
|
||||
&EncodeImage::rgba8(&logo),
|
||||
100,
|
||||
ChromaSubsampling::S444,
|
||||
&EncodeMeta { exif: Some(&exif), ..Default::default() },
|
||||
)
|
||||
.unwrap();
|
||||
let path = dir.join(format!("oriented-{orientation}.jpg"));
|
||||
std::fs::write(&path, bytes).unwrap();
|
||||
let wm = Watermark {
|
||||
image: path.to_string_lossy().into(),
|
||||
image_width: if orientation == 6 { 0.25 } else { 0.5 },
|
||||
opacity: 1.0,
|
||||
inset_x: 0.0,
|
||||
inset_y: 0.0,
|
||||
..Default::default()
|
||||
};
|
||||
let mut img = Rgba8::from_fn(80, 80, |_, _| [0, 0, 0, 255]);
|
||||
draw_watermark(&mut img, &wm);
|
||||
if orientation == 6 {
|
||||
assert!(img.get(70, 45)[0] > 240 && img.get(70, 75)[2] > 240, "EXIF 6: red above blue");
|
||||
} else {
|
||||
assert!(img.get(45, 70)[2] > 240 && img.get(75, 70)[0] > 240, "EXIF 2: blue left of red");
|
||||
}
|
||||
}
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphic_watermark_keeps_aspect_when_fitted_and_rotated() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-wm-aspect-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("tall.png");
|
||||
let logo = Rgba8::from_fn(40, 160, |_, _| [255, 0, 0, 255]);
|
||||
std::fs::write(&path, lightcraft_codecs::encode_png(&EncodeImage::rgba8(&logo), &EncodeMeta::default()).unwrap()).unwrap();
|
||||
for (rotation, expected) in [(0, (20, 80)), (90, (120, 30)), (180, (20, 80)), (270, (120, 30))] {
|
||||
let wm = Watermark {
|
||||
image: path.to_string_lossy().into(),
|
||||
image_width: 0.5,
|
||||
opacity: 1.0,
|
||||
inset_x: 0.0,
|
||||
inset_y: 0.0,
|
||||
rotation,
|
||||
..Default::default()
|
||||
};
|
||||
let mut img = Rgba8::from_fn(120, 80, |_, _| [0, 0, 0, 255]);
|
||||
draw_watermark(&mut img, &wm);
|
||||
let lit: Vec<_> = img.data.iter().enumerate().filter(|(_, p)| p[0] > 200).map(|(i, _)| (i % 120, i / 120)).collect();
|
||||
let bounds = (
|
||||
lit.iter().map(|p| p.0).max().unwrap() - lit.iter().map(|p| p.0).min().unwrap() + 1,
|
||||
lit.iter().map(|p| p.1).max().unwrap() - lit.iter().map(|p| p.1).min().unwrap() + 1,
|
||||
);
|
||||
assert_eq!(bounds, expected, "{rotation} degrees: fitting must preserve the logo's aspect ratio");
|
||||
}
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphic_watermark_fit_preserves_anchor_insets() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-wm-insets-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("tall.png");
|
||||
let logo = Rgba8::from_fn(40, 160, |_, _| [255, 0, 0, 255]);
|
||||
std::fs::write(&path, lightcraft_codecs::encode_png(&EncodeImage::rgba8(&logo), &EncodeMeta::default()).unwrap()).unwrap();
|
||||
use Anchor::*;
|
||||
for (anchor, rotation, expected) in [
|
||||
(BottomRight, 90, (0, 44, 111, 71)),
|
||||
(TopLeft, 270, (8, 8, 119, 35)),
|
||||
(Right, 90, (0, 26, 111, 53)),
|
||||
(BottomRight, 0, (94, 0, 111, 71)),
|
||||
(TopLeft, 180, (8, 8, 25, 79)),
|
||||
(Bottom, 0, (51, 0, 68, 71)),
|
||||
// Centered axes ignore their inset rather than shrinking the image unnecessarily.
|
||||
(Center, 90, (0, 25, 119, 54)),
|
||||
(Top, 90, (0, 8, 119, 37)),
|
||||
] {
|
||||
let wm = Watermark {
|
||||
image: path.to_string_lossy().into(),
|
||||
image_width: 0.5,
|
||||
opacity: 1.0,
|
||||
inset_x: 0.1,
|
||||
inset_y: 0.1,
|
||||
rotation,
|
||||
anchor,
|
||||
..Default::default()
|
||||
};
|
||||
let mut img = Rgba8::from_fn(120, 80, |_, _| [0, 0, 0, 255]);
|
||||
draw_watermark(&mut img, &wm);
|
||||
let lit: Vec<_> = img.data.iter().enumerate().filter(|(_, p)| p[0] > 200).map(|(i, _)| (i % 120, i / 120)).collect();
|
||||
let bounds = (
|
||||
lit.iter().map(|p| p.0).min().unwrap(),
|
||||
lit.iter().map(|p| p.1).min().unwrap(),
|
||||
lit.iter().map(|p| p.0).max().unwrap(),
|
||||
lit.iter().map(|p| p.1).max().unwrap(),
|
||||
);
|
||||
assert_eq!(bounds, expected, "{anchor:?}, {rotation} degrees");
|
||||
}
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sharpen_increases_edge_contrast() {
|
||||
let mut img = Rgba8::new(8, 8);
|
||||
|
||||
@@ -400,13 +400,15 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
|
||||
| LR-EXP-TYPE | File types | P0 | ✅ | `cmd:app.export` (`format`), `crates/engine/src/export.rs` (`ExportFormat`), `crates/engine/src/tests_export.rs` | JPEG, PNG, TIFF, PSD (flat, 8/16-bit, no layers: `crates/codecs/src/psd.rs`), WebP, AVIF, JPEG XL (issue #247: `jixel`, native only; lossy with quality or lossless, 8/16-bit, effort fast/normal (jixel's slowest search left out: jxl-oxide misdecodes its files), progressive, EXIF/XMP boxes; lossy files in Adobe RGB / ProPhoto are written as Rec. 2020, lossless keep any space via ICC; `crates/codecs/src/jxl_encode.rs`), DNG (raw photos: lossless re-encode with the edits in the embedded XMP; a raw without colour matrices of its own, as a NEF, gets the colour it opens with as `ColorMatrix1` / `ForwardMatrix1` / `AsShotNeutral`, also in Convert / Copy as DNG, so other raw converters open it, issue #1745), Original (+ XMP sidecar). Non-raw → DNG not supported; no JPEG XL export in the web build. Opaque 8-bit PNG/TIFF/WebP exports omit the redundant alpha channel; real transparency is retained (#1670). |
|
||||
| LR-EXP-DIM | Output size | P0 | ✅ | `cmd:app.export` (`longEdge`, `shortEdge`, `width`, `height`, `fit`, `megapixels`, `percent`, `dontEnlarge`, `ppi`), `crates/engine/src/export.rs` (`Resize`), `crates/ui-egui/src/panels/dialogs.rs` | full size = the cropped native size (no longer upscaled); both of Lightroom's boxes: Dimensions (W × H fits either orientation) and Width & Height (`fit: widthHeight`, each edge bound to its own number, #1620); ppi written to JFIF / pHYs / TIFF tags; at most 65,535 px an edge, and an export enlarged past the photo's size stops at 512 megapixels (Lightroom's ceiling too) and is refused, with the size it would need, when it needs more memory than the computer has (it aborted the process before) |
|
||||
| LR-EXP-QUALITY | JPEG quality | P0 | ✅ | `cmd:app.export` (`quality`, `limitKb`), `crates/codecs/src/jpeg_par.rs` | 4:4:4 chroma (4:2:0 when a file-size limit is set); Huffman tables made for each image from its own symbol counts (T.81 Annex K.2) instead of the example tables: the same pixels, 12–14 % smaller files at quality 80–100 on a 24 MP photo-like image, still interleaved baseline (issue #1373) |
|
||||
| LR-EXP-DIM | Output size | P0 | ✅ | `cmd:app.export` (`longEdge`, `shortEdge`, `width`, `height`, `megapixels`, `percent`, `dontEnlarge`, `ppi`), `crates/engine/src/export.rs` (`Resize`), `crates/ui-egui/src/panels/dialogs.rs` | full size = the cropped native size (no longer upscaled); W × H fits either orientation; ppi written to JFIF / pHYs / TIFF tags; at most 65,535 px an edge, and an export enlarged past the photo's size stops at 512 megapixels (Lightroom's ceiling too) and is refused, with the size it would need, when it needs more memory than the computer has (it aborted the process before) |
|
||||
| LR-EXP-QUALITY | JPEG quality | P0 | ✅ | `cmd:app.export` (`quality`, `limitKb`), `crates/codecs/src/jpeg_par.rs` | 4:4:4 chroma (4:2:0 when a file-size limit is set); Huffman tables made for each image from its own symbol counts (T.81 Annex K.2) instead of the example tables: the same pixels, 12–14 % smaller files at quality 80–100 on a 24 MP photo-like image, still interleaved baseline (issue #1373); ordinary and HDR gain map JPEGs search quality up to the requested maximum to fit `limitKb`, including gain map and metadata, or return an error. Tested by `size_limit_respected` and `hdr_jpeg_size_limit_respected`. |
|
||||
| LR-EXP-BITDEPTH | Bit depth | P1 | ✅ | `cmd:app.export` (`bitDepth`), `crates/pipeline/src/output.rs` (`OutputDepth`), `crates/engine/src/export.rs` | 16-bit PNG/TIFF/JPEG XL rendered at 16 bits (TIFF defaults to 16), 32-bit float linear TIFF with a linear profile, 10-bit AVIF; high-bit-depth renders run on the CPU |
|
||||
| LR-EXP-COMPRESSION | TIFF compression | P1 | ✅ | `cmd:app.export` (`tiffCompression`: none / lzw / zip) | |
|
||||
| LR-EXP-COLORSPACE | Output colour space | P0 | ✅ | `cmd:app.export` (`colorSpace`), `crates/pipeline/src/output.rs`, `crates/engine/src/export.rs` | sRGB, Display P3, Adobe RGB (1998) compatible, ProPhoto RGB, Rec. 2020: rendered from the working space with gamut mapping into the target gamut (CPU + GPU), own ICC profile embedded; AVIF stays sRGB (muxer has no ICC); lossy JPEG XL declares sRGB / P3 / Rec. 2020 (Adobe RGB and ProPhoto written as Rec. 2020), lossless JPEG XL embeds the profile |
|
||||
| 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 or HLG AVIF, PQ PNG, PQ JPEG XL, 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). 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-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 (EXIF orientation applied before sizing; oversized graphics fit the rotated image bounds without stretching, tested by `graphic_watermark_honours_exif_orientation` and `graphic_watermark_keeps_aspect_when_fitted_and_rotated`; `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`, `destinationMode`: specificFolder / sameAsOriginal, `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; Export to: Specific folder or Same folder as original photo (each photo into its original's folder + the subfolder; a demo/browser photo or a missing folder is that photo's error, issue #956); 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 |
|
||||
@@ -948,3 +950,11 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
|
||||
| 2026-10-17 | minor | MENU-VIEW-DETAIL, MENU-VIEW-EDIT, MENU-VIEW-CLIPPING, MENU-VIEW-MASKOVERLAY, MENU-VIEW-PHOTOCOUNT, MENU-VIEW-BEFOREAFTER, LR-VIEW-FOCUSPOINT: menu labels renamed: switches are named for what they show (Photo Counts, Focus Point, Clipping, Mask Overlay, All Mask Overlays, Mask Pins, Layout Image), View ▸ Detail is Detail View, Window ▸ Edit is Edit Panel, Compare Before and After is Before/After Side by Side, Clear Preview Cache is Discard Preview Cache; shortcuts and commands unchanged; statuses unchanged |
|
||||
| 2026-10-15 | patch | LR-BEHAV-LOCALIZE: cites `docs/localization-pl.md` (Polish format messages finished, counts right for every number); status unchanged (🟡) |
|
||||
| 2026-10-17 | minor | LR-IMP-DRAGDROP: files dragged over the window show what dropping will do, and dropped photos and folders open the import review (Settings ▸ Import can import them at once); a drop while a dialog is open or an import is running is refused; a finished scan no longer replaces an open dialog; the web build tells the person when a dropped file can't be read or imported. LR-BEHAV-EMPTY-STATES, LR-BEHAV-DRAGDROP: text updated; status unchanged (✅) |
|
||||
|
||||
### 2026-10-18 — export regression integration (maintenance)
|
||||
|
||||
Graphic watermarks honour EXIF rotation/mirroring through the shared `read_graphic` path and keep their aspect ratio when fitted to the output. HDR JPEG `limitKb` includes the gain map and metadata. Refreshed onto main fb7ac08b, retaining upstream export bounds, colour metadata, JPEG encoding and opaque RGB/real-alpha handling (#1691); no feature-count or readiness estimate changes.
|
||||
|
||||
### 2026-10-20 — graphic watermark inset regression
|
||||
|
||||
Oversized graphics fit the space remaining after the applicable anchor insets, with one aspect-preserving scale before quarter-turn rotation. Centered axes continue to ignore insets. Pixel-bound regressions cover edge/corner anchors, centered placement and all four rotations. Main d999e382 is integrated; HDR size-limit and EXIF-orientation fixes are unchanged.
|
||||
|
||||
Reference in New Issue
Block a user