diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index c98ceaa69..6fbd93391 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -76,7 +76,12 @@ fn services() -> Services { .map(|p| p.to_string_lossy().to_string()) .collect() })), - write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))), + write: Some(Box::new(|p: &str, b: &[u8]| { + if let Some(dir) = std::path::Path::new(p).parent().filter(|d| !d.as_os_str().is_empty()) { + std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; + } + std::fs::write(p, b).map_err(|e| e.to_string()) + })), png: Some(Box::new(|img: &lightcraft_raster::Rgba8| { lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default() })), diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs new file mode 100644 index 000000000..fb9397390 --- /dev/null +++ b/crates/engine/src/export.rs @@ -0,0 +1,286 @@ +//! Export: encode a rendered image to JPEG / PNG / TIFF / WebP / AVIF with an embedded sRGB profile, +//! optional output sharpening and a JPEG file-size limit. Pure (bytes in, bytes out) so the desktop +//! app, CLI, MCP and the web build share it; writing the file is the caller's job. + +use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, NamedSpace, TiffCompression, encode, icc}; +use lightcraft_raster::Rgba8; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ExportFormat { + #[default] + Jpeg, + Png, + Tiff, + Webp, + Avif, +} + +impl ExportFormat { + pub fn parse(s: &str) -> Option { + Some(match s.to_ascii_lowercase().as_str() { + "jpeg" | "jpg" => Self::Jpeg, + "png" => Self::Png, + "tiff" | "tif" => Self::Tiff, + "webp" => Self::Webp, + "avif" => Self::Avif, + _ => return None, + }) + } + pub fn extension(self) -> &'static str { + match self { + Self::Jpeg => "jpg", + Self::Png => "png", + Self::Tiff => "tif", + Self::Webp => "webp", + Self::Avif => "avif", + } + } +} + +/// Output sharpening target (applied after resizing, in display-encoded values). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SharpenFor { + #[default] + None, + Screen, + Matte, + Glossy, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SharpenAmount { + Low, + #[default] + Standard, + High, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ExportOptions { + pub format: ExportFormat, + /// 1–100 (JPEG, AVIF). + pub quality: u8, + /// Resize so the long edge is at most this many pixels (`None` = full size). + pub long_edge: Option, + /// JPEG only: largest quality whose file fits in this many KB. + pub limit_kb: Option, + pub sharpen: SharpenFor, + pub sharpen_amount: SharpenAmount, + /// File name template: `{name}` (original stem), `{seq}` (1-based, zero-padded to 3), `{ext}` is appended. + pub naming: String, +} + +impl Default for ExportOptions { + fn default() -> Self { + Self { + format: ExportFormat::Jpeg, + quality: 90, + long_edge: None, + limit_kb: None, + sharpen: SharpenFor::None, + sharpen_amount: SharpenAmount::Standard, + naming: "{name}".into(), + } + } +} + +impl ExportOptions { + /// Read options from command params (`format`, `quality`, `longEdge`, `limitKb`, `sharpen`, `sharpenAmount`, `naming`). + pub fn from_json(p: &serde_json::Value) -> Self { + use serde_json::Value; + let d = Self::default(); + let s = |k: &str| p.get(k).and_then(Value::as_str); + let u = |k: &str| p.get(k).and_then(Value::as_u64); + fn enm(p: &Value, k: &str) -> Option { + p.get(k).cloned().and_then(|v| serde_json::from_value(v).ok()) + } + Self { + format: s("format").and_then(ExportFormat::parse).unwrap_or(d.format), + quality: u("quality").map_or(d.quality, |q| q.clamp(1, 100) as u8), + long_edge: u("longEdge").filter(|v| *v > 0).map(|v| v.min(65_535) as u32), + limit_kb: u("limitKb").filter(|v| *v > 0).map(|v| v.min(u32::MAX as u64) as u32), + sharpen: enm(p, "sharpen").unwrap_or(d.sharpen), + sharpen_amount: enm(p, "sharpenAmount").unwrap_or(d.sharpen_amount), + naming: s("naming").map_or(d.naming, str::to_string), + } + } + + /// Output file name for photo `stem` at 1-based position `seq` in a batch. + pub fn file_name(&self, stem: &str, seq: usize) -> String { + let base = if self.naming.trim().is_empty() { "{name}" } else { self.naming.as_str() }; + let name = base.replace("{name}", stem).replace("{seq}", &format!("{seq:03}")); + let name: String = name.chars().map(|c| if matches!(c, '/' | '\\' | ':' | '\0') { '_' } else { c }).collect(); + format!("{name}.{}", self.format.extension()) + } +} + +/// Unsharp mask on an 8-bit image (separable box-blur approximation of a small Gaussian). +pub fn output_sharpen(img: &mut Rgba8, target: SharpenFor, amount: SharpenAmount) { + let (radius, base) = match target { + SharpenFor::None => return, + SharpenFor::Screen => (1usize, 0.35f32), + SharpenFor::Matte => (2, 0.6), + SharpenFor::Glossy => (1, 0.5), + }; + let k = base + * match amount { + SharpenAmount::Low => 0.6, + SharpenAmount::Standard => 1.0, + SharpenAmount::High => 1.5, + }; + let (w, h) = (img.width, img.height); + if w < 3 || h < 3 { + return; + } + let src: Vec<[f32; 3]> = img.data.iter().map(|p| [p[0] as f32, p[1] as f32, p[2] as f32]).collect(); + let r = radius as isize; + let box_pass = |inp: &[[f32; 3]], horizontal: bool| -> Vec<[f32; 3]> { + let mut out = vec![[0.0; 3]; inp.len()]; + for y in 0..h { + for x in 0..w { + let mut acc = [0.0f32; 3]; + for d in -r..=r { + let (sx, sy) = if horizontal { + ((x as isize + d).clamp(0, w as isize - 1) as usize, y) + } else { + (x, (y as isize + d).clamp(0, h as isize - 1) as usize) + }; + let p = inp[sy * w + sx]; + acc = [acc[0] + p[0], acc[1] + p[1], acc[2] + p[2]]; + } + let n = (2 * r + 1) as f32; + out[y * w + x] = acc.map(|v| v / n); + } + } + out + }; + let blur = box_pass(&box_pass(&src, true), false); + for (i, p) in img.data.iter_mut().enumerate() { + for c in 0..3 { + let v = src[i][c] + (src[i][c] - blur[i][c]) * k; + p[c] = v.round().clamp(0.0, 255.0) as u8; + } + } +} + +/// Encode a rendered (display-referred sRGB) image according to `o`. Resizing to `long_edge` is the +/// caller's job (render at that size); sharpening is applied here. +pub fn encode_image(img: &Rgba8, o: &ExportOptions) -> Result, String> { + let mut img = img.clone(); + output_sharpen(&mut img, o.sharpen, o.sharpen_amount); + let profile = icc::write_named(NamedSpace::Srgb); + let meta = EncodeMeta { icc: Some(&profile), ..Default::default() }; + let e = EncodeImage::rgba8(&img); + let r = match o.format { + ExportFormat::Jpeg => { + // 4:4:4 normally; 4:2:0 when targeting a file size (much smaller at equal visual quality). + let sub = if o.limit_kb.is_some() { ChromaSubsampling::S420 } else { ChromaSubsampling::S444 }; + let jpeg = |q: u8| encode::encode_jpeg(&e, q, sub, &meta); + match o.limit_kb { + Some(kb) => { + let limit = kb as usize * 1024; + // Binary search for the highest quality that fits. + let (mut lo, mut hi) = (1u8, o.quality); + let mut best = None; + while lo <= hi { + let mid = lo + (hi - lo) / 2; + let b = jpeg(mid).map_err(|e| e.to_string())?; + if b.len() <= limit { + best = Some(b); + lo = mid + 1; + } else if mid == 1 { + break; + } else { + hi = mid - 1; + } + } + return best.ok_or_else(|| format!("cannot fit within {kb} KB")); + } + None => jpeg(o.quality), + } + } + ExportFormat::Png => encode::encode_png(&e, &meta), + ExportFormat::Tiff => encode::encode_tiff(&e, TiffCompression::Deflate, &meta), + ExportFormat::Webp => encode::encode_webp_lossless(&e, &meta), + ExportFormat::Avif => encode::encode_avif(&e, o.quality, 6, &meta), + }; + r.map_err(|e| e.to_string()) +} + +/// One exported file. +pub struct Exported { + pub file_name: String, + pub bytes: Vec, + pub width: usize, + pub height: usize, +} + +/// Render photo `id` at the requested size (full size when `long_edge` is `None`) and encode it. +pub fn export_photo(session: &mut crate::Session, id: lightcraft_catalog::PhotoId, o: &ExportOptions, seq: usize) -> Result { + let p = session.catalog.photo(id).ok_or("no such photo")?; + let stem = p.file_name.rsplit_once('.').map_or(p.file_name.as_str(), |(a, _)| a).to_string(); + let full = p.width.max(p.height).max(1) as usize; + let size = o.long_edge.map_or(full, |l| l as usize); + let r = session.render_now(id, size, size)?; + let bytes = encode_image(&r.image, o)?; + Ok(Exported { file_name: o.file_name(&stem, seq), bytes, width: r.image.width, height: r.image.height }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_image() -> Rgba8 { + let mut img = Rgba8::new(96, 64); + for (i, p) in img.data.iter_mut().enumerate() { + let (x, y) = (i % 96, i / 96); + *p = [(x * 2) as u8, (y * 3) as u8, ((x * y) % 255) as u8, 255]; + } + img + } + + #[test] + fn formats_have_magic() { + let img = test_image(); + for (f, magic) in + [(ExportFormat::Jpeg, &b"\xFF\xD8"[..]), (ExportFormat::Png, b"\x89PNG"), (ExportFormat::Tiff, b"II"), (ExportFormat::Webp, b"RIFF")] + { + let b = encode_image(&img, &ExportOptions { format: f, ..Default::default() }).unwrap(); + assert!(b.starts_with(magic), "{f:?}"); + } + } + + #[test] + fn size_limit_respected() { + let img = test_image(); + let full = encode_image(&img, &ExportOptions { quality: 100, ..Default::default() }).unwrap(); + let kb = (full.len() / 1024 / 2).max(2) as u32; + let b = encode_image(&img, &ExportOptions { quality: 100, limit_kb: Some(kb), ..Default::default() }).unwrap(); + assert!(b.len() <= kb as usize * 1024); + } + + #[test] + fn sharpen_increases_edge_contrast() { + let mut img = Rgba8::new(8, 8); + for (i, p) in img.data.iter_mut().enumerate() { + let v = if i % 8 < 4 { 80 } else { 170 }; + *p = [v, v, v, 255]; + } + output_sharpen(&mut img, SharpenFor::Matte, SharpenAmount::High); + assert!(img.data[3][0] < 80 && img.data[4][0] > 170); + } + + #[test] + fn naming_and_params() { + let o = ExportOptions::from_json(&serde_json::json!({"format": "jpg", "quality": 150, "longEdge": 2048, "naming": "{name}-{seq}"})); + assert_eq!(o.format, ExportFormat::Jpeg); + assert_eq!(o.quality, 100); + assert_eq!(o.long_edge, Some(2048)); + assert_eq!(o.file_name("IMG/1", 7), "IMG_1-007.jpg"); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 13ff7733c..d740ac29d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -12,6 +12,7 @@ pub mod cmd; pub mod demo; +pub mod export; pub mod files; pub mod media; pub mod presets; diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index b74947da8..6e10b8a04 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -289,19 +289,43 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest } } -/// Export the active photo (UI command `app.export`): renders at `longEdge` and writes a PNG/JPEG via services. +/// Default export folder: `~/Pictures/LightCraft Exports` (falls back to the working directory). +pub fn default_export_dir() -> String { + std::env::var("HOME").map(|h| format!("{h}/Pictures/LightCraft Exports")).unwrap_or_default() +} + +/// Export the selected photos (UI command `app.export`). Params: see +/// [`lightcraft_engine::export::ExportOptions::from_json`], plus `dir` (output folder) or `path` +/// (exact output file, single photo), `ids` (default: the selection, else the active photo). pub fn export_active(app: &mut LightcraftApp, p: &Value) -> Result { - let id = app.session.active().ok_or("no photo selected")?; - let size = p.get("longEdge").and_then(Value::as_u64).unwrap_or(3000) as usize; - let r = app.session.render_now(id, size, size)?; - let name = app.session.catalog.photo(id).map(|p| p.file_name.clone()).unwrap_or_else(|| "export".into()); - let stem = name.rsplit_once('.').map(|(a, _)| a.to_string()).unwrap_or(name); - let path = p.get("path").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| format!("{stem}-lightcraft.png")); - let png = app.services.png.as_ref().ok_or("no encoder")?; - let bytes = png(&r.image); - let w = app.services.write.as_mut().ok_or("no writer")?; - w(&path, &bytes)?; - Ok(json!({"path": path, "width": r.image.width, "height": r.image.height})) + use lightcraft_engine::export::{ExportOptions, export_photo}; + let opts = ExportOptions::from_json(p); + let ids: Vec<_> = match p.get("ids").and_then(Value::as_array) { + Some(a) => a.iter().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect(), + None if !app.session.selection.ids.is_empty() => { + // Batch order (and `{seq}`) follows the grid order. + let sel: std::collections::HashSet<_> = app.session.selection.ids.iter().copied().collect(); + let mut v: Vec<_> = app.session.visible().iter().copied().filter(|id| sel.contains(id)).collect(); + let shown: std::collections::HashSet<_> = v.iter().copied().collect(); + v.extend(app.session.selection.ids.iter().filter(|id| !shown.contains(id))); + v + } + None => app.session.active().into_iter().collect(), + }; + if ids.is_empty() { + return Err("no photo selected".into()); + } + let dir = p.get("dir").and_then(Value::as_str).map(str::to_string).filter(|d| !d.is_empty()).unwrap_or_else(default_export_dir); + let exact = p.get("path").and_then(Value::as_str).filter(|_| ids.len() == 1).map(str::to_string); + let mut out = Vec::new(); + for (i, id) in ids.into_iter().enumerate() { + let e = export_photo(&mut app.session, id, &opts, i + 1)?; + let path = exact.clone().unwrap_or_else(|| if dir.is_empty() { e.file_name.clone() } else { format!("{dir}/{}", e.file_name) }); + let w = app.services.write.as_mut().ok_or("no writer")?; + w(&path, &e.bytes)?; + out.push(json!({"path": path, "width": e.width, "height": e.height, "bytes": e.bytes.len()})); + } + Ok(json!({"files": out})) } pub fn save_screenshot(app: &mut LightcraftApp, image: &egui::ColorImage, path: Option<&str>) -> Value { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 042f4eab2..5b760cd0e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -275,7 +275,8 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - app.ui.dialog = Some(Dialog::Export { format: "jpeg".into(), quality: 90, long_edge: 3000 }); + app.ui.dialog = + Some(Dialog::Export { opts: Default::default(), long_edge: 2048, limit_kb: 0, dir: crate::control::default_export_dir() }); Ok(Value::Null) } "app.about" => { diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index c27aef66b..4c16dc90a 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -56,15 +56,51 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } }); } - Dialog::Export { format, quality, long_edge } => { + Dialog::Export { opts, long_edge, limit_kb, dir } => { + use lightcraft_engine::export::{ExportFormat as F, SharpenAmount as A, SharpenFor as S}; + let n = app.session.selection.ids.len().max(1); + ui.label(egui::RichText::new(format!("{n} photo{}", if n == 1 { "" } else { "s" })).color(t.text_dim)); ui.horizontal(|ui| { ui.label("Format"); - for f in ["jpeg", "png", "tiff"] { - ui.selectable_value(format, f.to_string(), f.to_uppercase()); + for (f, l) in [(F::Jpeg, "JPEG"), (F::Png, "PNG"), (F::Tiff, "TIFF"), (F::Webp, "WebP"), (F::Avif, "AVIF")] { + ui.selectable_value(&mut opts.format, f, l); } }); - ui.add(egui::Slider::new(quality, 10..=100).text("Quality")); - ui.add(egui::Slider::new(long_edge, 512..=8000).text("Long edge (px)")); + if matches!(opts.format, F::Jpeg | F::Avif) { + ui.add(egui::Slider::new(&mut opts.quality, 1..=100).text("Quality")); + } + if opts.format == F::Jpeg { + ui.add(egui::Slider::new(limit_kb, 0..=20_000).text("Limit file size (KB, 0 = off)")); + } + let mut full = *long_edge == 0; + if ui.checkbox(&mut full, "Full size").changed() { + *long_edge = if full { 0 } else { 2048 }; + } + if !full { + ui.add(egui::Slider::new(long_edge, 256..=12_000).text("Long edge (px)")); + } + ui.horizontal(|ui| { + ui.label("Sharpen for"); + for (v, l) in [(S::None, "None"), (S::Screen, "Screen"), (S::Matte, "Matte paper"), (S::Glossy, "Glossy paper")] { + ui.selectable_value(&mut opts.sharpen, v, l); + } + }); + if opts.sharpen != S::None { + ui.horizontal(|ui| { + ui.label("Amount"); + for (v, l) in [(A::Low, "Low"), (A::Standard, "Standard"), (A::High, "High")] { + ui.selectable_value(&mut opts.sharpen_amount, v, l); + } + }); + } + ui.horizontal(|ui| { + ui.label("File name"); + ui.add(egui::TextEdit::singleline(&mut opts.naming).hint_text("{name}-{seq}").desired_width(f32::INFINITY)); + }); + ui.horizontal(|ui| { + ui.label("Folder"); + ui.add(egui::TextEdit::singleline(dir).desired_width(f32::INFINITY)); + }); } Dialog::About => { ui.label(egui::RichText::new("LightCraft").font(t.semibold(20.0)).color(t.text)); @@ -129,7 +165,13 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result app.run("develop.copy", json!({"groups": groups})), - Dialog::Export { format, quality, long_edge } => app.run("app.export", json!({"format": format, "quality": quality, "longEdge": long_edge})), + Dialog::Export { opts, long_edge, limit_kb, dir } => app.run( + "app.export", + json!({ + "format": opts.format, "quality": opts.quality, "longEdge": long_edge, "limitKb": limit_kb, + "sharpen": opts.sharpen, "sharpenAmount": opts.sharpen_amount, "naming": opts.naming, "dir": dir, + }), + ), Dialog::About | Dialog::Shortcuts => Ok(serde_json::Value::Null), } } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index c1b078083..34b3b15a7 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -119,11 +119,28 @@ pub struct UiState { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(tag = "kind", rename_all = "camelCase")] pub enum Dialog { - NewAlbum { name: String, folder: bool }, - RenameAlbum { id: u64, name: String }, - CreatePreset { name: String, group: String }, - CopySettings { groups: Vec }, - Export { format: String, quality: u32, long_edge: u32 }, + NewAlbum { + name: String, + folder: bool, + }, + RenameAlbum { + id: u64, + name: String, + }, + CreatePreset { + name: String, + group: String, + }, + CopySettings { + groups: Vec, + }, + /// `long_edge` 0 = full size; `limit_kb` 0 = no limit; `dir` empty = default export folder. + Export { + opts: lightcraft_engine::export::ExportOptions, + long_edge: u32, + limit_kb: u32, + dir: String, + }, About, Shortcuts, }