diff --git a/apps/lightcraft-cli/src/main.rs b/apps/lightcraft-cli/src/main.rs index 01dae3055..c8371cbb9 100644 --- a/apps/lightcraft-cli/src/main.rs +++ b/apps/lightcraft-cli/src/main.rs @@ -732,12 +732,13 @@ fn render(args: &[String]) -> Result<(), String> { for (k, v) in opts { p[k] = v; } - let mut o = ExportOptions::from_params(&p).map_err(|e| e.to_string())?; if p.get("format").is_none() { let ext = Path::new(&output).extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default(); - o.format = ExportFormat::parse(&ext) - .ok_or_else(|| format!("{output}: unknown extension (use .jpg .png .tif .webp .avif .dng or --opt format=…)"))?; + ExportFormat::parse(&ext).ok_or_else(|| format!("{output}: unknown extension (use .jpg .png .tif .webp .avif .dng or --opt format=…)"))?; + // given before validating, so `bitDepth` is checked against the format written (issue #1952) + p["format"] = json!(ext); } + let o = ExportOptions::from_params(&p).map_err(|e| e.to_string())?; // Add to Apple Photos: refused before anything is written when it can't be done let after = s.after_export(&o)?; let e = export_photo(&mut s, lightcraft_engine::catalog::PhotoId(id), &o, 1)?; diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index e93d04175..949c08f1b 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -17,6 +17,12 @@ impl Session { /// `app.export` params with a named `preset` expanded: the preset's params, overridden by the /// call's own (`preset` itself removed). Unknown preset names are an error. pub fn export_params(&self, p: &Value) -> std::result::Result { + self.export_params_or(p, crate::export::ExportFormat::default()) + } + + /// [`Session::export_params`] for a caller that writes `fallback` to a `path` naming no format + /// (see [`crate::export::ExportOptions::export_format`]). + pub fn export_params_or(&self, p: &Value, fallback: crate::export::ExportFormat) -> std::result::Result { let Some(name) = p.get("preset").and_then(Value::as_str) else { return Ok(p.clone()) }; let (preset, _) = self .all_export_presets() @@ -24,12 +30,17 @@ impl Session { .find(|(x, _)| x.name.eq_ignore_ascii_case(name.trim())) .ok_or_else(|| format!("unknown export preset `{name}` (see export.presets)"))?; // a preset saved by another version may hold keys this one doesn't know: not the caller's typo - let mut out = crate::export::ExportOptions::known_keys_only(&preset.params); + let mut out = crate::export::ExportOptions::known_keys(&preset.params); if let (Some(o), Some(own)) = (out.as_object_mut(), p.as_object()) { for (k, v) in own.iter().filter(|(k, _)| *k != "preset") { o.insert(k.clone(), v.clone()); } } + // the preset's depth is judged against the format written, known only now (`path`); a + // depth the call passes itself is the caller's, and validated as such + if !p.get("bitDepth").is_some_and(|d| !d.is_null()) { + crate::export::ExportOptions::drop_unfit_depth(&mut out, fallback); + } Ok(out) } } @@ -50,13 +61,25 @@ fn save(s: &mut Session, p: &Value) -> Result { let mut params = match p.get("params") { Some(v @ Value::Object(_)) => v.clone(), Some(_) => return Err(bad(ID, "`params` must be an object of app.export params")), - None => s.last_export.clone().ok_or_else(|| bad(ID, "no `params` and no previous export to save"))?, + // a stored setting, not the caller's typo: a depth it holds that its `format` lacks was + // written before #1952 and is dropped, as Export with Previous does; without `format` + // it is judged when the preset exports + None => { + let last = s.last_export.as_ref().ok_or_else(|| bad(ID, "no `params` and no previous export to save"))?; + let mut last = crate::export::ExportOptions::known_keys(last); + if last.get("format").is_some_and(Value::is_string) { + crate::export::ExportOptions::drop_unfit_depth(&mut last, Default::default()); + } + last + } }; if let Some(o) = params.as_object_mut() { o.retain(|k, _| !NOT_SETTINGS.contains(&k.as_str())); } - // a preset that app.export would refuse is refused here, where the typo is on screen - crate::export::ExportOptions::validate(ID, ¶ms)?; + // a preset that app.export would refuse is refused here, where the typo is on screen; without + // `format`, its `bitDepth` is judged when it exports, against the format written then + let format = params.get("format").and_then(Value::as_str).map(|_| crate::export::ExportOptions::export_format(¶ms, Default::default())); + crate::export::ExportOptions::validate_as(ID, ¶ms, format)?; let preset = ExportPreset { name: name.to_string(), params }; match s.export_presets.iter_mut().find(|x| x.name.eq_ignore_ascii_case(name)) { Some(x) => *x = preset, diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index e679e817b..a9e3dd21b 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -816,8 +816,17 @@ impl ExportOptions { /// newer version may have written and this one doesn't know dropped, also inside `watermark`. /// [`ExportOptions::validate`] is strict about what a caller types; settings saved on disk are /// not the caller's typo, and refusing them would make a preset or Export with Previous fail - /// for good after an update. + /// for good after an update. A `bitDepth` the stored format doesn't have (written at the + /// format's own depth before issue #1952) is dropped too. pub fn known_keys_only(p: &serde_json::Value) -> serde_json::Value { + let mut p = Self::known_keys(p); + Self::drop_unfit_depth(&mut p, ExportFormat::default()); + p + } + + /// [`ExportOptions::known_keys_only`] keeping `bitDepth`, for a preset whose format may only be + /// known once the call's own params (`path`) are merged in. + pub(crate) fn known_keys(p: &serde_json::Value) -> serde_json::Value { let mut p = p.clone(); if let Some(o) = p.as_object_mut() { o.retain(|k, _| OPTION_PARAMS.contains(&k.as_str()) || TARGET_PARAMS.contains(&k.as_str())); @@ -828,13 +837,44 @@ impl ExportOptions { p } + /// Drop a stored `bitDepth` the format `p` exports ([`ExportOptions::export_format`] with + /// `fallback`) doesn't have: it was written at the format's own depth before issue #1952. + pub(crate) fn drop_unfit_depth(p: &mut serde_json::Value, fallback: ExportFormat) { + let format = Self::export_format(p, fallback); + if let Some(o) = p.as_object_mut() + && let Some(depth) = o.get("bitDepth").and_then(whole) + && !Self::bit_depths(format).iter().any(|d| i64::from(d.0) == depth) + { + o.remove("bitDepth"); + } + } + /// Strict [`ExportOptions::from_json`] for a command that is about to export (issue #181): an /// unknown parameter or a value that cannot be read as what it is for is an error, never a /// silent default. `app.export` targets (`id`, `ids`, `path`, `dir`, `preset`, `background`) /// are accepted and left to the caller. pub fn from_params(p: &serde_json::Value) -> crate::Result { - Self::validate("app.export", p)?; - Ok(Self::from_json(p)) + Self::from_params_or(p, ExportFormat::default()) + } + + /// [`ExportOptions::from_params`] for a caller that writes `fallback` to a `path` naming no + /// format; the format returned is [`ExportOptions::export_format`], the one written. + pub fn from_params_or(p: &serde_json::Value, fallback: ExportFormat) -> crate::Result { + let format = Self::export_format(p, fallback); + Self::validate_as("app.export", p, Some(format))?; + Ok(Self { format, ..Self::from_json(p) }) + } + + /// The format `app.export` writes: `format` (`null`: the default), else the one the extension of + /// `path` names, else `fallback` for a path that names none (the desktop app writes JPEG there, + /// the headless backend PNG), else the default. + pub fn export_format(p: &serde_json::Value, fallback: ExportFormat) -> ExportFormat { + use serde_json::Value; + match (p.get("format"), p.get("path").and_then(Value::as_str)) { + (Some(f), _) => f.as_str().and_then(ExportFormat::parse).unwrap_or_default(), + (None, Some(path)) => std::path::Path::new(path).extension().and_then(|e| e.to_str()).and_then(ExportFormat::parse).unwrap_or(fallback), + (None, None) => ExportFormat::default(), + } } /// Check `app.export` params for `cmd` (the command named in the error): every key must be an @@ -842,6 +882,12 @@ impl ExportOptions { /// kind of thing its key takes, and a `watermark` object must hold only [`WATERMARK_PARAMS`] /// with its sizes in range (issue #183). `null` means "not given", as a dropped key does. pub fn validate(cmd: &str, p: &serde_json::Value) -> crate::Result<()> { + Self::validate_as(cmd, p, Some(Self::export_format(p, ExportFormat::default()))) + } + + /// [`ExportOptions::validate`] with `bitDepth` checked against `format`, the one exported + /// (`None`: not known yet, as for a preset without `format`). + pub(crate) fn validate_as(cmd: &str, p: &serde_json::Value, format: Option) -> crate::Result<()> { use serde_json::Value; let bad = |msg: String| crate::cmd::bad(cmd, msg); let Some(obj) = p.as_object() else { return Err(bad("params must be an object".into())) }; @@ -949,6 +995,18 @@ impl ExportOptions { _ => return Err(bad(unknown_key("parameter", k, &known))), } } + // issue #1952: a depth the format doesn't have would be written at the format's own depth + if let (Some(format), Some(depth)) = (format, obj.get("bitDepth").and_then(whole)) { + let mut depths: Vec = Self::bit_depths(format).iter().map(|d| d.0).collect(); + if !depths.iter().any(|d| i64::from(*d) == depth) { + depths.sort_unstable(); + let mut list: Vec = depths.iter().map(u8::to_string).collect(); + let last = list.pop().unwrap_or_default(); + let list = if list.is_empty() { last } else { format!("{} or {last}", list.join(", ")) }; + let name = serde_json::to_value(format).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default(); + return Err(bad(format!("`bitDepth` must be {list} for {name}"))); + } + } Ok(()) } @@ -3222,6 +3280,7 @@ mod tests { resize: Some(Resize::default()), limit_kb: Some(500), watermark: Some(Watermark { text: "©".into(), ..Default::default() }), + format: ExportFormat::Png, bit_depth: Some(16), ..Default::default() }; @@ -3258,6 +3317,51 @@ mod tests { ExportOptions::from_params(&json!({"quality": 92, "longEdge": null, "watermark": null})).unwrap(); } + /// Issue #1952: a `bitDepth` the format doesn't have is refused (the Export dialog never offers + /// it), not written at the format's own depth; the error names the depths the format takes. + #[test] + fn bit_depth_must_suit_the_format() { + use serde_json::json; + let err = |p: serde_json::Value| ExportOptions::from_params(&p).err().map(|e| e.to_string()).unwrap_or_default(); + assert!(err(json!({"format": "jpeg", "bitDepth": 16})).contains("`bitDepth` must be 8 for jpeg")); + assert!(err(json!({"format": "png", "bitDepth": 10})).contains("`bitDepth` must be 8 or 16 for png")); + assert!(err(json!({"format": "tiff", "bitDepth": 10})).contains("`bitDepth` must be 8, 16 or 32 for tiff")); + // no `format`: the one `path` names, else the default (JPEG) + assert!(err(json!({"path": "/out/a.png", "bitDepth": 32})).contains("for png")); + assert!(err(json!({"bitDepth": 16})).contains("for jpeg")); + assert!(ExportOptions::from_params(&json!({"path": "/out/a.tif", "bitDepth": 32})).is_ok()); + // a path naming no format, or a `null` format, exports the default too + assert!(err(json!({"path": "/out/output", "bitDepth": 10})).contains("for jpeg")); + assert!(err(json!({"path": "/out/output.xyz", "bitDepth": 10})).contains("for jpeg")); + assert!(err(json!({"format": null, "path": "/out/a.png", "bitDepth": 16})).contains("for jpeg")); + // or the caller's own format for such a path, which is the one returned + let png = |p: serde_json::Value| ExportOptions::from_params_or(&p, ExportFormat::Png); + assert!(png(json!({"path": "/out/output", "bitDepth": 10})).unwrap_err().to_string().contains("for png")); + assert_eq!(png(json!({"path": "/out/output.xyz", "bitDepth": 16})).unwrap().format, ExportFormat::Png); + assert_eq!(png(json!({"path": "/out/a.tif", "bitDepth": 32})).unwrap().format, ExportFormat::Tiff); + // stored settings (a preset, Export with Previous) lose such a depth instead of failing + let stored = ExportOptions::known_keys_only(&json!({"format": "jpeg", "bitDepth": 16})); + assert_eq!(stored.get("bitDepth"), None); + assert_eq!(ExportOptions::known_keys_only(&json!({"format": "tiff", "bitDepth": 32}))["bitDepth"], 32); + for f in [ + ExportFormat::Jpeg, + ExportFormat::Png, + ExportFormat::Tiff, + ExportFormat::Psd, + ExportFormat::Webp, + ExportFormat::Avif, + ExportFormat::Jxl, + ExportFormat::Original, + ExportFormat::Dng, + ExportFormat::RenderedDng, + ] { + for &(b, _) in ExportOptions::bit_depths(f) { + let o = ExportOptions { format: f, bit_depth: Some(b), ..Default::default() }; + assert_eq!(ExportOptions::from_params(&o.to_json()).ok(), Some(o), "{f:?} {b}"); + } + } + } + /// Issue #956: `destinationMode` picks a specific folder (the default) or each photo's own /// folder; it is checked like the other params and kept in presets and Export with Previous. #[test] diff --git a/crates/engine/src/tests_export.rs b/crates/engine/src/tests_export.rs index 0ac8df804..9434ad03f 100644 --- a/crates/engine/src/tests_export.rs +++ b/crates/engine/src/tests_export.rs @@ -98,6 +98,43 @@ fn dng_export_rejects_non_raw_photos() { let _ = std::fs::remove_dir_all(&dir); } +/// Review of #1966: a preset without `format` keeps its `bitDepth` until the format written is +/// known. With `path: a.png` it writes the 16-bit PNG it asks for; a format without that depth +/// gets its own, as stored settings do, while a depth the call itself passes is still refused. +#[test] +fn a_preset_depth_is_judged_against_the_format_written() { + use crate::export::ExportPreset; + let mut s = Session::with_demo(); + let id = s.active().unwrap(); + s.export_presets.push(ExportPreset { name: "Deep".into(), params: json!({"bitDepth": 16, "longEdge": 64}) }); + let p = s.export_params(&json!({"preset": "deep", "path": "/out/a.png"})).unwrap(); + let o = ExportOptions::from_params(&p).unwrap(); + assert_eq!((o.format, o.bit_depth), (ExportFormat::Png, Some(16))); + assert_eq!(export_photo(&mut s, id, &o, 1).unwrap().bytes[24], 16, "a 16-bit PNG"); + let p = s.export_params(&json!({"preset": "deep", "path": "/out/a.jpg"})).unwrap(); + assert_eq!(ExportOptions::from_params(&p).unwrap().bit_depth, None); + let p = s.export_params(&json!({"preset": "deep", "path": "/out/a.jpg", "bitDepth": 16})).unwrap(); + assert!(ExportOptions::from_params(&p).unwrap_err().to_string().contains("`bitDepth` must be 8 for jpeg")); + // such a preset can be saved: its depth is judged when it exports; with a format, at once + s.execute("export.savePreset", &json!({"name": "Deep 2", "params": {"bitDepth": 16}})).unwrap(); + assert!(s.execute("export.savePreset", &json!({"name": "Deep 3", "params": {"format": "jpeg", "bitDepth": 16}})).is_err()); +} + +/// Review of #1966: savePreset without `params` saves the previous export, a stored setting. A +/// depth written before #1952 at the format's own depth is dropped, as Export with Previous does, +/// instead of refusing the save; without `format`, the depth waits for the format written. +#[test] +fn a_preset_saved_from_the_previous_export_drops_its_unfit_depth() { + let mut s = Session::with_demo(); + s.last_export = Some(json!({"format": "jpeg", "bitDepth": 16, "quality": 80, "dir": "/out"})); + s.execute("export.savePreset", &json!({"name": "Old"})).unwrap(); + assert_eq!(s.export_presets[0].params, json!({"format": "jpeg", "quality": 80})); + s.last_export = Some(json!({"bitDepth": 16, "dir": "/out"})); + s.execute("export.savePreset", &json!({"name": "Deep"})).unwrap(); + let p = s.export_params(&json!({"preset": "Deep", "path": "/out/a.png"})).unwrap(); + assert_eq!(ExportOptions::from_params(&p).unwrap().bit_depth, Some(16)); +} + #[test] fn presets_conflicts_and_subfolders() { use crate::export::{Destination, export_batch}; diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 7c9af704d..fcdfcd622 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -61,16 +61,13 @@ impl Headless { /// With `path` and no `format`, the format follows the path's extension. fn export(&mut self, p: &Value) -> Result { use lightcraft_engine::export::{Destination, ExportFormat, ExportOptions, Resize, prepare_batch, run_batch}; - let p = &self.session.export_params(p)?; - let mut opts = ExportOptions::from_params(p).map_err(|e| e.to_string())?; + let p = &self.session.export_params_or(p, ExportFormat::Png)?; + // PNG for a `path` that names no format; `bitDepth` is checked against the format written + let mut opts = ExportOptions::from_params_or(p, ExportFormat::Png).map_err(|e| e.to_string())?; if !ExportOptions::has_size_param(p) { opts.resize = Some(Resize::long_edge(3000)); } let exact = p.get("path").and_then(Value::as_str); - if let (Some(path), None) = (exact, p.get("format")) { - let ext = Path::new(path).extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default(); - opts.format = ExportFormat::parse(&ext).unwrap_or(ExportFormat::Png); - } 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(), // like the desktop app: the selection (in grid order), else the given / active photo diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 99193b480..15b32fa5c 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -147,6 +147,43 @@ fn export_refuses_unknown_params_and_bad_values() { assert!(e.contains("did you mean `quality`"), "{e}"); } +/// Review of #1966: a `path` whose extension names no format exports PNG through the headless +/// backend, so its `bitDepth` is checked against PNG's, not let through to an 8-bit file. +#[test] +fn export_checks_the_depth_of_the_format_a_bare_path_gets() { + let mut b = Headless::demo(); + let base = std::env::temp_dir().join(format!("lc-mcp-bare-path-depth-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&base).unwrap(); + for name in ["output", "output.xyz"] { + let e = b.call("app.export", json!({"path": base.join(name).to_string_lossy(), "longEdge": 32, "bitDepth": 10})).unwrap_err(); + assert!(e.contains("`bitDepth` must be 8 or 16 for png"), "{name}: {e}"); + } + assert_eq!(std::fs::read_dir(&base).unwrap().count(), 0, "nothing written"); + // the format such a path gets is still PNG, and 16 bits is one of its depths + b.call("app.export", json!({"path": base.join("output").to_string_lossy(), "longEdge": 32, "bitDepth": 16})).unwrap(); + let written = std::fs::read_dir(&base).unwrap().next().unwrap().unwrap().path(); + let bytes = std::fs::read(&written).unwrap(); + assert!(bytes.starts_with(b"\x89PNG"), "{written:?} is a PNG"); + assert_eq!(bytes[24], 16, "{written:?} is 16-bit"); + let _ = std::fs::remove_dir_all(&base); +} + +/// Review of #1966: a preset's `bitDepth` is judged against the format this backend writes, PNG +/// for a `path` that names none, so a 16-bit preset without `format` writes a 16-bit PNG. +#[test] +fn a_preset_depth_is_judged_against_the_format_written() { + let mut b = Headless::demo(); + let base = std::env::temp_dir().join(format!("lc-mcp-preset-depth-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&base).unwrap(); + b.session.export_presets.push(lightcraft_engine::export::ExportPreset { name: "Deep".into(), params: json!({"bitDepth": 16}) }); + b.call("app.export", json!({"preset": "Deep", "path": base.join("output").to_string_lossy(), "longEdge": 32})).unwrap(); + let bytes = std::fs::read(base.join("output")).unwrap(); + assert!(bytes.starts_with(b"\x89PNG") && bytes[24] == 16, "a 16-bit PNG: {:?}", bytes.get(..25)); + let _ = std::fs::remove_dir_all(&base); +} + /// Every option the `export` tool advertises reaches `app.export` (they were accepted, then dropped). #[test] fn export_tool_forwards_its_advertised_options() { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 097b13c4d..953874f6a 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -484,11 +484,8 @@ pub fn default_export_dir() -> String { pub fn export_active(app: &mut LightcraftApp, p: &Value) -> Result { use lightcraft_engine::export::{Destination, ExportOptions, export_batch}; let p = &app.session.export_params(p)?; - let mut opts = ExportOptions::from_params(p).map_err(|e| e.to_string())?; - if let (Some(path), None) = (p.get("path").and_then(Value::as_str), p.get("format")) { - let ext = path.rsplit_once('.').map_or("", |(_, e)| e); - opts.format = lightcraft_engine::export::ExportFormat::parse(ext).unwrap_or(opts.format); - } + // the format follows `path` (JPEG when it names none), and `bitDepth` is checked against it + let opts = ExportOptions::from_params(p).map_err(|e| e.to_string())?; 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() => { diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 1d8b58401..3643b4314 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -2191,6 +2191,54 @@ mod tests { assert!(rect[2].as_f64().unwrap() > 50.0 && rect[3].as_f64().unwrap() > 50.0, "{rect}"); } + /// Issue #1952: a previous export saved with a depth its format doesn't have (an API call made + /// before `app.export` refused it) opens at a depth the format offers, so the dialog's Export, + /// which has no depth to pick for JPEG, doesn't fail; a depth the format has is kept. + #[test] + fn export_dialog_drops_a_depth_the_format_lacks() { + let mut h = demo([1200.0, 900.0]); + let t = Duration::from_secs(10); + let opened = |h: &mut Headless, last: Value| { + h.app.ui.dialog = None; + h.app.session.last_export = Some(last); + h.request("engine.execute", json!({"command": "dialog.export", "params": {}}), t); + match &h.app.ui.dialog { + Some(crate::state::Dialog::Export { opts, .. }) => opts.clone(), + _ => panic!("the export dialog is open"), + } + }; + let o = opened(&mut h, json!({"format": "jpeg", "bitDepth": 16})); + assert_eq!(o.bit_depth, None); + lightcraft_engine::export::ExportOptions::from_params(&o.to_json()).expect("the dialog's settings export"); + assert_eq!(opened(&mut h, json!({"format": "tiff", "bitDepth": 32})).bit_depth, Some(32)); + } + + /// Review of #1966: a `path` whose extension names no format exports JPEG here, so its + /// `bitDepth` is checked against JPEG's, not let through to an 8-bit file. + #[test] + fn export_checks_the_depth_of_the_format_a_bare_path_gets() { + let mut h = demo([1200.0, 900.0]); + let t = Duration::from_secs(10); + let out = std::env::temp_dir().join(format!("lc-bare-path-depth-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&out); + std::fs::create_dir_all(&out).unwrap(); + h.app.services.write = Some(Box::new(|p: &str, bytes: &[u8]| lightcraft_engine::export::write_file(p, bytes))); + for name in ["output", "output.xyz"] { + let p = json!({"path": out.join(name).to_string_lossy(), "longEdge": 32, "bitDepth": 10}); + let r = h.request("engine.execute", json!({"command": "app.export", "params": p}), t); + assert_eq!(r["ok"], false, "{name}: {r}"); + assert!(r["error"].as_str().unwrap().contains("`bitDepth` must be 8 for jpeg"), "{name}: {r}"); + } + assert_eq!(std::fs::read_dir(&out).unwrap().count(), 0, "nothing written"); + // the format such a path gets is still JPEG + let p = json!({"path": out.join("output").to_string_lossy(), "longEdge": 32, "bitDepth": 8}); + let r = h.request("engine.execute", json!({"command": "app.export", "params": p}), t); + assert_eq!(r["ok"], true, "{r}"); + let written = std::fs::read_dir(&out).unwrap().next().unwrap().unwrap().path(); + assert!(std::fs::read(&written).unwrap().starts_with(&[0xFF, 0xD8]), "{written:?} is a JPEG"); + let _ = std::fs::remove_dir_all(&out); + } + #[test] fn export_dialog_runs_in_the_background() { let mut h = demo([1200.0, 900.0]); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 743bd4600..bb4d24f26 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1410,7 +1410,8 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option