# Conflicts:
#	crates/engine/src/tests_merge.rs
#	crates/ui-egui/locales/de-formats.json
#	crates/ui-egui/locales/es-formats.json
#	crates/ui-egui/locales/fr-formats.json
#	crates/ui-egui/locales/ja-formats.json
#	crates/ui-egui/locales/pt-br-formats.json
#	crates/ui-egui/locales/ru-formats.json
#	crates/ui-egui/locales/uk-formats.json
#	crates/ui-egui/locales/zh-hans-formats.json
#	crates/ui-egui/locales/zh-hant-formats.json
#	docs/merge.md
#	docs/parity-checklist.md
#	docs/whats-new.md
This commit is contained in:
Brandon Thomas committed 2026-10-14 22:33:16 -04:00
commit 2fd769963e
18 files changed
+213 -24

No files matched your search

+1 -1
View File
@@ -76,7 +76,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};
@@ -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::<Vec<_>>(),
@@ -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<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<_>>()?;
@@ -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 },
+27
View File
@@ -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");
}
+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
@@ -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})"
}
+3 -1
View File
@@ -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})"
}
+3 -1
View File
@@ -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})"
}
+3 -1
View File
@@ -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})"
}
+3 -1
View File
@@ -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})"
}
+3 -1
View File
@@ -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})"
}
+3 -1
View File
@@ -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})"
}
+3 -1
View File
@@ -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})"
}
+3 -1
View File
@@ -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})"
}
+45 -1
View File
@@ -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");
}
}
+1 -1
View File
@@ -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
+1
View File
@@ -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 |
+7
View File
@@ -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.