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.
This commit is contained in:
Michael Anderson
2026-10-14 07:32:45 -05:00
parent a4b53b28cc
commit 02d5d6bc03
18 changed files with 225 additions and 27 deletions
+1 -1
View File
@@ -56,7 +56,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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)
),
+60 -6
View File
@@ -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::<Vec<_>>(),
@@ -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<usize>) -> 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<usize>) -> 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<usize> {
let ev: Vec<f64> = frames.iter().map(|f| f.exposure).collect::<Option<_>>()?;
@@ -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 },
+27
View File
@@ -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");
}
+21 -6
View File
@@ -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<Option<Cam
}
/// Stitch `frames` (oriented, linear) into a panorama.
pub fn stitch(frames: Vec<Frame>, opts: &PanoOptions, progress: &Progress) -> Result<PanoResult> {
pub fn stitch(mut frames: Vec<Frame>, opts: &PanoOptions, progress: &Progress) -> Result<PanoResult> {
let n = frames.len();
if n < 2 {
return Err(MergeError::TooFew(n, 2));
@@ -231,9 +240,9 @@ pub fn stitch(frames: Vec<Frame>, 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<Frame>, 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<Frame>, 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(),
+23
View File
@@ -245,3 +245,26 @@ fn unrelated_photos_do_not_stitch() {
let frames: Vec<Frame> = 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));
}
+3 -1
View File
@@ -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})"
}
+3 -1
View File
@@ -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})"
}
+3 -1
View File
@@ -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})"
}
+3 -1
View File
@@ -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})"
}
+3 -1
View File
@@ -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})"
}
+3 -1
View File
@@ -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})"
}
+3 -1
View File
@@ -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})"
}
+3 -1
View File
@@ -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})"
}
+3 -1
View File
@@ -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})"
}
+45 -1
View File
@@ -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");
}
}
+11 -2
View File
@@ -13,7 +13,7 @@ All algorithms are our own implementations from published papers (cited in the m
`<name>-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.
+3 -2
View File
@@ -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 |
+7
View File
@@ -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.