diff --git a/README.md b/README.md index bca63f8a..d58b4bd5 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/ROADMAP.md b/ROADMAP.md index 32f6a89b..22de8fec 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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-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 and German 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, diff --git a/apps/lightcraft-cli/tests/cli.rs b/apps/lightcraft-cli/tests/cli.rs index 2009e818..271a5aac 100644 --- a/apps/lightcraft-cli/tests/cli.rs +++ b/apps/lightcraft-cli/tests/cli.rs @@ -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(); diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index a8b25023..e08d0d17 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -80,6 +80,17 @@ fn delete(s: &mut Session, p: &Value) -> Result { pub fn specs() -> Vec { 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", diff --git a/crates/engine/src/contact_sheet.rs b/crates/engine/src/contact_sheet.rs new file mode 100644 index 00000000..663d6f82 --- /dev/null +++ b/crates/engine/src/contact_sheet.rs @@ -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 { + 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 { + 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, + pub pages: usize, + pub photos: usize, +} + +pub fn prepare(session: &mut Session, p: &Value) -> Result { + 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 { + 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::(), cells.iter().skip(cells.len() - right).copied().collect::()) +} + +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, + offsets: Vec, + 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::>().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 { + 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::().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::().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::(); + 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)); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a6c5f642..c1670bb4 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; diff --git a/crates/ui-egui/src/export_task.rs b/crates/ui-egui/src/export_task.rs index fee92150..e06f9cb5 100644 --- a/crates/ui-egui/src/export_task.rs +++ b/crates/ui-egui/src/export_task.rs @@ -62,6 +62,50 @@ pub fn start(app: &mut LightcraftApp, items: Vec, 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 { + 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 }; @@ -72,11 +116,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); } @@ -126,3 +174,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::::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()); + } +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 5b79548b..64bf99ec 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -145,6 +145,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"), @@ -928,6 +930,27 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + 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, ¶ms) + } "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); @@ -1451,6 +1474,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option 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() diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index fa2e6ad9..f5b46765 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -98,6 +98,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", @@ -474,6 +475,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); @@ -821,6 +840,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", @@ -870,6 +890,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { dlg, Dialog::Import { .. } | Dialog::Export { .. } + | Dialog::ContactSheet { .. } | Dialog::NewAlbum { .. } | Dialog::NewSmartAlbum { .. } | Dialog::SmartRules { .. } @@ -1100,6 +1121,7 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result 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, .. } => { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 83b3df29..9888b222 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -506,6 +506,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, diff --git a/docs/parity.md b/docs/parity.md index 4c27755b..ceae7e6e 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -47,10 +47,10 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | X. Cross-cutting behaviours (BEHAV) | 18 | 5 | 1 | 1 | 8/8 (100%) | 6/8 (75%) | | Y. Menus | 82 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 75 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) | -| Lightroom Classic extras | 32 | 11 | 39 | 9 | — | 22/24 (92%) | -| **Total** | 394 | 38 | 84 | 36 | 193/200 (97%) | 142/152 (93%) | +| Lightroom Classic extras | 32 | 15 | 35 | 9 | — | 22/24 (92%) | +| **Total** | 394 | 42 | 80 | 36 | 193/200 (97%) | 142/152 (93%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **80.0%** of 516 in-scope rows — P0 98.2% of 200 · P1 95.7% of 152 · P2 42.9% of 163. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **80.4%** of 516 in-scope rows — P0 98.2% of 200 · P1 95.7% of 152 · P2 44.2% of 163. ## Top gaps @@ -82,7 +82,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 @@ -796,13 +797,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 | 🚫 | | |