# Conflicts:
#	docs/parity.md
This commit is contained in:
Brandon Thomas committed 2026-10-09 20:56:57 -04:00
commit 89753f5d67
11 files changed
+619 -7

No files matched your search

+14
View File
@@ -249,6 +249,20 @@ lightcraft --control 7980 ~/Pictures/trip
path=out.jpg longEdge=2048` runs any chain of commands (headless, on a saved library, or against the running app)
and prints one JSON result per command; `lightcraft-cli render in.dng -o out.jpg --set light.exposure=0.7 --preset …`.
- **Undo for everything**, including agent actions: a slider drag (or a scripted burst of updates) is one undo step.
- **Contact sheets (native app / CLI):** select photos, then **File → Contact Sheet PDF…**. Choose A4 or Letter, landscape,
rows/columns and filename captions; save a paginated PDF and print it from your PDF viewer. Exports use the
current edits, fit each photo without further cropping, and render at 150 dpi in sRGB. The background export
supports cancellation and protects originals and XMP sidecars. From the CLI:
```sh
lightcraft-cli run --import ~/Pictures/shoot library.selectAll export.contactSheet path=Contact.pdf paper=a4 columns=3 rows=4 captions=true
```
`export.contactSheet` also accepts explicit `ids`, `landscape=true`, and `paper=letter`. It replaces an existing
output only after the entire PDF succeeds. Limits: 1,000 photos, 100 pages, 256 MiB; captions are rasterized
with the same font coverage as watermarks (CJK requires craft-fonts), with long names shortened in the middle.
This is a contact-sheet workflow,
not a printer-driver or custom picture-package layout editor.
- **Every widget is addressable** (`ui.widgets`) and clickable by name, so agents operate the real UI, not a
side door.
- The screenshots in this README were produced end to end by the [`docs/showcase/`](docs/showcase/) scripts.
+2 -2
View File
@@ -27,7 +27,7 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW, NEF, RW2, RAF, CR3, CR2 and PEF have guarded camera-JPEG colour estimates, with bundled ILCE-7CR, ILCE-7M4, X-H2S and X-T4 profiles (docs/camera-preview-colour.md); other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) |
| **AI & computational** | ~15–20% | Assisted culling (focus, bursts), auto tone, HDR/panorama merge; subject/sky/background masks as classical heuristics, or the photo's own segmentation mattes when it ships them (DNG semantic masks, e.g. iPhone ProRAW's sky matte); non-destructive Bayer RAW denoise with opt-in weights on pure-Rust CPU/GPU; **faces (MVP, [docs/faces.md](docs/faces.md))**: a pure-Rust detector (a 232 KB opt-in download), opt-in recognition models (one-click download or add a file; they run on the shared pure-Rust CPU interpreter; pinned SFace/AuraFace embeddings agree with an independent reference runtime on 40 public crops; one-thread medians 34–36 / 233–252 ms (AuraFace remains expensive)), a background scan that finds and embeds faces, name suggestions, a person's page with look-alike faces to confirm, an Unnamed faces section (look-alikes together, select a group and name it at once) | Real segmentation masks (subject, sky, people, objects, landscape, depth), denoise model policy / linear RGB / quality verification, super resolution, lens blur, generative remove, natural-language search; for faces: clusters with separators between groups, small faces in large photos (the detector looks at 640 px), thresholds calibrated on live faces, writing face regions back to XMP. **Blocked on a model strategy** (licensable weights or our own training; pure-Rust inference is feasible) |
| **Workflow & library** | ~85% (single machine) | Robust catalog (journal + snapshots, background compaction, crash-tested), 85k-photo libraries stay responsive, Local browsing with automatic cleanup, XMP interop, keywords, smart albums, Move import | Opening an 85k library takes 1.7–4.7 s; no cloud sync (out of scope), no tablet companion (#74, roadmap), shared albums, publish services, tethering |
| **Classic modules** | ~30% | Geotagging from GPX track logs, soft proofing (partial), slideshow (basic) | **Map view, Book, Print, Slideshow module, Web, publish services**: ~40 tracker rows ⬜ |
| **Classic modules** | ~30% | Geotagging from GPX track logs, soft proofing (partial), slideshow (basic), paginated contact-sheet PDF export (A4/Letter, layout/captions, current edits, 150 dpi sRGB) | **Map view, Book, full Print module, Slideshow module, Web, publish services**: contact sheets work, custom print packages and printer colour management remain missing |
| **HDR & video** | 0% | | HDR edit/display/export; video play/trim/edit/export |
| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; English, Simplified/Traditional Chinese, Japanese, Brazilian Portuguese, Spanish, German, Russian and Ukrainian UI (see docs/localization.md); remaining technical errors; Traditional Chinese uses the Simplified Chinese font until craft-fonts has a TC face; accessibility partial; headless UI tests time out under machine load |
@@ -62,7 +62,7 @@ Priorities, in order. Each points at tracker rows in [`docs/parity.md`](docs/par
segmentation masks and denoise. Bayer denoise inference is implemented in pure Rust with an opt-in GPL model; release policy and independently licensed weights remain open. Unblocks M12 and Enhance. Face detection and recognition run on our own checked Rust engine. Object / Describe masks now
run Meta's SAM 3 in pure Rust (optional, user-consented download under the non-OSI SAM License; CDN mirrors still
to be set up, docs/ai-masks.md); Subject / Sky / People, Raw Details and Super Resolution are still open.
7. **Then:** HDR (Q), the Classic output modules (Print first, then Map view, Book, Slideshow), video (R), localisation
7. **Then:** HDR (Q), the Classic output modules (expand contact-sheet PDF export into full Print, then Map view, Book, Slideshow), video (R), localisation
and accessibility.
Already closed in the week of 2026-10-03: compressed NEF (#10), Move import (#29), per-library smart-preview folder,
+40
View File
@@ -7,6 +7,46 @@ use serde_json::{Value, json};
const BIN: &str = env!("CARGO_BIN_EXE_lightcraft-cli");
#[test]
fn contact_sheet_exports_pdf_and_protects_originals() {
let input = tmp("contact-sheet-input.png");
gradient_png(&input);
let out = tmp("contact-sheet.pdf");
let (ok, lines, err) = run_cli(
&[
"--import",
input.to_str().unwrap(),
"export.contactSheet",
&format!("path={}", out.display()),
"paper=letter",
"landscape=true",
"columns=2",
"rows=3",
],
None,
);
assert!(ok, "{err}: {lines:?}");
assert_eq!(lines[0]["result"]["pages"], 1);
assert_eq!(lines[0]["result"]["photos"], 1);
let bytes = std::fs::read(&out).unwrap();
assert!(bytes.starts_with(b"%PDF-1.4"));
assert!(String::from_utf8_lossy(&bytes).contains("/MediaBox [0 0 792.00 612.00]"));
let before = std::fs::read(&input).unwrap();
for target in [input.clone(), input.with_extension("xmp")] {
if target != input {
std::fs::write(&target, b"existing sidecar").unwrap();
}
let (ok, _, _) = run_cli(&["--import", input.to_str().unwrap(), "export.contactSheet", &format!("path={}", target.display())], None);
assert!(!ok, "overwrote protected file");
}
assert_eq!(std::fs::read(&input).unwrap(), before);
assert_eq!(std::fs::read(input.with_extension("xmp")).unwrap(), b"existing sidecar");
let prior = std::fs::read(&out).unwrap();
let (ok, _, _) = run_cli(&["--import", input.to_str().unwrap(), "export.contactSheet", &format!("path={}", out.display()), "columns=0"], None);
assert!(!ok);
assert_eq!(std::fs::read(&out).unwrap(), prior, "failed export changed prior output");
}
fn tmp(name: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("lightcraft-cli-test-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
+11
View File
@@ -80,6 +80,17 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(query "export.contactSheet", "Export Contact Sheet PDF", [], None,
"{path, ids?: photo ids (default selection), paper?: a4|letter, landscape?: bool, columns?: 1..8, rows?: 1..10, captions?: bool} → {path, pages, photos, bytes}; replaces an existing output, never an original",
always, |s, p| {
let path = p.get("path").and_then(Value::as_str).filter(|s| !s.trim().is_empty()).ok_or_else(|| bad("export.contactSheet", "missing path"))?;
s.check_write_target(path).map_err(|e| bad("export.contactSheet", e))?;
let doc = crate::contact_sheet::prepare(s, p).and_then(|work| work.run(&mut |_, _| true)).map_err(|e| bad("export.contactSheet", e))?;
s.check_write_target(path).map_err(|e| bad("export.contactSheet", e))?;
crate::export::write_file(path, &doc.bytes).map_err(|e| bad("export.contactSheet", e))?;
Ok(json!({"path": path, "pages": doc.pages, "photos": doc.photos, "bytes": doc.bytes.len()}))
}
),
cmd!(
"export.presets",
"Export Presets",
+378
View File
@@ -0,0 +1,378 @@
//! Paginated contact sheets, rendered with the normal export pipeline and packaged as PDF.
use crate::export::{Anchor, ExportFormat, ExportOptions, Watermark, draw_watermark, encode_image};
use crate::{Session, media::RenderJob};
use lightcraft_pipeline::{OutputDepth, OutputSpace};
use lightcraft_raster::Rgba8;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase", deny_unknown_fields)]
pub struct Options {
pub paper: String,
pub landscape: bool,
pub columns: usize,
pub rows: usize,
pub captions: bool,
}
impl Default for Options {
fn default() -> Self {
Self { paper: "a4".into(), landscape: false, columns: 3, rows: 4, captions: true }
}
}
impl Options {
pub fn parse(p: &Value) -> Result<Self, String> {
let mut settings = p.as_object().cloned().ok_or("params must be an object")?;
settings.remove("path");
settings.remove("ids");
settings.remove("id");
let o: Self = serde_json::from_value(Value::Object(settings)).map_err(|e| e.to_string())?;
o.layout()?;
Ok(o)
}
fn layout(&self) -> Result<Layout, String> {
if !(1..=8).contains(&self.columns) || !(1..=10).contains(&self.rows) {
return Err("columns must be 1–8 and rows 1–10".into());
}
let (mut w, mut h) = match self.paper.as_str() {
"a4" => (595.28, 841.89),
"letter" => (612.0, 792.0),
_ => return Err("paper must be a4 or letter".into()),
};
if self.landscape {
std::mem::swap(&mut w, &mut h);
}
// Fixed 150 dpi, bounded paper sizes and grid dimensions keep allocations predictable.
let width = (w * 150.0 / 72.0_f64).round() as usize;
let height = (h * 150.0 / 72.0_f64).round() as usize;
Ok(Layout { points: (w, h), width, height, cell_w: (width - 120) / self.columns, cell_h: (height - 120) / self.rows })
}
}
struct Layout {
points: (f64, f64),
width: usize,
height: usize,
cell_w: usize,
cell_h: usize,
}
pub struct Prepared {
options: Options,
items: Vec<(String, RenderJob)>,
}
pub struct Document {
pub bytes: Vec<u8>,
pub pages: usize,
pub photos: usize,
}
pub fn prepare(session: &mut Session, p: &Value) -> Result<Prepared, String> {
let options = Options::parse(p)?;
if let Some(ids) = p.get("ids") {
let a = ids.as_array().ok_or("ids must be an array of photo ids")?;
if a.iter().any(|v| v.as_u64().is_none()) {
return Err("ids must contain only photo ids".into());
}
}
if p.get("id").is_some_and(|v| v.as_u64().is_none()) {
return Err("id must be a photo id".into());
}
let ids = session.targets(p);
if ids.is_empty() || ids.len() > 1000 {
return Err("select between 1 and 1000 photos".into());
}
let l = options.layout()?;
if ids.len().div_ceil(options.columns * options.rows) > 100 {
return Err("contact sheets are limited to 100 pages; increase the grid size or select fewer photos".into());
}
let mut items = Vec::with_capacity(ids.len());
for id in ids {
let name = session.catalog.photo(id).ok_or("no such photo")?.file_name.clone();
let job = session.export_job(id, l.cell_w - 16, l.cell_h - 48, OutputSpace::Srgb, OutputDepth::U8)?;
items.push((name, job));
}
Ok(Prepared { options, items })
}
impl Prepared {
pub fn len(&self) -> usize {
self.items.len()
}
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
/// Cancellation produces no partial document; callers write only after this succeeds.
pub fn run(self, progress: &mut dyn FnMut(usize, &str) -> bool) -> Result<Document, String> {
let l = self.options.layout()?;
let capacity = self.options.columns * self.options.rows;
let photos = self.items.len();
let pages = photos.div_ceil(capacity);
let mut pdf = Pdf::new(pages, l.points);
let mut page = Rgba8::filled(l.width, l.height, [255; 4]);
for (i, (name, job)) in self.items.into_iter().enumerate() {
if !progress(i, &name) {
return Err("Contact sheet cancelled".into());
}
let image = job.run().rendered?.image;
let slot = i % capacity;
let x = 60 + slot % self.options.columns * l.cell_w;
let y = 60 + slot / self.options.columns * l.cell_h;
// The renderer fits within the requested rectangle and applies crop/orientation.
if image.width > l.cell_w - 16 || image.height > l.cell_h - 48 {
return Err("render exceeded contact sheet cell".into());
}
blit(&mut page, &image, x + (l.cell_w - image.width) / 2, y + (l.cell_h - 40 - image.height) / 2)?;
if self.options.captions {
let mut caption = Rgba8::filled(l.cell_w - 16, 28, [255; 4]);
draw_watermark(
&mut caption,
&Watermark {
text: caption_text(&name, l.cell_w - 16),
size: 0.5,
opacity: 1.0,
anchor: Anchor::Center,
inset: 0.0,
color: [32; 3],
shadow: false,
..Default::default()
},
);
blit(&mut page, &caption, x + 8, y + l.cell_h - 36)?;
}
if slot + 1 == capacity || i + 1 == photos {
let jpg = encode_image(&page, &ExportOptions { format: ExportFormat::Jpeg, quality: 92, ..Default::default() })?;
if pdf.bytes.len().saturating_add(jpg.len()) > 256 * 1024 * 1024 {
return Err("contact sheet exceeds 256 MiB; select fewer photos".into());
}
pdf.page(i / capacity, &jpg, l.width, l.height);
page.data.fill([255; 4]);
}
}
if !progress(photos, "PDF") {
return Err("Contact sheet cancelled".into());
}
Ok(Document { bytes: pdf.finish(), pages, photos })
}
}
fn caption_text(name: &str, width: usize) -> String {
use unicode_segmentation::UnicodeSegmentation;
let clean: String = name.chars().map(|c| if c.is_control() { ' ' } else { c }).collect();
let cells: Vec<_> = clean.graphemes(true).collect();
// A conservative one-em budget at 14 px also accommodates the optional CJK faces.
let limit = (width / 14).max(3);
if cells.len() <= limit {
return clean;
}
let left = limit / 2;
let right = limit - left - 1;
format!("{}…{}", cells.iter().take(left).copied().collect::<String>(), cells.iter().skip(cells.len() - right).copied().collect::<String>())
}
fn blit(dst: &mut Rgba8, src: &Rgba8, x: usize, y: usize) -> Result<(), String> {
if x.checked_add(src.width).is_none_or(|w| w > dst.width) || y.checked_add(src.height).is_none_or(|h| h > dst.height) {
return Err("contact sheet image outside page".into());
}
for row in 0..src.height {
let start = (y + row) * dst.width + x;
let a = dst.data.get_mut(start..start + src.width).ok_or("invalid page buffer")?;
let b = src.data.get(row * src.width..(row + 1) * src.width).ok_or("invalid render buffer")?;
for (dst, src) in a.iter_mut().zip(b) {
let alpha = u32::from(src[3]);
for channel in 0..3 {
dst[channel] = ((u32::from(src[channel]) * alpha + u32::from(dst[channel]) * (255 - alpha) + 127) / 255) as u8;
}
dst[3] = 255;
}
}
Ok(())
}
// Minimal PDF 1.4: one JPEG XObject per page, explicit byte lengths and classic xref offsets.
struct Pdf {
bytes: Vec<u8>,
offsets: Vec<usize>,
points: (f64, f64),
}
impl Pdf {
fn new(pages: usize, points: (f64, f64)) -> Self {
let mut p = Self { bytes: b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n".to_vec(), offsets: vec![0], points };
p.object(1, b"<< /Type /Catalog /Pages 2 0 R >>");
let kids = (0..pages).map(|i| format!("{} 0 R", 3 + i * 3)).collect::<Vec<_>>().join(" ");
p.object(2, format!("<< /Type /Pages /Count {pages} /Kids [{kids}] >>").as_bytes());
p
}
fn object(&mut self, id: usize, body: &[u8]) {
self.offsets.push(self.bytes.len());
self.bytes.extend_from_slice(format!("{id} 0 obj\n").as_bytes());
self.bytes.extend_from_slice(body);
self.bytes.extend_from_slice(b"\nendobj\n");
}
fn stream(&mut self, id: usize, dictionary: &str, bytes: &[u8]) {
let mut body = format!("<< {dictionary} /Length {} >>\nstream\n", bytes.len()).into_bytes();
body.extend_from_slice(bytes);
body.extend_from_slice(b"\nendstream");
self.object(id, &body);
}
fn page(&mut self, index: usize, jpg: &[u8], width: usize, height: usize) {
let id = 3 + index * 3;
let (w, h) = self.points;
self.object(
id,
format!(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {w:.2} {h:.2}] /Resources << /XObject << /Im {} 0 R >> >> /Contents {} 0 R >>",
id + 2,
id + 1
)
.as_bytes(),
);
self.stream(id + 1, "", format!("q {w:.2} 0 0 {h:.2} 0 0 cm /Im Do Q").as_bytes());
self.stream(
id + 2,
&format!("/Type /XObject /Subtype /Image /Width {width} /Height {height} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode"),
jpg,
);
}
fn finish(mut self) -> Vec<u8> {
let start = self.bytes.len();
self.bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", self.offsets.len()).as_bytes());
for offset in self.offsets.iter().skip(1) {
self.bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
}
self.bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{start}\n%%EOF\n", self.offsets.len()).as_bytes());
self.bytes
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn validates_settings_and_selection() {
let mut s = Session::with_demo();
for p in [
json!({"columns": 0}),
json!({"rows": 11}),
json!({"paper": "poster"}),
json!({"landscape": "yes"}),
json!({"typo": 2}),
json!({"ids": ["bad"]}),
json!({"ids": []}),
] {
assert!(prepare(&mut s, &p).is_err(), "{p}");
}
let a = Options::default().layout().unwrap();
let b = Options { landscape: true, ..Default::default() }.layout().unwrap();
assert_eq!((a.width, a.height), (b.height, b.width));
let id = s.catalog.photos().next().unwrap().id.0;
assert!(prepare(&mut s, &json!({"ids": [id, u64::MAX]})).is_err());
assert!(prepare(&mut s, &json!({"ids": vec![id; 1001]})).is_err());
assert!(prepare(&mut s, &json!({"ids": vec![id; 101], "columns": 1, "rows": 1})).is_err());
}
#[test]
fn transparent_pixels_composite_over_white() {
let mut dst = Rgba8::filled(3, 1, [255; 4]);
let src = Rgba8 { width: 3, height: 1, data: vec![[0, 0, 0, 0], [255, 0, 0, 128], [10, 20, 30, 255]] };
blit(&mut dst, &src, 0, 0).unwrap();
assert_eq!(dst.data, vec![[255; 4], [255, 127, 127, 255], [10, 20, 30, 255]]);
assert!(blit(&mut dst, &src, usize::MAX, 0).is_err());
}
#[test]
fn unicode_captions_are_rasterized() {
assert_eq!(caption_text("very-long-éééé-filename.jpg", 140), "very-….jpg");
assert_eq!(caption_text("café\n.jpg", 300), "café .jpg");
let mut img = Rgba8::filled(360, 28, [255; 4]);
draw_watermark(
&mut img,
&Watermark {
text: "Été – Straße café.jpg".into(),
size: 0.5,
opacity: 1.0,
anchor: Anchor::Center,
inset: 0.0,
color: [32; 3],
shadow: false,
..Default::default()
},
);
assert!(img.data.iter().filter(|p| p[0] < 128).count() > 100);
let mut ascii = Rgba8::filled(360, 28, [255; 4]);
draw_watermark(
&mut ascii,
&Watermark {
text: "Ete - Strasse cafe.jpg".into(),
size: 0.5,
opacity: 1.0,
anchor: Anchor::Center,
inset: 0.0,
color: [32; 3],
shadow: false,
..Default::default()
},
);
assert_ne!(img, ascii);
}
#[test]
fn pagination_xref_and_cancellation() {
let mut s = Session::with_demo();
let ids: Vec<_> = s.catalog.photos().take(3).map(|p| p.id.0).collect();
let p = json!({"ids": ids, "columns": 1, "rows": 2, "captions": false});
let mut seen = Vec::new();
let doc = prepare(&mut s, &p)
.unwrap()
.run(&mut |i, _| {
seen.push(i);
true
})
.unwrap();
assert_eq!((doc.pages, doc.photos), (2, 3));
assert_eq!(seen, vec![0, 1, 2, 3]);
let text = String::from_utf8_lossy(&doc.bytes);
assert!(text.contains("/Count 2 /Kids [3 0 R 6 0 R]"));
let xref = text.rsplit("startxref\n").next().unwrap().lines().next().unwrap().parse::<usize>().unwrap();
assert!(doc.bytes[xref..].starts_with(b"xref\n0 9\n"));
let xref_text = std::str::from_utf8(&doc.bytes[xref..]).unwrap();
for (index, line) in xref_text.lines().skip(3).take(8).enumerate() {
let offset = line.split_whitespace().next().unwrap().parse::<usize>().unwrap();
assert!(doc.bytes[offset..].starts_with(format!("{} 0 obj\n", index + 1).as_bytes()));
}
assert!(prepare(&mut s, &p).unwrap().run(&mut |_, _| false).is_err());
}
fn first_page(bytes: &[u8]) -> Rgba8 {
let start = bytes.windows(2).position(|v| v == [0xff, 0xd8]).unwrap();
let end = bytes[start..].windows(2).position(|v| v == [0xff, 0xd9]).unwrap() + start + 2;
lightcraft_codecs::decode(&bytes[start..end], Default::default()).unwrap().to_srgb8()
}
#[test]
fn sheet_uses_current_edits_and_captions() {
let mut s = Session::with_demo();
let p = json!({"columns": 1, "rows": 2, "captions": false});
let plain = first_page(&prepare(&mut s, &p).unwrap().run(&mut |_, _| true).unwrap().bytes);
s.execute("develop.set", &json!({"control": "light.exposure", "value": 2.0})).unwrap();
let bright = first_page(&prepare(&mut s, &p).unwrap().run(&mut |_, _| true).unwrap().bytes);
let sum = |img: &Rgba8| img.data.iter().map(|p| u64::from(p[0]) + u64::from(p[1]) + u64::from(p[2])).sum::<u64>();
assert!(sum(&bright) > sum(&plain), "sheet ignored develop settings");
let captions = first_page(&prepare(&mut s, &json!({"columns": 1, "rows": 2})).unwrap().run(&mut |_, _| true).unwrap().bytes);
assert!(sum(&captions) < sum(&bright), "captions absent from PDF pixels");
assert_eq!((plain.width, plain.height), (1240, 1754));
}
}
+1
View File
@@ -19,6 +19,7 @@ mod camera_preview;
pub mod camera_profiles;
pub mod cmd;
pub mod config;
pub mod contact_sheet;
pub mod crs;
pub mod crs_masks;
pub mod demo;
+118
View File
@@ -63,6 +63,50 @@ pub fn start(app: &mut LightcraftApp, items: Vec<PreparedExport>, opts: ExportOp
Ok(json!({"background": true, "total": total}))
}
/// Export a single paginated PDF using the same progress and cancellation UI as image exports.
pub fn start_contact_sheet(app: &mut LightcraftApp, params: &Value) -> Result<Value, String> {
if app.export.is_some() {
return Err("an export is already running".into());
}
let path = params.get("path").and_then(Value::as_str).filter(|s| !s.trim().is_empty()).ok_or("missing path")?.to_string();
app.session.check_write_target(&path)?;
let guard = app.session.original_guard();
let prepared = lightcraft_engine::contact_sheet::prepare(&mut app.session, params)?;
let total = prepared.len();
let write = app.services.write_shared.clone();
let progress = Arc::new(Mutex::new((0, String::new())));
let cancel = Arc::new(AtomicBool::new(false));
let (p, c) = (progress.clone(), cancel.clone());
let (tx, rx) = channel();
let work = move || {
let result = prepared
.run(&mut |done, name| {
if let Ok(mut g) = p.lock() {
*g = (done, name.to_string());
}
!c.load(Ordering::Relaxed)
})
.and_then(|doc| {
if c.load(Ordering::Relaxed) {
return Err("Contact sheet cancelled".into());
}
guard.check(std::path::Path::new(&path))?;
match write {
Some(write) => write(&path, &doc.bytes)?,
None => lightcraft_engine::export::write_file(&path, &doc.bytes)?,
}
Ok(vec![json!({"path": path, "pages": doc.pages, "photos": doc.photos, "bytes": doc.bytes.len(), "contactSheet": true})])
});
let _ = tx.send(result);
};
#[cfg(not(target_arch = "wasm32"))]
std::thread::Builder::new().name("contact-sheet".into()).spawn(work).map_err(|e| e.to_string())?;
#[cfg(target_arch = "wasm32")]
work();
app.export = Some(ExportTask { total, progress, cancel, rx });
Ok(json!({"background": true, "total": total}))
}
/// Per frame: draw the progress panel; when the batch finishes, report it.
pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {
let Some(task) = &app.export else { return };
@@ -73,11 +117,15 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {
app.export = None;
let msg = match r {
Ok(files) => {
let sheet = files.first().filter(|f| f["contactSheet"] == true);
let ok = files.iter().filter(|f| f.get("path").is_some()).count();
let failed: Vec<&Value> = files.iter().filter(|f| f.get("error").is_some()).collect();
let skipped = files.iter().filter(|f| f.get("skipped").is_some()).count();
let mut m =
crate::i18n::tr_format!("Exported {ok} of {total} photo{}", if total == 1 { "" } else { "s" }, ok = ok, total = total);
if let Some(sheet) = sheet {
m = format!("Exported contact sheet: {} photos, {} pages", sheet["photos"], sheet["pages"]);
}
if skipped > 0 {
m += &crate::i18n::tr_format!(" · {skipped} skipped (file exists)", skipped = skipped);
}
@@ -127,3 +175,73 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {
}
}
}
#[cfg(test)]
mod contact_sheet_tests {
use super::*;
use crate::pick::{PickKind, PickRequest};
use crate::state::Dialog;
use std::time::{Duration, Instant};
#[test]
fn contact_sheet_menu_dialog_picker_and_background_export() {
let asked = Arc::new(Mutex::new(Vec::<PickRequest>::new()));
let requests = asked.clone();
let bytes = Arc::new(Mutex::new(Vec::new()));
let written = bytes.clone();
let services = crate::Services {
picker: Some(Box::new(move |req| {
requests.lock().unwrap().push(req);
let (tx, rx) = channel();
tx.send(vec!["Contact Sheet.pdf".into()]).unwrap();
Ok(rx)
})),
write_shared: Some(Arc::new(move |path, data| {
assert_eq!(path, "Contact Sheet.pdf");
*written.lock().unwrap() = data.to_vec();
Ok(())
})),
..Default::default()
};
let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services);
app.run("dialog.contactSheet", json!({})).unwrap();
let Some(Dialog::ContactSheet { options }) = app.ui.dialog.as_mut() else { panic!("no contact sheet settings") };
options.paper = "letter".into();
options.landscape = true;
let mut h = crate::headless::Headless::new(app, [1000.0, 800.0], 1.0);
let dialog = h.app.ui.dialog.take().unwrap();
crate::panels::dialogs::confirm_dialog(&mut h.app, &dialog).unwrap();
assert_eq!(h.app.pending_picks.len(), 1);
let deadline = Instant::now() + Duration::from_secs(20);
loop {
// The progress window lays out text, which egui only allows inside a frame.
h.step();
if h.app.last_export_result.is_some() {
break;
}
assert!(Instant::now() < deadline, "export did not finish");
std::thread::sleep(Duration::from_millis(10));
}
let req = asked.lock().unwrap();
assert_eq!(req[0].kind, PickKind::Save);
assert_eq!(req[0].filter.as_ref().unwrap().1, &["pdf"]);
assert_eq!(req[0].file_name.as_deref(), Some("Contact Sheet.pdf"));
let b = bytes.lock().unwrap();
assert!(b.starts_with(b"%PDF-1.4"));
assert!(String::from_utf8_lossy(&b).contains("/MediaBox [0 0 792.00 612.00]"));
assert_eq!(h.app.last_export_result.as_ref().unwrap()["files"][0]["photos"], 1);
}
#[test]
fn cancelled_contact_sheet_never_calls_writer() {
let mut app = LightcraftApp::new(
lightcraft_engine::Session::with_demo(),
crate::Services { write_shared: Some(Arc::new(|_, _| panic!("cancelled export wrote a file"))), ..Default::default() },
);
let ids: Vec<_> = app.session.catalog.photos().take(24).map(|p| p.id.0).collect();
start_contact_sheet(&mut app, &json!({"path": "Cancelled.pdf", "ids": ids})).unwrap();
app.export.as_ref().unwrap().cancel.store(true, Ordering::Relaxed);
let task = app.export.take().unwrap();
assert!(task.rx.recv_timeout(Duration::from_secs(20)).unwrap().is_err());
}
}
+24
View File
@@ -149,6 +149,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[
("dialog.pasteSettings", "Paste Selected Settings…", Some("Cmd+Shift+V"), "Edit"),
("view.focusSearch", "Find…", Some("Cmd+F"), "Edit"),
("dialog.export", "Export…", None, "File"),
("dialog.contactSheet", "Contact Sheet PDF…", None, "File"),
("app.contactSheet", "Export Contact Sheet PDF", None, ""),
("photo.editInExternal", "Edit in External Editor", Some("Cmd+Shift+E"), "Photo"),
("dialog.mergeHdr", "HDR…", Some("Ctrl+H"), "Photo>Photo Merge"),
("dialog.mergePanorama", "Panorama…", Some("Ctrl+M"), "Photo>Photo Merge"),
@@ -960,6 +962,27 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
app.ui.dialog = Some(Dialog::CopySettings { groups });
Ok(Value::Null)
}
"dialog.contactSheet" => {
app.ui.dialog = Some(Dialog::ContactSheet { options: lightcraft_engine::contact_sheet::Options::default() });
Ok(Value::Null)
}
"app.contactSheet" => {
if app.export.is_some() {
return Some(Err("an export is already running".into()));
}
let mut params = p.clone();
if p.get("path").is_none() {
let req = PickRequest::save("Contact Sheet PDF", "PDF", &["pdf"], "Contact Sheet.pdf");
let paths = match crate::pick::ask(app, id, p, "path", req, |_| None) {
Picked::Now(paths) => paths,
Picked::Later => return Some(Ok(Value::Null)),
Picked::Unavailable => return Some(Err("no save dialog on this platform; supply path".into())),
};
let Some(path) = paths.first() else { return Some(Ok(Value::Null)) };
params["path"] = json!(path);
}
crate::export_task::start_contact_sheet(app, &params)
}
"dialog.export" => {
let prev = app.session.last_export.clone().unwrap_or_default();
let u = |k: &str, d: u64| prev.get(k).and_then(Value::as_u64).unwrap_or(d);
@@ -1558,6 +1581,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool {
match id {
"dialog.contactSheet" | "app.contactSheet" => !cfg!(target_arch = "wasm32") && app.session.active().is_some() && app.export.is_none(),
s if s.starts_with("panel.") || s.starts_with("tool.") || s.starts_with("section.") => app.session.active().is_some() || s == "panel.close",
"app.export" | "dialog.export" | "dialog.createPreset" | "dialog.rename" | "dialog.captureTime" | "dialog.copySettings" => {
app.session.active().is_some()
+22
View File
@@ -108,6 +108,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
Dialog::CopySettings { .. } => "Choose Edit Settings to Copy",
Dialog::PasteSettings { .. } => "Paste Selected Settings",
Dialog::Export { .. } => "Export",
Dialog::ContactSheet { .. } => "Contact Sheet PDF",
Dialog::Merge { opts } => opts.title(),
Dialog::Settings { .. } => "Settings",
Dialog::ConfirmDelete { .. } => "Delete Photos",
@@ -476,6 +477,24 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
);
group_checklist(ui, "pasteGroup", groups);
}
Dialog::ContactSheet { options } => {
options.columns = options.columns.clamp(1, 8);
options.rows = options.rows.clamp(1, 10);
let n = app.session.targets(&json!({})).len();
ui.label(format!("{n} selected photos · {} pages", n.div_ceil(options.columns * options.rows)));
field(ui, "Paper", |ui| {
egui::ComboBox::from_id_salt("contactSheetPaper").selected_text(&options.paper).show_ui(ui, |ui| {
ui.selectable_value(&mut options.paper, "a4".into(), "A4");
ui.selectable_value(&mut options.paper, "letter".into(), "Letter");
});
});
ui.checkbox(&mut options.landscape, "Landscape");
field(ui, "Columns", |ui| { ui.add(egui::DragValue::new(&mut options.columns).range(1..=8)); });
field(ui, "Rows", |ui| { ui.add(egui::DragValue::new(&mut options.rows).range(1..=10)); });
ui.checkbox(&mut options.captions, "Filename captions");
ui.label("150 dpi · sRGB · current edits · fit without cropping");
ui.label("Choose a PDF destination after confirming. Print the PDF from your viewer.");
}
Dialog::Export { opts, full_size, resize, preset_name, limit_kb, dir } => {
use lightcraft_engine::export::{Anchor as P, ExportFormat as F, MetadataPolicy as M, SharpenAmount as A, SharpenFor as S};
let n = app.session.selection.ids.len().max(1);
@@ -947,6 +966,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
add_label = crate::i18n::tr_format!("{verb} {n} Photo{}", if n == 1 { "" } else { "s" }, verb = crate::i18n::tr(verb), n = n);
add_label.as_str()
}
Dialog::ContactSheet { .. } => "Choose PDF…",
Dialog::Merge { .. } => "Merge",
Dialog::DenoiseModel { info, .. } if info["download"].is_string() => "Accept & Download",
Dialog::DenoiseModel { .. } => "Install",
@@ -1010,6 +1030,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
dlg,
Dialog::Import { .. }
| Dialog::Export { .. }
| Dialog::ContactSheet { .. }
| Dialog::NewAlbum { .. }
| Dialog::NewSmartAlbum { .. }
| Dialog::SmartRules { .. }
@@ -1328,6 +1349,7 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_jso
"groups": groups,
}),
),
Dialog::ContactSheet { options } => app.run("app.contactSheet", json!(options)),
Dialog::CopySettings { groups } => app.run("develop.copy", json!({"groups": groups})),
Dialog::PasteSettings { groups } => app.run("develop.paste", json!({"groups": groups})),
Dialog::Export { opts, full_size, resize, limit_kb, dir, .. } => {
+3
View File
@@ -527,6 +527,9 @@ pub struct NameEdit {
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum Dialog {
ContactSheet {
options: lightcraft_engine::contact_sheet::Options,
},
/// `parent`: the folder to create it in (none: the top level).
NewAlbum {
name: String,
+6 -5
View File
@@ -83,7 +83,8 @@ Take the first one nobody is working on.
built-in list is empty, so today users need their own mirror or a manual install). Subject / Sky / People could
use the same model with fixed prompts; denoise / super-resolution models remain a maintainer decision.
7. **HDR** (Q. HDR, LR-EXP-HDR), **video** (R. Video), **Classic output modules** (Map view, Book, Slideshow module,
Print, publish): large, well understood, lower priority than 1–5.
Print, publish): large, well understood, lower priority than 1–5. Contact-sheet PDF export now covers a
basic Print workflow; custom picture packages, printer integration and print colour management remain open.
8. **LR-VIEW-ZOOM** (P0): pinch zoom and two-finger pan now work, with stable image cursors between gesture events.
The whole-image preview is still capped by
Settings → Performance → Preview size (2,560 px by default). Render visible regions at native resolution so
@@ -798,13 +799,13 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06-
| LRC-SS-MUSIC | Slideshow music | P2 | ⬜ | | |
| LRC-SS-PLAYBACK | Slideshow playback | P2 | ⬜ | | |
| LRC-SS-EXPORT | Slideshow export | P2 | ⬜ | | |
| LRC-PRINT-LAYOUTSTYLE | Print layout styles | P2 | ⬜ | | |
| LRC-PRINT-LAYOUTSTYLE | Print layout styles | P2 | 🟡 | `cmd:dialog.contactSheet`, `cmd:export.contactSheet`, `crates/engine/src/contact_sheet.rs` | paginated contact sheets; single-image/custom package layouts missing |
| LRC-PRINT-IMAGESETTINGS | Print image settings | P2 | ⬜ | | |
| LRC-PRINT-LAYOUT | Print layout | P2 | ⬜ | | |
| LRC-PRINT-LAYOUT | Print layout | P2 | 🟡 | `cmd:export.contactSheet`, `crates/ui-egui/src/panels/dialogs.rs` | A4/Letter, portrait/landscape, 1–8 columns and 1–10 rows; fixed margins and spacing |
| LRC-PRINT-GUIDES | Print guides | P2 | ⬜ | | |
| LRC-PRINT-CELLS | Picture-package cells | P2 | ⬜ | | |
| LRC-PRINT-PAGE | Print page options | P2 | ⬜ | | |
| LRC-PRINT-JOB | Print job & colour management | P2 | ⬜ | | |
| LRC-PRINT-PAGE | Print page options | P2 | 🟡 | `cmd:export.contactSheet`, `crates/engine/src/contact_sheet.rs` | optional filename captions; no page backgrounds/identity plates |
| LRC-PRINT-JOB | Print job & colour management | P2 | 🟡 | `cmd:export.contactSheet`, `cmd:app.contactSheet`, `crates/ui-egui/src/export_task.rs` | PDF export at fixed 150 dpi in sRGB, current edits, background progress/cancel and original protection; no native printer integration, ICC soft proof/print profiles or selectable resolution |
| LRC-PRINT-TEMPLATES | Print templates | P2 | ⬜ | | |
| LRC-WEB-LAYOUT | Web gallery layouts | OOS | 🚫 | | |
| LRC-WEB-SITEINFO | Web gallery site info | OOS | 🚫 | | |