M10.2: export dialog + batch export (JPEG/PNG/TIFF/WebP/AVIF, size, file-size limit, output sharpening, naming, folder)

Export encoding lives in lightcraft-engine::export (pure bytes, shared by app/CLI/MCP/web).
Embeds our own sRGB ICC; writer creates the output folder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-09-30 23:37:24 -04:00
co-authored by Claude Opus 5.5
parent 59585102b1
commit 72ba795439
7 changed files with 401 additions and 25 deletions
+6 -1
View File
@@ -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()
})),
+286
View File
@@ -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<Self> {
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<u32>,
/// JPEG only: largest quality whose file fits in this many KB.
pub limit_kb: Option<u32>,
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<T: serde::de::DeserializeOwned>(p: &Value, k: &str) -> Option<T> {
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<Vec<u8>, 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<u8>,
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<Exported, String> {
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");
}
}
+1
View File
@@ -12,6 +12,7 @@
pub mod cmd;
pub mod demo;
pub mod export;
pub mod files;
pub mod media;
pub mod presets;
+36 -12
View File
@@ -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<Value, String> {
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 {
+2 -1
View File
@@ -275,7 +275,8 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
Ok(Value::Null)
}
"dialog.export" => {
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" => {
+48 -6
View File
@@ -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<serde_jso
app.run("preset.create", json!({"name": if name.is_empty() { "My Preset" } else { name }, "group": group}))
}
Dialog::CopySettings { groups } => 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),
}
}
+22 -5
View File
@@ -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<String> },
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<String>,
},
/// `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,
}