Files
lightcraft/crates/engine/src/tests_merge.rs
T
Brandon ThomasandClaude Opus 5.5 bd2e756b24 Never crash: gpu and merge free of panicking calls
gpu (its render already runs under catch_unwind and falls back to the CPU):
- a failed readback (device lost, out of memory) logs, marks the GPU
  broken and returns zeros, so crate::render discards the result and
  renders on the CPU, instead of panicking;
- an unknown kernel name does the same instead of panic!;
- finish-parameter fields are looked up as Option (debug_assert on an
  unknown name) instead of panic!;
- the warp sampler matches (mode, warp) like the CPU pipeline;
- Buf::raw keeps one documented expect (Some from construction to Drop).

merge:
- pyramid building (push_pull, Blender::finish), camera graph growth,
  bundle adjustment, focal median and alignment use let-else / filter_map
  instead of expect;
- synth::bracket_dngs / pano_views return Result (a missing demo scene
  or a DNG write error is an error); lightcraft-cli synth-merge and the
  engine merge tests handle it.

lightcraft-gpu, lightcraft-merge and lightcraft-cli now #![deny]
unwrap/expect/panic/unimplemented/todo/unreachable; clippy.toml as in the
steering PR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:34:53 -04:00

95 lines
4.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Photo Merge through the command layer: synthetic brackets / panorama views on disk → merged
//! DNG next to the sources → imported, selected, Auto Settings / auto crop applied.
use serde_json::json;
use crate::Session;
fn temp_dir(tag: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("lc-merge-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn import(s: &mut Session, paths: Vec<String>) -> Vec<u64> {
let r = s.execute("library.import", &json!({"paths": paths})).unwrap();
r["imported"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect()
}
#[test]
fn hdr_merge_command_creates_and_imports_a_dng() {
let dir = temp_dir("hdr");
let mut paths = Vec::new();
for (i, b) in lightcraft_merge::synth::bracket_dngs(480, 320, &[-2.0, 0.0, 2.0]).unwrap().into_iter().enumerate() {
let p = dir.join(format!("IMG_{i}.dng"));
std::fs::write(&p, b).unwrap();
paths.push(p.to_string_lossy().to_string());
}
let mut s = Session::new().with_fs();
let ids = import(&mut s, paths);
assert_eq!(ids.len(), 3);
// preview: rendered PNG, nothing added
let png = dir.join("preview.png");
let pv = s.execute("merge.hdr", &json!({"ids": ids, "deghost": "medium", "preview": true, "showOverlay": true, "previewPath": png})).unwrap();
assert!(png.exists() && pv["previewSize"][0].as_u64().unwrap() > 100, "{pv}");
assert_eq!(s.catalog.photos().count(), 3);
let ev: Vec<f64> = pv["ev"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
assert!((ev[0] + 2.0).abs() < 0.1 && (ev[2] - 2.0).abs() < 0.1, "{ev:?}");
// full merge
let r = s.execute("merge.hdr", &json!({"ids": ids, "autoSettings": true, "stack": true})).unwrap();
let path = r["path"].as_str().unwrap();
assert!(path.ends_with("IMG_0-HDR.dng"), "{path}");
let id = lightcraft_catalog::PhotoId(r["id"].as_u64().unwrap());
let p = s.catalog.photo(id).unwrap().clone();
assert_eq!((p.width, p.height), (480, 320));
assert_eq!(p.kind, lightcraft_catalog::MediaKind::Raw);
assert_eq!(s.selection.active, Some(id));
let d = s.develop_of(id).unwrap();
assert!(d.light.exposure != 0.0 || d.light.highlights != 0.0, "auto settings applied");
let img = s.render_now(id, 256, 256).unwrap();
assert_eq!(img.image.width, 256);
// Create Stack: the result tops a collapsed stack of its sources, so the grid shows the result
let st = s.catalog.stack_of(id).expect("merge result stacked").clone();
assert_eq!(st.top(), id);
assert_eq!(st.photos[1..].iter().map(|p| p.0).collect::<Vec<_>>(), ids);
assert!(st.collapsed);
assert_eq!(s.visible_cloned(), vec![id]);
// the second merge doesn't overwrite the first
let r2 = s.execute("merge.hdr", &json!({"ids": ids, "autoSettings": false})).unwrap();
assert!(r2["path"].as_str().unwrap().ends_with("IMG_0-HDR-2.dng"));
let id2 = lightcraft_catalog::PhotoId(r2["id"].as_u64().unwrap());
assert!(s.catalog.stack_of(id2).is_none(), "no stack without the option");
// the type filter finds merge results (and only them)
s.execute("library.filter", &json!({"merged": "hdr"})).unwrap();
let found: std::collections::HashSet<_> = s.catalog.query(&s.filter, &Default::default()).into_iter().collect();
assert_eq!(found, [id, id2].into_iter().collect());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn panorama_command_with_auto_crop() {
let dir = temp_dir("pano");
let mut paths = Vec::new();
for (i, v) in lightcraft_merge::synth::pano_views(480, 360, 420.0, &[-25.0, 0.0, 25.0]).unwrap().into_iter().enumerate() {
let img = v.to_srgb8();
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
let p = dir.join(format!("P_{i}.png"));
std::fs::write(&p, png).unwrap();
paths.push(p.to_string_lossy().to_string());
}
let mut s = Session::new().with_fs();
let ids = import(&mut s, paths);
let r = s.execute("merge.panorama", &json!({"ids": ids, "projection": "cylindrical", "autoCrop": true, "autoSettings": false})).unwrap();
assert_eq!(r["projection"], "cylindrical");
assert_eq!(r["used"].as_array().unwrap().len(), 3);
let id = lightcraft_catalog::PhotoId(r["id"].as_u64().unwrap());
let p = s.catalog.photo(id).unwrap().clone();
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:?}");
// too few photos
assert!(s.execute("merge.panorama", &json!({"ids": [ids[0]]})).is_err());
let _ = std::fs::remove_dir_all(&dir);
}