mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 19:27:01 -04:00
Merge PR #1543
# Conflicts: # crates/engine/src/export.rs # docs/parity-checklist.md # docs/whats-new.md
This commit is contained in:
commit
418f6255be
4 files changed
+77
-3
No files matched your search
@@ -108,6 +108,10 @@ impl Default for Resize {
|
||||
}
|
||||
}
|
||||
|
||||
/// The most pixels an export enlarged past its photo's own size can have: 512 megapixels, also
|
||||
/// Lightroom's ceiling. A full-size or smaller export is never held to it.
|
||||
pub const MAX_ENLARGED_PIXELS: f64 = 512e6;
|
||||
|
||||
impl Resize {
|
||||
pub fn long_edge(px: u32) -> Self {
|
||||
Self { value: px as f32, ..Default::default() }
|
||||
@@ -131,6 +135,9 @@ impl Resize {
|
||||
ResizeMode::Percent => v / 100.0,
|
||||
};
|
||||
let k = if self.dont_enlarge { k.min(1.0) } else { k };
|
||||
// an enlargement stops at 512 megapixels (Lightroom's ceiling too): 20000 typed for 2000
|
||||
// with Don't Enlarge off asked for gigapixels, more memory than a computer has
|
||||
let k = k.min((MAX_ENLARGED_PIXELS / (w * h)).sqrt().max(1.0));
|
||||
// at least one pixel, at most the encoders' 65535 limit; the limit wins for a photo more
|
||||
// than 65535 times as long as it is short (where `clamp` would panic: min above max)
|
||||
k.max(1.0 / short).min(65_535.0 / long)
|
||||
@@ -1671,10 +1678,16 @@ fn prepare_guarded(
|
||||
let (full_px, long) = ((p.width as usize).saturating_mul(p.height as usize), p.width.max(p.height) as usize);
|
||||
let (work, weight) = if o.format.is_rendered() {
|
||||
let (w, h) = output_size(p, o);
|
||||
let (nw, nh) = lightcraft_pipeline::native_output_size(p.width.max(1) as usize, p.height.max(1) as usize, &p.develop);
|
||||
let native_px = (nw.round().max(1.0) * nh.round().max(1.0)) as usize;
|
||||
let label = p.file_name.clone();
|
||||
let meta = export_metadata(p, &session.catalog, o);
|
||||
let gain_map = o.hdr_output() && o.format == ExportFormat::Jpeg;
|
||||
let job = session.export_job(id, w, h, o.effective_space(), o.effective_depth())?;
|
||||
let weight = render_weight(full_px, long, job.level, w.saturating_mul(h));
|
||||
if let Some(why) = too_big_for_memory((w, h), native_px, weight, crate::memory::total_ram()) {
|
||||
return Err(format!("{label}: {why}"));
|
||||
}
|
||||
let hdr_job = if gain_map { Some(session.export_job(id, w, h, o.effective_space(), OutputDepth::F32Hdr)?) } else { None };
|
||||
(Work::Render(Box::new(RenderWork { job, hdr_job, meta, opts: o.clone() })), weight)
|
||||
} else {
|
||||
@@ -1698,6 +1711,26 @@ fn prepare_guarded(
|
||||
Ok(PreparedExport { photo: id, file_name, work, guard, weight })
|
||||
}
|
||||
|
||||
/// Why an export of `out` pixels (`w`, `h`), from a photo of `native_px` pixels at its own size,
|
||||
/// can't be made on a computer with `ram` bytes, given its working memory `weight`: an export
|
||||
/// enlarged past the photo's size that needs more memory than the computer has would fail to
|
||||
/// allocate, which ends the process rather than the export. Full-size and smaller exports, and
|
||||
/// computers whose memory isn't known, are never refused.
|
||||
fn too_big_for_memory(out: (usize, usize), native_px: usize, weight: usize, ram: Option<usize>) -> Option<String> {
|
||||
let ram = ram?;
|
||||
if out.0.saturating_mul(out.1) <= native_px || weight <= ram {
|
||||
return None;
|
||||
}
|
||||
let gb = |b: usize| b as f64 / (1u64 << 30) as f64;
|
||||
Some(format!(
|
||||
"exporting at {} × {} needs about {:.0} GB of memory, more than this computer has ({:.0} GB); choose a smaller size",
|
||||
out.0,
|
||||
out.1,
|
||||
gb(weight).ceil(),
|
||||
gb(ram)
|
||||
))
|
||||
}
|
||||
|
||||
/// Working memory of rendering a photo of `full_px` pixels (long edge `long`) from source level
|
||||
/// `level` into `out_px` output pixels: the decoded source (linear RGB f32, 12 B/px) plus the
|
||||
/// pipeline's planes and the encoded output (~36 B per output pixel). Measured: a 24 MP export
|
||||
@@ -2739,10 +2772,44 @@ mod tests {
|
||||
assert_eq!(ExportOptions::from_json(&ExportOptions::resize_json(None)).resize, None);
|
||||
assert_eq!(ExportOptions::from_json(&json!({"ppi": 300})).ppi, 300);
|
||||
// never larger than the encoders' limit, never empty
|
||||
assert_eq!(size(json!({"percent": 10000, "dontEnlarge": false}), 6000.0, 4000.0), Some((65535, 43690)));
|
||||
assert_eq!(size(json!({"percent": 10000, "dontEnlarge": false}), 6000.0, 4000.0), Some((27713, 18475)), "512 MP at most");
|
||||
assert_eq!(size(json!({"width": 70000, "dontEnlarge": false}), 60000.0, 100.0), Some((65535, 109)), "65535 px an edge");
|
||||
assert_eq!(size(json!({"width": 1}), 6000.0, 40.0).map(|s| s.1), Some(1));
|
||||
}
|
||||
|
||||
/// Typing 20000 for 2000 (or percent 10000) with Don't Enlarge off asked for gigapixels: the
|
||||
/// render's first plane alone (34 GB at 65535 × 43690) failed to allocate, which aborts the
|
||||
/// process. An enlargement stops at 512 megapixels; full-size and smaller exports of photos
|
||||
/// larger than that are untouched.
|
||||
#[test]
|
||||
fn enlargements_stop_at_512_megapixels() {
|
||||
let size = |p: serde_json::Value, w: f64, h: f64| ExportOptions::from_json(&p).resize.map(|r| r.apply(w, h)).unwrap();
|
||||
for p in [json!({"longEdge": 60000, "dontEnlarge": false}), json!({"megapixels": 5000}), json!({"percent": 10000, "dontEnlarge": false})] {
|
||||
let (w, h) = size(p.clone(), 6000.0, 4000.0);
|
||||
assert!((w * h) as f64 <= MAX_ENLARGED_PIXELS * 1.001, "{p}: {w}×{h}");
|
||||
assert!((w as f64 / h as f64 - 1.5).abs() < 0.001, "{p}: keeps the shape");
|
||||
}
|
||||
// below the ceiling an enlargement is as asked
|
||||
assert_eq!(size(json!({"longEdge": 12000, "dontEnlarge": false}), 6000.0, 4000.0), (12000, 8000));
|
||||
// a 600 MP panorama exports at full size, and smaller, as before; it is never enlarged
|
||||
assert_eq!(size(json!({"percent": 100, "dontEnlarge": false}), 30000.0, 20000.0), (30000, 20000));
|
||||
assert_eq!(size(json!({"percent": 50}), 30000.0, 20000.0), (15000, 10000));
|
||||
assert_eq!(size(json!({"percent": 200, "dontEnlarge": false}), 30000.0, 20000.0), (30000, 20000));
|
||||
}
|
||||
|
||||
/// An enlarged export that needs more memory than the computer has is refused with a message,
|
||||
/// before it starts; everything else goes ahead.
|
||||
#[test]
|
||||
fn enlarged_exports_must_fit_the_computers_memory() {
|
||||
let gb = 1usize << 30;
|
||||
let why = too_big_for_memory((27713, 18475), 24_000_000, 20 * gb, Some(16 * gb)).unwrap();
|
||||
assert!(why.contains("27713 × 18475") && why.contains("20 GB") && why.contains("16 GB") && why.contains("smaller size"), "{why}");
|
||||
assert_eq!(too_big_for_memory((27713, 18475), 24_000_000, 20 * gb, Some(32 * gb)), None, "fits");
|
||||
assert_eq!(too_big_for_memory((6000, 4000), 24_000_000, 20 * gb, Some(gb)), None, "full size is never refused");
|
||||
assert_eq!(too_big_for_memory((3000, 2000), 24_000_000, 20 * gb, Some(gb)), None, "nor a smaller export");
|
||||
assert_eq!(too_big_for_memory((27713, 18475), 24_000_000, 20 * gb, None), None, "unknown memory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_size_survives_the_render() {
|
||||
// the render fits the photo into the size `apply` gives; that must not cost a pixel (#1312)
|
||||
|
||||
@@ -34,7 +34,7 @@ pub fn default_budget() -> usize {
|
||||
}
|
||||
|
||||
/// Physical memory (bytes), when the platform tells us without native calls.
|
||||
fn total_ram() -> Option<usize> {
|
||||
pub(crate) fn total_ram() -> Option<usize> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let s = std::fs::read_to_string("/proc/meminfo").ok()?;
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; Model folder/error/retry) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (LRC-LIB-CATALOG-IMPORT: catalogs whose tables are all in the WAL open; LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (LR-EXP-DIM: enlargements stop at 512 MP and must fit in memory; LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
|
||||
|
||||
The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we
|
||||
have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good
|
||||
@@ -393,7 +394,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
|
||||
|---|---|---|---|---|---|
|
||||
| LR-EXP-DIALOG | Export dialog | P0 | ✅ | `cmd:dialog.export`, `cmd:app.export` (`preset`), `cmd:export.presets`, `cmd:export.savePreset`, `cmd:export.deletePreset`, `crates/ui-egui/src/panels/dialogs.rs`, `crates/engine/src/export.rs` | batch export; built-in presets (JPEG Small 2048 px / Large full size, Original + Settings, DNG) and saved user presets (library prefs) load into the dialog; deleting a user preset is command-only; on a short window the options scroll and Cancel / OK stay visible (LR-BEHAV-DIALOGS, issue #781) |
|
||||
| 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), Original (+ XMP sidecar). Non-raw → DNG not supported; no JPEG XL export in the web build |
|
||||
| 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 |
|
||||
| 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`) | |
|
||||
| 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) | |
|
||||
@@ -801,6 +802,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
|
||||
| 2026-10-13 | minor | KEYC-SELECTONLYACTIVE: Edit → Select Only Active Photo (⌘⇧D) lands as a new row; status 🟡 (headless-tested, native macOS menu path unverified) |
|
||||
| 2026-10-13 | patch | LRC-LIB-CATALOG-IMPORT: a catalog whose tables are all still in its `-wal` file (main file schema format 0) opens instead of failing as an unsupported schema format; status unchanged |
|
||||
| 2026-10-14 | patch | LR-REM-HEAL, LR-REM-CLONE: zero-feather edges preserve exact-radius pixels; zero-opacity regression covers all spot modes and image corners. Statuses and feature counts unchanged. |
|
||||
| 2026-10-13 | patch | LR-EXP-DIM: an enlarged export stops at 512 megapixels and is refused when it needs more memory than the computer has, instead of aborting the app; status unchanged |
|
||||
| 2026-10-11 | minor | LR-MERGE-HDR: HDR and panorama merges of raws without colour matrices (CR2, NEF, ARW…) write the colour they open with as DNG tags (issue #965); status unchanged |
|
||||
| 2026-10-12 | minor | LR-META-XMP: Read Metadata from File no longer auto-writes the sidecar it has just read, which added `lc:settings` and hid another app's later `crs:` edits (#934); status unchanged (✅) |
|
||||
| 2026-10-12 | patch | LR-IMP-CAMERA-COVERAGE: CR2 white level from the active area, not the saturated masked rows above it (issue #1272); status unchanged (🟡) |
|
||||
|
||||
@@ -105,6 +105,11 @@
|
||||
- A Lightroom Classic catalog whose changes are all still in its `.lrcat-wal` file (a new catalog Lightroom has
|
||||
open and hasn't written back yet) imports instead of failing with "unsupported SQLite schema format".
|
||||
|
||||
### Reliability
|
||||
- An export enlarged far past the photo's size (20000 typed for 2000 with Don't Enlarge off) no longer ends
|
||||
LightCraft: an enlargement stops at 512 megapixels, as in Lightroom, and one that needs more memory than the
|
||||
computer has is refused with the size it would take, before it starts.
|
||||
|
||||
### Slider drags on large raws
|
||||
- Dragging a slider no longer re-renders the edited photo's filmstrip thumbnail on every frame; it follows once you
|
||||
let go. On a 64 MP raw the first of those renders decoded the whole file again, which held up the loupe's
|
||||
|
||||
Reference in new issue
Block a user