mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-26 11:09:23 -06:00
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:
co-authored by
Claude Opus 5.5
parent
59585102b1
commit
72ba795439
@@ -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()
|
||||
})),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
pub mod cmd;
|
||||
pub mod demo;
|
||||
pub mod export;
|
||||
pub mod files;
|
||||
pub mod media;
|
||||
pub mod presets;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" => {
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user