From 02d5d6bc0342954ba5bfa02c5c20e28a52ed1224 Mon Sep 17 00:00:00 2001 From: Michael Anderson Date: Tue, 13 Oct 2026 07:15:34 -0500 Subject: [PATCH] LR-MERGE-PANO: Keep panoramas at full resolution when memory allows (#1455) Drop the 40 MP default and free each photo's pixels once it's projected. --- crates/engine/src/cmd/merge.rs | 2 +- crates/engine/src/merge.rs | 66 +++++++++++++++++++-- crates/engine/src/tests_merge.rs | 27 +++++++++ crates/merge/src/pano/mod.rs | 27 +++++++-- crates/merge/tests/pano.rs | 23 +++++++ crates/ui-egui/locales/de-formats.json | 4 +- crates/ui-egui/locales/es-formats.json | 4 +- crates/ui-egui/locales/fr-formats.json | 4 +- crates/ui-egui/locales/ja-formats.json | 4 +- crates/ui-egui/locales/pt-br-formats.json | 4 +- crates/ui-egui/locales/ru-formats.json | 4 +- crates/ui-egui/locales/uk-formats.json | 4 +- crates/ui-egui/locales/zh-hans-formats.json | 4 +- crates/ui-egui/locales/zh-hant-formats.json | 4 +- crates/ui-egui/src/merge.rs | 46 +++++++++++++- docs/merge.md | 13 +++- docs/parity-checklist.md | 5 +- docs/whats-new.md | 7 +++ 18 files changed, 225 insertions(+), 27 deletions(-) diff --git a/crates/engine/src/cmd/merge.rs b/crates/engine/src/cmd/merge.rs index a8869153a..eb649c401 100644 --- a/crates/engine/src/cmd/merge.rs +++ b/crates/engine/src/cmd/merge.rs @@ -56,7 +56,7 @@ pub fn specs() -> Vec { "Panorama Merge", [], None, - "{ids?, projection=auto|spherical|cylindrical|perspective, boundaryWarp=0..100, autoCrop=false, fillEdges=false, autoSettings=true, stack=false, maxMegapixels=40, preview=false, previewPath?}", + "{ids?, projection=auto|spherical|cylindrical|perspective, boundaryWarp=0..100, autoCrop=false, fillEdges=false, autoSettings=true, stack=false, maxMegapixels=full size (≤ 512, held to memory), preview=false, previewPath?}", can_merge, |s, p| run(s, "merge.panorama", p) ), diff --git a/crates/engine/src/merge.rs b/crates/engine/src/merge.rs index ef9fd00f9..ac525f631 100644 --- a/crates/engine/src/merge.rs +++ b/crates/engine/src/merge.rs @@ -12,6 +12,7 @@ use std::sync::Arc; use lightcraft_catalog::{PhotoId, Source}; use lightcraft_develop::DevelopSettings; +use lightcraft_merge::pano::MAX_MEGAPIXELS; use lightcraft_merge::{Deghost, Frame, HdrOptions, PanoOptions, Projection}; use lightcraft_pipeline::{Quality, RenderRequest, SourceInfo}; use lightcraft_raster::{Rgb32f, Rgba8}; @@ -149,8 +150,9 @@ impl MergeJob { MergeKind::Panorama { pano } => { let frames = self.load(true, progress, (0.0, 0.25))?; let p = sub(0.25, 0.9); - let r = lightcraft_merge::stitch(frames, pano, &p).map_err(err)?; - let info = pano_info(&r, &self.sources); + let fit = fit_in_memory(pano, pixels(&frames), crate::memory::total_ram()); + let r = lightcraft_merge::stitch(frames, &fit, &p).map_err(err)?; + let info = pano_info(&r, &self.sources, pano, &fit); ( r.image, r.color, @@ -192,8 +194,9 @@ impl MergeJob { merged.push(f); } let p = sub(0.6, 0.9); - let r = lightcraft_merge::stitch(merged, pano, &p).map_err(err)?; - let mut info = pano_info(&r, &self.sources); + let fit = fit_in_memory(pano, pixels(&merged), crate::memory::total_ram()); + let r = lightcraft_merge::stitch(merged, &fit, &p).map_err(err)?; + let mut info = pano_info(&r, &self.sources, pano, &fit); info["bracket"] = json!(b); ( r.image, @@ -230,7 +233,20 @@ impl MergeJob { } } -fn pano_info(r: &lightcraft_merge::PanoResult, sources: &[(PhotoId, String)]) -> Value { +/// `asked` is what the command asked for, `fit` what ran (held to memory): when the panorama came +/// out smaller than the photos' own resolution, `limit` says why (`memory`, `size`: Lightroom's +/// 512 MP / 65,000 px, or `asked`). +fn pano_info(r: &lightcraft_merge::PanoResult, sources: &[(PhotoId, String)], asked: &PanoOptions, fit: &PanoOptions) -> Value { + let megapixels = (r.image.width * r.image.height) as f64 / 1e6; + let native = r.native_megapixels; + let why = if fit.max_megapixels < asked.max_megapixels && fit.max_megapixels < native { + "memory" + } else if asked.max_megapixels < MAX_MEGAPIXELS && asked.max_megapixels < native { + "asked" + } else { + "size" + }; + let limit = (megapixels < native * 0.98).then_some(why); json!({ "projection": r.projection, "used": r.used.iter().filter_map(|&i| sources.get(i).map(|s| s.0.0)).collect::>(), @@ -238,9 +254,46 @@ fn pano_info(r: &lightcraft_merge::PanoResult, sources: &[(PhotoId, String)]) -> "rmsPx": r.rms, "gains": r.gains, "crop": r.crop, + "megapixels": megapixels, + "nativeMegapixels": native, + "limit": limit, }) } +/// Peak memory of a panorama per pixel of its output (bytes), measured as the peak resident size +/// of synthetic stitches of 20–81 MP from 38–154 MP of photos, rounded up from the smaller ones +/// (the larger need less): while the photos are projected they are still held, beside the tiles +/// ([`PANO_TILE_BYTES`] on top of the photos' own 12 bytes a pixel); the blend that follows, the +/// photos freed, peaks at [`PANO_BLEND_BYTES`]. Writing the DNG takes less than either. +const PANO_TILE_BYTES: f64 = 36.0; +const PANO_BLEND_BYTES: f64 = 68.0; + +/// What a merge may use of the computer's memory: three quarters of it (the rest is the system's +/// and the other apps'), less the app's own memory budget (its caches). +fn merge_memory(ram: Option) -> f64 { + // a computer whose memory we can't read (Windows) is taken to have 8 GB + let ram = ram.unwrap_or(8 << 30) as f64; + (ram * 0.75 - crate::memory::budget() as f64).max(0.0) +} + +/// `pano` with its size held to what fits in memory beside its photos (`frame_px` pixels, 12 +/// bytes each, loaded already): a panorama of five 51 MP photos may have up to 166 MP on a 16 GB +/// computer, 355 MP on 32 GB. Never below 40 megapixels, the size every panorama had before it +/// followed the photos' resolution, and never above what was asked. +pub(crate) fn fit_in_memory(pano: &PanoOptions, frame_px: f64, ram: Option) -> PanoOptions { + let room = merge_memory(ram); + let fits = ((room - frame_px * 12.0) / PANO_TILE_BYTES).min(room / PANO_BLEND_BYTES) / 1e6; + PanoOptions { max_megapixels: pano.max_megapixels.min(fits.max(MIN_FITTED_MEGAPIXELS)), ..pano.clone() } +} + +/// The pixels of `frames`. +fn pixels(frames: &[Frame]) -> f64 { + frames.iter().map(|f| (f.width() * f.height()) as f64).sum() +} + +/// The smallest size [`fit_in_memory`] holds a panorama to (megapixels). +pub(crate) const MIN_FITTED_MEGAPIXELS: f64 = 40.0; + /// Bracket size from EXIF: the smallest period of the exposure sequence (≥ 2). pub fn detect_bracket(frames: &[Frame]) -> Option { let ev: Vec = frames.iter().map(|f| f.exposure).collect::>()?; @@ -331,7 +384,8 @@ pub fn parse(id: &str, p: &Value) -> Result<(MergeKind, MergeFinish)> { boundary_warp: p.get("boundaryWarp").and_then(Value::as_f64).unwrap_or(0.0).clamp(0.0, 100.0), auto_crop: p.get("autoCrop").and_then(Value::as_bool).unwrap_or(false), fill_edges: p.get("fillEdges").and_then(Value::as_bool).unwrap_or(false), - max_megapixels: p.get("maxMegapixels").and_then(Value::as_f64).unwrap_or(40.0), + // the photos' own resolution unless asked (held to what fits in memory when it runs) + max_megapixels: p.get("maxMegapixels").and_then(Value::as_f64).filter(|v| *v > 0.0).unwrap_or(MAX_MEGAPIXELS).min(MAX_MEGAPIXELS), }; let kind = match id { "merge.hdr" => MergeKind::Hdr { hdr }, diff --git a/crates/engine/src/tests_merge.rs b/crates/engine/src/tests_merge.rs index cb42dbc2a..8cb85120f 100644 --- a/crates/engine/src/tests_merge.rs +++ b/crates/engine/src/tests_merge.rs @@ -164,7 +164,34 @@ fn panorama_command_with_auto_crop() { assert!(p.width > 800 && p.height > 300, "{}×{}", p.width, p.height); let crop = s.develop_of(id).unwrap().crop.geometry.rect; assert!(crop.width() < 1.0 && crop.width() * crop.height() > 0.5, "{crop:?}"); + // the photos' own resolution, so nothing held it back (issue #1455) + let (mp, native) = (r["megapixels"].as_f64().unwrap(), r["nativeMegapixels"].as_f64().unwrap()); + assert!((mp / native - 1.0).abs() < 0.02 && r["limit"].is_null(), "{r}"); + // a size asked for says so + let small = + s.execute("merge.panorama", &json!({"ids": ids, "projection": "cylindrical", "autoSettings": false, "maxMegapixels": native / 2.0})).unwrap(); + assert_eq!(small["limit"], "asked", "{small}"); + assert!(small["megapixels"].as_f64().unwrap() <= native / 2.0 * 1.02, "{small}"); // too few photos assert!(s.execute("merge.panorama", &json!({"ids": [ids[0]]})).is_err()); let _ = std::fs::remove_dir_all(&dir); } + +/// Issue #1455: a panorama keeps its photos' resolution, held to what fits in memory beside them +/// (never below the 40 MP every panorama used to have), and never above a size asked for. +#[test] +fn panorama_size_is_held_to_memory() { + use crate::merge::{MIN_FITTED_MEGAPIXELS, fit_in_memory}; + let full = lightcraft_merge::PanoOptions::default(); + let gb = |g: usize| Some(g << 30); + let photos = 5.0 * 51e6; // five 51 MP photos + let mp = |o: lightcraft_merge::PanoOptions| o.max_megapixels; + assert_eq!(mp(fit_in_memory(&full, photos, gb(512))), lightcraft_merge::pano::MAX_MEGAPIXELS, "plenty of memory: the full size"); + let m16 = mp(fit_in_memory(&full, photos, gb(16))); + assert!(m16 > MIN_FITTED_MEGAPIXELS && m16 < 512.0, "16 GB: {m16} MP"); + assert!(mp(fit_in_memory(&full, photos, gb(32))) > m16, "more memory, more pixels"); + assert_eq!(mp(fit_in_memory(&full, photos, gb(4))), MIN_FITTED_MEGAPIXELS, "never below 40 MP"); + let asked = lightcraft_merge::PanoOptions { max_megapixels: 20.0, ..full.clone() }; + assert_eq!(mp(fit_in_memory(&asked, photos, gb(16))), 20.0, "a size asked for is kept when it fits"); + assert_eq!(fit_in_memory(&full, 1e6, None), fit_in_memory(&full, 1e6, gb(8)), "memory unknown: taken as 8 GB"); +} diff --git a/crates/merge/src/pano/mod.rs b/crates/merge/src/pano/mod.rs index 309505614..73d19f515 100644 --- a/crates/merge/src/pano/mod.rs +++ b/crates/merge/src/pano/mod.rs @@ -39,16 +39,23 @@ pub struct PanoOptions { pub boundary_warp: f64, pub auto_crop: bool, pub fill_edges: bool, - /// Output size limit (megapixels); the panorama is scaled down to fit. + /// Output size limit (megapixels); the panorama is scaled down to fit. At most + /// [`MAX_MEGAPIXELS`] (the default: the photos' own resolution). pub max_megapixels: f64, } impl Default for PanoOptions { fn default() -> Self { - PanoOptions { projection: Projection::Auto, boundary_warp: 0.0, auto_crop: false, fill_edges: false, max_megapixels: 40.0 } + PanoOptions { projection: Projection::Auto, boundary_warp: 0.0, auto_crop: false, fill_edges: false, max_megapixels: MAX_MEGAPIXELS } } } +/// The largest panorama, in megapixels (Lightroom's limit too); a bigger one is scaled down. +pub const MAX_MEGAPIXELS: f64 = 512.0; + +/// The longest edge of a panorama, in pixels (Lightroom's limit too); a longer one is scaled down. +pub const MAX_EDGE: f64 = 65_000.0; + /// The stitched panorama. #[derive(Clone, Debug)] pub struct PanoResult { @@ -71,6 +78,8 @@ pub struct PanoResult { pub rms: f64, /// Horizontal and vertical field of view (degrees). pub fov: (f64, f64), + /// The panorama's size at the photos' own resolution (megapixels), before any limit. + pub native_megapixels: f64, /// Output focal length and the surface coordinates of the canvas's top-left corner: canvas /// pixel (x, y) (before boundary warp) shows surface point (x + 0.5 + origin.0, y + 0.5 + origin.1). pub focal: f64, @@ -210,7 +219,7 @@ pub fn register(frames: &[Frame], progress: &Progress) -> Result<(Vec, opts: &PanoOptions, progress: &Progress) -> Result { +pub fn stitch(mut frames: Vec, opts: &PanoOptions, progress: &Progress) -> Result { let n = frames.len(); if n < 2 { return Err(MergeError::TooFew(n, 2)); @@ -231,9 +240,9 @@ pub fn stitch(frames: Vec, opts: &PanoOptions, progress: &Progress) -> Re if projection == Projection::Perspective && (bx1 - bx0) * (by1 - by0) > 12.0 * area { return Err(MergeError::NoOverlap(format!("the field of view ({:.0}°) is too wide for a perspective projection", fov.0))); } - // output scale: native resolution, limited to max_megapixels - let max_px = (opts.max_megapixels.max(0.1) * 1e6).min(400e6); - let s = (max_px / ((bx1 - bx0) * (by1 - by0))).sqrt().min(1.0); + // output scale: native resolution, limited to max_megapixels and the longest edge + let max_px = if opts.max_megapixels.is_nan() { MAX_MEGAPIXELS } else { opts.max_megapixels.clamp(0.1, MAX_MEGAPIXELS) } * 1e6; + let s = (max_px / ((bx1 - bx0) * (by1 - by0))).sqrt().min(MAX_EDGE / (bx1 - bx0).max(by1 - by0)).min(1.0); let f_out = fmed * s; let (ox, oy) = (bx0 * s, by0 * s); let (cw, ch) = (((bx1 - bx0) * s).ceil() as usize + 1, ((by1 - by0) * s).ceil() as usize + 1); @@ -298,6 +307,11 @@ pub fn stitch(frames: Vec, opts: &PanoOptions, progress: &Progress) -> Re .collect(); check(progress, 0.6, "Exposure compensation")?; compensate_gains(&tiles, &mut gains, &frames); + // the tiles hold every pixel the blend needs: the frames' own go now, before the blend + // takes its memory (12 bytes a pixel of every photo: 3 GB for five 51 MP frames) + for f in &mut frames { + f.image = Rgb32f::new(0, 0); + } // seams: label = max feather weight let mut best = vec![0f32; pw * ph]; @@ -373,6 +387,7 @@ pub fn stitch(frames: Vec, opts: &PanoOptions, progress: &Progress) -> Re rms_before, rms, fov, + native_megapixels: (bx1 - bx0) * (by1 - by0) / 1e6, focal: f_out, origin: (ox, oy), color: r.color.clone(), diff --git a/crates/merge/tests/pano.rs b/crates/merge/tests/pano.rs index 402037ca0..fdb736524 100644 --- a/crates/merge/tests/pano.rs +++ b/crates/merge/tests/pano.rs @@ -245,3 +245,26 @@ fn unrelated_photos_do_not_stitch() { let frames: Vec = a.iter().take(2).map(|s| Frame::from_linear_rec2020(s.render(400, 300), None)).collect(); assert!(stitch(frames, &PanoOptions::default(), &no_progress).is_err()); } + +/// Issue #1455: a panorama came out at 40 MP whatever its photos' resolution. It now keeps their +/// own resolution unless a size is asked for (up to Lightroom's 512 MP); a size scales it down, +/// and a NaN one (from an agent) is no size at all, not a 0.1 MP thumbnail. +#[test] +fn panoramas_keep_the_photos_resolution_unless_asked() { + assert_eq!(PanoOptions::default().max_megapixels, lightcraft_merge::pano::MAX_MEGAPIXELS); + let shots = [ + Shot { yaw: -22.0, pitch: 0.5, roll: 0.3, gain: 1.0 }, + Shot { yaw: 0.0, pitch: -0.4, roll: 0.0, gain: 1.0 }, + Shot { yaw: 21.0, pitch: 0.8, roll: -0.4, gain: 1.0 }, + ]; + let opts = |max_megapixels: f64| PanoOptions { projection: Projection::Perspective, max_megapixels, ..Default::default() }; + let mp = |r: &lightcraft_merge::pano::PanoResult| (r.image.width * r.image.height) as f64 / 1e6; + let full = run(&shots, 800.0, &PanoOptions { projection: Projection::Perspective, ..Default::default() }).0; + assert!((mp(&full) / full.native_megapixels - 1.0).abs() < 0.02, "{} MP for {} MP", mp(&full), full.native_megapixels); + let quarter = run(&shots, 800.0, &opts(full.native_megapixels / 4.0)).0; + assert!((quarter.native_megapixels / full.native_megapixels - 1.0).abs() < 1e-6); + let k = mp(&quarter) / (full.native_megapixels / 4.0); + assert!(k > 0.9 && k <= 1.02, "a quarter of the size asked: {} MP", mp(&quarter)); + let nan = run(&shots, 800.0, &opts(f64::NAN)).0; + assert_eq!((nan.image.width, nan.image.height), (full.image.width, full.image.height)); +} diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index dff2ddb75..4424af1b2 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -247,5 +247,7 @@ "{n} photos opened for editing; each is stacked with its original": "{n} Fotos zur Bearbeitung geöffnet; jedes ist mit seinem Original gestapelt", "{name} opened in the other editor": "{name} im anderen Editor geöffnet", "{n} originals opened in the other editor": "{n} Originale im anderen Editor geöffnet", - "· {in_source} identical copies": "· {in_source} identische Kopien" + "· {in_source} identical copies": "· {in_source} identische Kopien", + "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what}-Zusammenfügung mit {mp} hinzugefügt: die volle Größe, {full}, braucht mehr Arbeitsspeicher, als dieser Computer hat", + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what}-Zusammenfügung mit {mp} hinzugefügt, der größten möglichen Panoramagröße (volle Größe: {full})" } diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index 452fc6621..403762642 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -247,5 +247,7 @@ "{n} photos opened for editing; each is stacked with its original": "{n} fotos abiertas para editar; cada una está apilada con su original", "{name} opened in the other editor": "{name} abierto en el otro editor", "{n} originals opened in the other editor": "{n} originales abiertos en el otro editor", - "· {in_source} identical copies": "· {in_source} copias idénticas" + "· {in_source} identical copies": "· {in_source} copias idénticas", + "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "Combinación {what} añadida a {mp}: el tamaño completo, {full}, necesita más memoria de la que tiene este ordenador", + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "Combinación {what} añadida a {mp}, el tamaño máximo de una panorámica (tamaño completo: {full})" } diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json index d85e837a3..2fd99dc91 100644 --- a/crates/ui-egui/locales/fr-formats.json +++ b/crates/ui-egui/locales/fr-formats.json @@ -247,5 +247,7 @@ "{n} photos opened for editing; each is stacked with its original": "{n} photos ouvertes pour modification ; chacune est empilée avec son original", "{name} opened in the other editor": "{name} ouvert dans l’autre éditeur", "{n} originals opened in the other editor": "{n} originaux ouverts dans l’autre éditeur", - "· {in_source} identical copies": "· {in_source} copies identiques" + "· {in_source} identical copies": "· {in_source} copies identiques", + "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "Fusion de {what} ajoutée en {mp} : la taille complète, {full}, demande plus de mémoire que n’en a cet ordinateur", + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "Fusion de {what} ajoutée en {mp}, la plus grande taille possible pour un panorama (taille complète : {full})" } diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index 211e47e29..4d3504f22 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -247,5 +247,7 @@ "{n} photos opened for editing; each is stacked with its original": "{n}枚の写真を編集用に開きました。それぞれ元の写真とスタックされています", "{name} opened in the other editor": "{name}を別のエディタで開きました", "{n} originals opened in the other editor": "{n}個の元ファイルを別のエディタで開きました", - "· {in_source} identical copies": "・同一のコピー {in_source} 件" + "· {in_source} identical copies": "・同一のコピー {in_source} 件", + "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what}合成を{mp}で追加しました:フルサイズ({full})にはこのコンピューターのメモリが足りません", + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what}合成を{mp}で追加しました。パノラマの最大サイズです(フルサイズ:{full})" } diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index e81969e0f..2e6f73223 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -247,5 +247,7 @@ "{n} photos opened for editing; each is stacked with its original": "{n} fotos abertas para edição; cada uma está empilhada com a original", "{name} opened in the other editor": "{name} aberto no outro editor", "{n} originals opened in the other editor": "{n} originais abertos no outro editor", - "· {in_source} identical copies": "· {in_source} cópias idênticas" + "· {in_source} identical copies": "· {in_source} cópias idênticas", + "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what} mesclagem adicionada em {mp}: o tamanho completo, {full}, precisa de mais memória do que este computador tem", + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what} mesclagem adicionada em {mp}, o maior tamanho possível para um panorama (tamanho completo: {full})" } diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index 38e28e356..c8b848f12 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -247,5 +247,7 @@ "{n} photos opened for editing; each is stacked with its original": "Открыто для редактирования фото: {n}; каждое объединено в стопку с оригиналом", "{name} opened in the other editor": "{name} открыт в другом редакторе", "{n} originals opened in the other editor": "Открыто оригиналов в другом редакторе: {n}", - "· {in_source} identical copies": "· идентичных копий: {in_source}" + "· {in_source} identical copies": "· идентичных копий: {in_source}", + "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what}: слияние добавлено в размере {mp}: для полного размера ({full}) не хватает памяти этого компьютера", + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what}: слияние добавлено в размере {mp} — это наибольший размер панорамы (полный размер: {full})" } diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json index 55636422f..d9e04c0bb 100644 --- a/crates/ui-egui/locales/uk-formats.json +++ b/crates/ui-egui/locales/uk-formats.json @@ -247,5 +247,7 @@ "{n} photos opened for editing; each is stacked with its original": "Відкрито для редагування фото: {n}; кожне об’єднано в стос з оригіналом", "{name} opened in the other editor": "{name} відкрито в іншому редакторі", "{n} originals opened in the other editor": "Відкрито оригіналів в іншому редакторі: {n}", - "· {in_source} identical copies": "· ідентичних копій: {in_source}" + "· {in_source} identical copies": "· ідентичних копій: {in_source}", + "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what}: об’єднання додано в розмірі {mp}: для повного розміру ({full}) бракує пам’яті цього комп’ютера", + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what}: об’єднання додано в розмірі {mp} — це найбільший розмір панорами (повний розмір: {full})" } diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index 782f8d731..65ba929b7 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -247,5 +247,7 @@ "{n} photos opened for editing; each is stacked with its original": "已打开 {n} 张照片进行编辑;每张都与其原片堆叠在一起", "{name} opened in the other editor": "已在其他编辑器中打开 {name}", "{n} originals opened in the other editor": "已在其他编辑器中打开 {n} 个原始文件", - "· {in_source} identical copies": "· {in_source} 个相同副本" + "· {in_source} identical copies": "· {in_source} 个相同副本", + "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "已添加 {what} 合并({mp}):完整尺寸({full})所需的内存超出了这台电脑的内存", + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "已添加 {what} 合并({mp}),这是全景图的最大尺寸(完整尺寸:{full})" } diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index 56db559c6..e7efa5bb2 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -247,5 +247,7 @@ "{n} photos opened for editing; each is stacked with its original": "已開啟 {n} 張相片進行編輯;每張都與其原始相片堆疊在一起", "{name} opened in the other editor": "已在其他編輯器中開啟 {name}", "{n} originals opened in the other editor": "已在其他編輯器中開啟 {n} 個原始檔案", - "· {in_source} identical copies": "· {in_source} 個相同副本" + "· {in_source} identical copies": "· {in_source} 個相同副本", + "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "已新增{what}合併照片({mp}):完整尺寸({full})所需的記憶體超過這台電腦的記憶體", + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "已新增{what}合併照片({mp}),這是全景圖的最大尺寸(完整尺寸:{full})" } diff --git a/crates/ui-egui/src/merge.rs b/crates/ui-egui/src/merge.rs index a603692fa..ee0262ef0 100644 --- a/crates/ui-egui/src/merge.rs +++ b/crates/ui-egui/src/merge.rs @@ -269,8 +269,9 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { }); match r.map_err(lightcraft_engine::EngineError::Other).and_then(|out| app.session.finish_merge(&t.job, out)) { Ok(v) => { + let message = added_message(what, &v); app.merge.last_result = Some(v); - app.toast(ctx, crate::i18n::tr_format!("{what} merge added", what = what)); + app.toast(ctx, message); } Err(_) if t.cancel.load(Ordering::Relaxed) => app.toast(ctx, crate::i18n::tr_format!("{what} merge cancelled", what = what)), Err(e) => { @@ -292,6 +293,27 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { } } +/// The message for a finished merge (`what`: "Panorama", …; `info`: its result): a panorama +/// smaller than its photos' resolution says why (issue #1455). +fn added_message(what: &str, info: &Value) -> String { + let size = |k: &str| info[k].as_f64().map(|mp| format!("{mp:.0} MP")); + match (info["limit"].as_str(), size("megapixels"), size("nativeMegapixels")) { + (Some("memory"), Some(mp), Some(full)) => crate::i18n::tr_format!( + "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has", + what = what, + mp = mp, + full = full + ), + (Some("size"), Some(mp), Some(full)) => crate::i18n::tr_format!( + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})", + what = what, + mp = mp, + full = full + ), + _ => crate::i18n::tr_format!("{what} merge added", what = what), + } +} + fn last_failed_matches(app: &LightcraftApp, opts: &MergeDialog) -> bool { app.merge.failed_options.as_ref() == Some(opts) } @@ -423,3 +445,25 @@ fn num(ui: &mut egui::Ui, spec: &lightcraft_develop::ControlSpec, v: &mut f64) - None => false, } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Issue #1455: a panorama held to the computer's memory, or to the largest size, says so. + #[test] + fn a_smaller_panorama_says_why() { + let added = |info: Value| added_message("Panorama", &info); + assert_eq!( + added(json!({"limit": "memory", "megapixels": 166.2, "nativeMegapixels": 194.0})), + "Panorama merge added at 166 MP: its full size, 194 MP, needs more memory than this computer has" + ); + assert_eq!( + added(json!({"limit": "size", "megapixels": 512.0, "nativeMegapixels": 700.4})), + "Panorama merge added at 512 MP, the largest a panorama can be (its full size is 700 MP)" + ); + assert_eq!(added(json!({"limit": null, "megapixels": 90.0, "nativeMegapixels": 90.0})), "Panorama merge added"); + assert_eq!(added(json!({"limit": "asked", "megapixels": 20.0, "nativeMegapixels": 90.0})), "Panorama merge added"); + assert_eq!(added_message("HDR", &json!({"ev": [0.0]})), "HDR merge added"); + } +} diff --git a/docs/merge.md b/docs/merge.md index 7c298cb10..03c7bae57 100644 --- a/docs/merge.md +++ b/docs/merge.md @@ -13,7 +13,7 @@ All algorithms are our own implementations from published papers (cited in the m `-HDR.dng`, `-Pano.dng` or `-HDR-Pano.dng`, imported and selected. - **Commands** (CLI, MCP, control channel): - `merge.hdr {ids?, align=true, deghost=none|low|medium|high, autoSettings=true, stack=false, preview=false, showOverlay=false, previewPath?}` - - `merge.panorama {ids?, projection=auto|spherical|cylindrical|perspective, boundaryWarp=0..100, autoCrop=false, fillEdges=false, autoSettings=true, maxMegapixels=40, preview, previewPath?}` + - `merge.panorama {ids?, projection=auto|spherical|cylindrical|perspective, boundaryWarp=0..100, autoCrop=false, fillEdges=false, autoSettings=true, maxMegapixels (default: the photos' own resolution), preview, previewPath?}` - `merge.hdrPanorama {ids?, bracket=0 (from EXIF), …both sets of options}` - **CLI:** `lightcraft-cli merge hdr|panorama|hdr-panorama [options] FILES…`; `lightcraft-cli synth-merge hdr|panorama -o DIR` writes synthetic inputs (procedural scene). @@ -67,5 +67,14 @@ scale in `BaselineExposure`; raw sources keep their DNG colour tags (camera RGB) not patch synthesis. - *Create Stack*: the result goes on top of a collapsed stack of its sources (`Catalog::stack_with_ops`); its caption also lists them. -- Panorama output is limited to `maxMegapixels` (default 40) — blending memory is ~25 bytes/pixel. +- A panorama comes out at its photos' own resolution, at most 512 MP and 65,000 px on its long edge + (Lightroom's limits), unless `maxMegapixels` asks for less (issue #1455; it was 40 MP for every panorama). + It is held to what fits in memory: three quarters of the computer's RAM less the app's memory budget + (a computer whose memory can't be read, Windows, counts as 8 GB), never below 40 MP. Measured peak + resident sizes of synthetic stitches (20–81 MP out of 38–154 MP of photos): while the photos are projected + they are still held beside the tiles, 12 bytes per photo pixel plus 27–34 per output pixel; the blend that + follows, with the photos' pixels freed, peaks at 55–67 bytes per output pixel; the DNG takes less. The + limit uses 36 and 68. Five 51 MP photos can make up to 166 MP with 16 GB, 355 MP with 32 GB. The result's + `megapixels`, `nativeMegapixels` and `limit` (`memory`, `size` or `asked`) say when it came out smaller, + and the app's message says why. - The browser build has no file access for merges yet. diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7bb187811..e21508b7f 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (LR-MERGE-PANO: panoramas at full resolution, held to memory (#1455); integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry) · **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 @@ -348,7 +348,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| | LR-MERGE-HDR | HDR merge | P2 | ✅ | `cmd:merge.hdr`, `cmd:dialog.mergeHdr`, `crates/merge/src/frame.rs` (`Frame::lens`) | auto align, deghost None–High + overlay, auto settings, float DNG, Create Stack; the DNG keeps the reference frame's embedded lens corrections (tested by `hdr_merge_keeps_the_reference_frames_lens_corrections`); JPEG brackets treated as linear; raws without colour matrices of their own (CR2, NEF, ARW…) keep the colour they open with, written as DNG tags (`starting_colour_tags`; the look's chroma curve has no tag; panoramas too), issue #965 | -| LR-MERGE-PANO | Panorama | P2 | ✅ | `cmd:merge.panorama`, `cmd:dialog.mergePanorama` | spherical/cylindrical/perspective + auto, boundary warp, auto crop, fill edges (diffusion), DNG; no lens model / 360° wrap | +| LR-MERGE-PANO | Panorama | P2 | ✅ | `cmd:merge.panorama`, `cmd:dialog.mergePanorama` | spherical/cylindrical/perspective + auto, boundary warp, auto crop, fill edges (diffusion), DNG at the photos' own resolution up to 512 MP / 65,000 px, held to what fits in memory (issue #1455; was a fixed 40 MP); no lens model / 360° wrap | | LR-MERGE-HDRPANO | HDR panorama | P2 | ✅ | `cmd:merge.hdrPanorama`, `cmd:dialog.mergeHdrPanorama` | brackets grouped by EXIF | | LR-MERGE-HEADLESS | Merge with last settings | P2 | ✅ | `cmd:merge.hdrLast`, `cmd:merge.panoramaLast`, `cmd:merge.hdrPanoramaLast` | no dialog; the options of the last merge of that kind (defaults the first time) | @@ -815,6 +815,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-13 | patch | LR-BEHAV-AUTOSAVE: a damaged snapshot that still parses is repaired on open (ids, stacks, folder loops, id counters) instead of panicking or hanging; status unchanged | | 2026-10-13 | minor | LR-EDIT-OPTICS-PROFILE: Nikon Z 6II / Z 8 / Z f NEFs use the camera's in-file distortion correction (NikonNEFInfo); status unchanged (🟡) | | 2026-10-11 | minor | Model setup folder action, opener error and retry (LR-MASK-OBJECTS). | +| 2026-10-13 | minor | LR-MERGE-PANO: a panorama keeps its photos' resolution (up to 512 MP / 65,000 px) instead of a fixed 40 MP, held to what fits in memory (issue #1455); status unchanged | | 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references | | 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) | | 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged | diff --git a/docs/whats-new.md b/docs/whats-new.md index 1bf44c3f4..4f321c1c7 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,13 @@ ## 2026-10-14 +### Full-resolution panoramas +- Panorama and HDR Panorama merges keep the resolution of their photos instead of stopping at 40 megapixels + (issue #1455), up to 512 MP and 65,000 pixels on the long edge, as in Lightroom. When the full size needs more + memory than the computer has, the panorama is made as large as fits (five 51 MP photos: up to 166 MP with + 16 GB, 355 MP with 32 GB) and the message says so. The merge also frees each photo's pixels once they are + projected, so it needs less memory than before at any size (an 81 MP stitch: 4.4 GB instead of 6.2 GB). + ### Hard-edged Heal and Clone spots - A spot with Feather at 0 no longer draws black pixels exactly on its radius. This also fixes spots at 0% Opacity changing pixels along their edge. The hard edge now leaves pixels on the radius unchanged.