mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-19 02:10:25 -05:00
Merge PR #1549
# 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:
commit
2fd769963e
18 files changed
+213
-24
No files matched your search
@@ -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)
|
||||
),
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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})"
|
||||
}
|
||||
@@ -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})"
|
||||
}
|
||||
@@ -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})"
|
||||
}
|
||||
@@ -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})"
|
||||
}
|
||||
@@ -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})"
|
||||
}
|
||||
@@ -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})"
|
||||
}
|
||||
@@ -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})"
|
||||
}
|
||||
@@ -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})"
|
||||
}
|
||||
@@ -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})"
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user