mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 19:27:01 -04:00
commit
89753f5d67
11 files changed
+619
-7
No files matched your search
@@ -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
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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, ¶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);
|
||||
@@ -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()
|
||||
|
||||
@@ -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, .. } => {
|
||||
|
||||
@@ -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
@@ -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 | 🚫 | | |
|
||||
|
||||
Reference in new issue
Block a user