diff --git a/crates/engine/src/cmd/merge.rs b/crates/engine/src/cmd/merge.rs index cb9904244..708614453 100644 --- a/crates/engine/src/cmd/merge.rs +++ b/crates/engine/src/cmd/merge.rs @@ -76,7 +76,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 fe182cebc..ed61c0509 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}; @@ -151,8 +152,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, @@ -194,8 +196,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, @@ -232,7 +235,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::>(), @@ -240,9 +256,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::>()?; @@ -333,7 +386,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 366e72476..aaa59acb1 100644 --- a/crates/engine/src/tests_merge.rs +++ b/crates/engine/src/tests_merge.rs @@ -164,6 +164,14 @@ 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); @@ -199,3 +207,22 @@ fn hdr_batch_merges_each_stack() { assert_eq!(s.catalog.photos().count(), 8); 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 ce601ae33..f198457bb 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -287,5 +287,7 @@ "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft wurde unerwartet beendet: {e}\n\nEinzelheiten stehen in der Protokolldatei: {path}", "LightCraft stopped unexpectedly: {e}": "LightCraft wurde unerwartet beendet: {e}", "Renames {n} face{} across the library. The files are not changed.": "Benennt {n} Gesichter in der Bibliothek um. Die Dateien werden nicht geändert.{:.0}", - "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "„{to}“ ist bereits eine Person. Zusammenführen ergibt eine Person; Rückgängig trennt sie wieder." + "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "„{to}“ ist bereits eine Person. Zusammenführen ergibt eine Person; Rückgängig trennt sie wieder.", + "{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 fb2c80100..8274ed8be 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -287,5 +287,7 @@ "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft se detuvo inesperadamente: {e}\n\nLos detalles están en el archivo de registro: {path}", "LightCraft stopped unexpectedly: {e}": "LightCraft se detuvo inesperadamente: {e}", "Renames {n} face{} across the library. The files are not changed.": "Cambia el nombre de {n} cara{} en toda la biblioteca. Los archivos no se modifican.", - "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” ya es una persona. Combinarlas deja una sola; Deshacer las separa de nuevo." + "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” ya es una persona. Combinarlas deja una sola; Deshacer las separa de nuevo.", + "{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 45f40081a..4a5716b0c 100644 --- a/crates/ui-egui/locales/fr-formats.json +++ b/crates/ui-egui/locales/fr-formats.json @@ -287,5 +287,7 @@ "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft s’est arrêté de façon inattendue : {e}\n\nConsultez le fichier journal pour plus de détails : {path}", "LightCraft stopped unexpectedly: {e}": "LightCraft s’est arrêté de façon inattendue : {e}", "Renames {n} face{} across the library. The files are not changed.": "Renomme {n} visage{} dans la bibliothèque. Les fichiers ne sont pas modifiés.", - "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "« {to} » est déjà une personne. Les combiner n’en fait qu’une ; Annuler les sépare de nouveau." + "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "« {to} » est déjà une personne. Les combiner n’en fait qu’une ; Annuler les sépare de nouveau.", + "{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 d38dfdc48..2df9f2b82 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -287,5 +287,7 @@ "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraftが予期せず終了しました: {e}\n\n詳しくはログファイルをご覧ください: {path}", "LightCraft stopped unexpectedly: {e}": "LightCraftが予期せず終了しました: {e}", "Renames {n} face{} across the library. The files are not changed.": "ライブラリ全体で{n}件の顔の名前を変更します。ファイルは変更されません。{:.0}", - "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "「{to}」はすでに別の人物です。まとめると1人になり、元に戻すと再び分かれます。" + "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "「{to}」はすでに別の人物です。まとめると1人になり、元に戻すと再び分かれます。", + "{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 2b6e1f046..1969dd262 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -287,5 +287,7 @@ "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "O LightCraft parou inesperadamente: {e}\n\nO arquivo de log tem os detalhes: {path}", "LightCraft stopped unexpectedly: {e}": "O LightCraft parou inesperadamente: {e}", "Renames {n} face{} across the library. The files are not changed.": "Renomeia {n} rosto{} na biblioteca. Os arquivos não são alterados.", - "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” já é uma pessoa. Combiná-las deixa uma só; Desfazer as separa de novo." + "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” já é uma pessoa. Combiná-las deixa uma só; Desfazer as separa de novo.", + "{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 0cc9dd881..11d899c38 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -287,5 +287,7 @@ "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft неожиданно остановился: {e}\n\nПодробности в файле журнала: {path}", "LightCraft stopped unexpectedly: {e}": "LightCraft неожиданно остановился: {e}", "Renames {n} face{} across the library. The files are not changed.": "Переименовывает {n} лиц в библиотеке. Файлы не изменяются.{:.0}", - "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "«{to}» уже есть среди людей. Объединение оставляет одного человека; отмена снова разделяет их." + "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "«{to}» уже есть среди людей. Объединение оставляет одного человека; отмена снова разделяет их.", + "{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 42675238c..2040873ca 100644 --- a/crates/ui-egui/locales/uk-formats.json +++ b/crates/ui-egui/locales/uk-formats.json @@ -287,5 +287,7 @@ "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft несподівано зупинився: {e}\n\nПодробиці у файлі журналу: {path}", "LightCraft stopped unexpectedly: {e}": "LightCraft несподівано зупинився: {e}", "Renames {n} face{} across the library. The files are not changed.": "Перейменовує {n} облич у бібліотеці. Файли не змінюються.{:.0}", - "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "«{to}» уже є серед людей. Об’єднання лишає одну людину; скасування знову розділяє їх." + "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "«{to}» уже є серед людей. Об’єднання лишає одну людину; скасування знову розділяє їх.", + "{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 6af55c88d..f93aaf809 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -287,5 +287,7 @@ "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft 意外停止:{e}\n\n详细信息见日志文件:{path}", "LightCraft stopped unexpectedly: {e}": "LightCraft 意外停止:{e}", "Renames {n} face{} across the library. The files are not changed.": "在整个图库中重命名 {n} 张面孔。不会改动文件。{:.0}", - "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}”已经是另一个人。合并后成为一个人;撤销会再把他们分开。" + "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}”已经是另一个人。合并后成为一个人;撤销会再把他们分开。", + "{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 1517e885d..f3bd24b20 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -287,5 +287,7 @@ "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft 意外停止:{e}\n\n詳細資訊請見記錄檔:{path}", "LightCraft stopped unexpectedly: {e}": "LightCraft 意外停止:{e}", "Renames {n} face{} across the library. The files are not changed.": "在整個圖庫中重新命名 {n} 張面孔。不會改動檔案。{:.0}", - "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "「{to}」已經是另一個人。合併後成為一個人;還原會再把他們分開。" + "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "「{to}」已經是另一個人。合併後成為一個人;還原會再把他們分開。", + "{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 4374ac591..02e4ddec2 100644 --- a/crates/ui-egui/src/merge.rs +++ b/crates/ui-egui/src/merge.rs @@ -296,8 +296,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) => { // cancelling one merge of a batch stops the batch @@ -330,6 +331,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) } @@ -461,3 +483,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 3485e02d1..50dd8e0af 100644 --- a/docs/merge.md +++ b/docs/merge.md @@ -16,7 +16,7 @@ All algorithms are our own implementations from published papers (cited in the m one exposure is skipped), one after the other in the activity stack. - **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}` - `merge.hdrBatch {ids?, align, deghost, autoSettings, stack}` → `{merged: [{id, path, …}], failed: [{photos, error}]}` - **CLI:** `lightcraft-cli merge hdr|panorama|hdr-panorama|hdr-batch [options] FILES…` (`hdr-batch` finds the brackets diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 757a4ec4b..ac14ed41a 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -849,6 +849,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-13 | patch | LR-BEHAV-GPU: a GPU lost while LightCraft runs (driver reset) is reported as a stop, not a failed start, and the next start renders on the CPU and says why (issue #1337); status unchanged | | 2026-10-13 | minor | LR-META-INFO: Nikon DSLR raws show their lens from the maker note; status unchanged (✅) | | 2026-10-13 | patch | LR-IMP-LOCAL: browsing a Local folder again forgets the untouched records of files deleted or moved by another app (issue #1392); status unchanged | +| 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 d42148617..1419d4148 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -91,6 +91,13 @@ each time you look (issue #1392): the next time you browse the folder, in a later session, they're left out. A photo you had rated, flagged or edited stays, marked missing, so its changes aren't lost; photos in My Photos are unchanged. +### 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.