diff --git a/Cargo.lock b/Cargo.lock index 6ee0870e96..ba0e6baea0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4717,6 +4717,7 @@ dependencies = [ "photocraft-engine", "photocraft-format", "photocraft-io", + "photocraft-raw", "rmcp", "rustix 1.1.5", "schemars", @@ -4735,6 +4736,7 @@ dependencies = [ "photocraft-engine", "photocraft-format", "photocraft-io", + "photocraft-raw", "serde_json", "tokio", "winresource", diff --git a/apps/photocraft-cli/Cargo.toml b/apps/photocraft-cli/Cargo.toml index f4848f379a..70f15d5be1 100644 --- a/apps/photocraft-cli/Cargo.toml +++ b/apps/photocraft-cli/Cargo.toml @@ -31,6 +31,10 @@ photocraft-codecs = { workspace = true } serde_json = { workspace = true } tokio = { version = "1", features = ["rt-multi-thread", "macros"] } +[dev-dependencies] +# Synthetic camera raws (#7789). +photocraft-raw = { workspace = true, features = ["testgen"] } + [build-dependencies] # Icon + VERSIONINFO for photocraft-cli.exe; build.rs only uses it when targeting Windows. winresource = "0.1" diff --git a/apps/photocraft-cli/src/lib.rs b/apps/photocraft-cli/src/lib.rs index 656eb74dbe..c2497e1bf1 100644 --- a/apps/photocraft-cli/src/lib.rs +++ b/apps/photocraft-cli/src/lib.rs @@ -312,7 +312,9 @@ fn convert(a: &Args, _out: &mut dyn Write, err: &mut dyn Write) -> R { }; let opts = export_opts(a)?; refuse_overwriting_input(a, input, output)?; - let o = files::open(Path::new(input)).map_err(|e| e.to_string())?; + let mut o = files::open(Path::new(input)).map_err(|e| e.to_string())?; + // Preferences › Raw Defaults › Resolution at its default, as `run` and `info` open a raw. + photocraft_engine::file_cmds::raw_defaults(&photocraft_engine::Session::new(), &o.warnings, &mut o.document); warn_all(err, &o.warnings); warn_all(err, &photocraft_engine::type_extra_cmds::missing_font_warnings(&o.document)); let ws = files::save(&o.document, Path::new(output), a.get("--format"), &opts, None).map_err(|e| e.to_string())?; diff --git a/apps/photocraft-cli/tests/cli.rs b/apps/photocraft-cli/tests/cli.rs index 2b95aa0f83..c31239d14f 100644 --- a/apps/photocraft-cli/tests/cli.rs +++ b/apps/photocraft-cli/tests/cli.rs @@ -977,3 +977,18 @@ fn run_pairs_params_with_the_preceding_cmd() { assert_ne!(code, 0); assert!(err.contains("--params"), "{err}"); } + +/// #7789: `convert` opens a developed raw at Raw Defaults › Resolution (default 300 ppi), as +/// `run`, `info` and the app do; it wrote 72. +#[test] +fn convert_opens_a_raw_at_the_raw_defaults_resolution() { + use photocraft_raw::testgen::{DngSpec, mosaic, scene}; + let d = tmp("convert-raw"); + let (w, h) = (24, 16); + let (raw, psd) = (d.join("IMG_0001.dng"), d.join("IMG_0001.psd")); + std::fs::write(&raw, DngSpec::cfa(w, h, mosaic(&scene(w, h), w, [0, 1, 1, 2], 0, 65535)).build()).unwrap(); + ok(bin().args(["convert"]).arg(&raw).arg(&psd)); + let doc = photocraft_io::import("IMG_0001.psd", &std::fs::read(&psd).unwrap()).unwrap().document; + assert_eq!(doc.resolution_dpi, 300.0); + std::fs::remove_dir_all(d).unwrap(); +} diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml index 3835c932d8..ae1837cdd3 100644 --- a/crates/automation/Cargo.toml +++ b/crates/automation/Cargo.toml @@ -27,6 +27,8 @@ getrandom = { workspace = true } cap-std = "4" [dev-dependencies] +# Synthetic camera raws (#7789). +photocraft-raw = { workspace = true, features = ["testgen"] } rmcp = { version = "3.5", default-features = false, features = ["client", "server", "macros", "transport-io"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std", "sync", "net", "io-util", "time"] } diff --git a/crates/automation/src/headless.rs b/crates/automation/src/headless.rs index cad8d233fa..1e3e83e1bf 100644 --- a/crates/automation/src/headless.rs +++ b/crates/automation/src/headless.rs @@ -100,6 +100,8 @@ impl Headless { files::open_bytes(name, &bytes)? } }; + // Preferences › Raw Defaults › Resolution, as the app's File › Open applies it. + file_cmds::raw_defaults(&self.session, &o.warnings, &mut o.document); let path = match file_cmds::template_name(&self.session, requested) { Some(untitled) => { o.document.name = untitled; diff --git a/crates/automation/tests/raw_defaults.rs b/crates/automation/tests/raw_defaults.rs new file mode 100644 index 0000000000..0f12e06c5e --- /dev/null +++ b/crates/automation/tests/raw_defaults.rs @@ -0,0 +1,20 @@ +//! #7789: the headless (MCP) `doc.open` opens a developed raw at Preferences › Raw Defaults › +//! Resolution, as the app's File › Open does. + +use photocraft_automation::Headless; +use photocraft_raw::testgen::{DngSpec, mosaic, scene}; +use serde_json::json; + +#[test] +fn doc_open_gives_a_raw_the_raw_defaults_resolution() { + let dir = std::env::temp_dir().join(format!("pc-raw-defaults-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let (w, h) = (24, 16); + let path = dir.join("IMG_0001.dng"); + std::fs::write(&path, DngSpec::cfa(w, h, mosaic(&scene(w, h), w, [0, 1, 1, 2], 0, 65535)).build()).unwrap(); + let mut headless = Headless::trusted_local(); + headless.handle("engine.execute", json!({"command": "prefs.set", "params": {"path": "rawDefaults.resolution", "value": 240}})).unwrap(); + headless.handle("doc.open", json!({"path": path.to_str().unwrap()})).unwrap(); + assert_eq!(headless.session.active().unwrap().doc.resolution_dpi, 240.0); + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index e3f6e815e0..b2340dfa6d 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -308,6 +308,24 @@ pub(crate) fn is_windows_device_name(component: &str) -> bool { } pub(crate) fn import(name: &str, bytes: &[u8]) -> Result { + import_result(name, bytes).map(|r| r.document) +} + +/// [`import`] for a file that becomes a document of its own (Revert, Load Files into Stack; +/// Batch's scratch sessions use [`import_at_raw_ppi`]): a developed raw gets Preferences › Raw +/// Defaults › Resolution, as it does through File › Open. +pub(crate) fn import_opened(s: &Session, name: &str, bytes: &[u8]) -> Result { + import_at_raw_ppi(s.prefs().raw_defaults.resolution, name, bytes) +} + +fn import_at_raw_ppi(raw_ppi: f64, name: &str, bytes: &[u8]) -> Result { + let r = import_result(name, bytes)?; + let mut doc = r.document; + raw_resolution(raw_ppi, &r.warnings, &mut doc); + Ok(doc) +} + +fn import_result(name: &str, bytes: &[u8]) -> Result { crate::allocation::checkpoint("importing document")?; let r = photocraft_io::import(name, bytes).map_err(|e| EngineError::Other(format!("{name}: {e}")))?; // Auxiliary imports return only a document and cannot surface the preview's fidelity warning. @@ -317,7 +335,7 @@ pub(crate) fn import(name: &str, bytes: &[u8]) -> Result { "{name}: only this Affinity file's embedded preview could be read; open it with File › Open to see the warning, or export PSD or PNG from Affinity before using it here" ))); } - Ok(r.document) + Ok(r) } /// What a headless save writes beyond the format: JPEG quality and format, TIFF layers, Targa @@ -586,7 +604,7 @@ fn close_others(s: &mut Session, p: &Value) -> Result { fn revert(s: &mut Session) -> Result { let path = s.active().and_then(|d| d.path.clone()).ok_or(EngineError::Other("the document has never been saved".into()))?; let bytes = read_file(&path)?; - let fresh = import(&file_name(&path), &bytes)?; + let fresh = import_opened(s, &file_name(&path), &bytes)?; s.edit("Revert", |doc, active| { let (id, name) = (doc.id, doc.name.clone()); *doc = fresh; @@ -643,7 +661,10 @@ pub(crate) fn tag_exr_colorspace(s: &Session, name: &str, doc: &mut Document) { /// Camera Raw's workflow options set it (the raw itself states none). `warnings` are the /// import's notes, which say whether the raw was developed or only its preview opened. pub fn raw_defaults(s: &Session, warnings: &[String], doc: &mut Document) { - let ppi = s.prefs().raw_defaults.resolution; + raw_resolution(s.prefs().raw_defaults.resolution, warnings, doc); +} + +fn raw_resolution(ppi: f64, warnings: &[String], doc: &mut Document) { if ppi.is_finite() && warnings.iter().any(|w| w.contains(photocraft_io::raw::DEVELOPED_NOTE)) { doc.resolution_dpi = ppi.clamp(1.0, 10_000.0) as f32; } @@ -1279,9 +1300,17 @@ pub(crate) fn list_images_tree(dir: &str, _skip: &str, _cmd: &str) -> Result Result<()>) -> Value { +pub(crate) fn process_files( + s: &Session, + inputs: &[String], + output: &str, + format: &str, + save: SaveOpts, + suffix: &str, + f: &dyn Fn(&mut Session) -> Result<()>, +) -> Value { let inputs: Vec = inputs.iter().map(|path| BatchInput { path: path.clone(), rel_dir: String::new() }).collect(); - process_tree(&inputs, output, format, save, suffix, &|s, _| f(s).map(|()| false)) + process_tree(s, &inputs, output, format, save, suffix, &|scratch, _| f(scratch).map(|()| false)) } /// [`process_files`] for inputs from subfolders: each result goes to its input's relative folder @@ -1289,6 +1318,7 @@ pub(crate) fn process_files(inputs: &[String], output: &str, format: &str, save: /// output path and returns `true` when it wrote the result there itself (Batch writes what a /// recorded save marked); otherwise the active document is saved after it. pub(crate) fn process_tree( + s: &Session, inputs: &[BatchInput], output: &str, format: &str, @@ -1300,7 +1330,7 @@ pub(crate) fn process_tree( let ext = if format == "same" { same_format(path) } else { format.trim_start_matches('.').to_ascii_lowercase() }; (path.as_str(), join(&join(output, rel_dir), &format!("{}{suffix}.{ext}", stem(path)))) }); - process_outputs(outputs, save, f) + process_outputs(s, outputs, save, f) } /// The extension a batch output takes with Format "Same": the input's own, except for formats @@ -1328,7 +1358,9 @@ fn same_format_takes_the_file_names_extension() { assert_eq!(same_format("/in/.hidden"), "png"); } -fn process_outputs<'a>(outputs: impl IntoIterator, save: SaveOpts, f: &dyn Fn(&mut Session, &str) -> Result) -> Value { +fn process_outputs<'a>(s: &Session, outputs: impl IntoIterator, save: SaveOpts, f: &dyn Fn(&mut Session, &str) -> Result) -> Value { + // The scratch sessions have default preferences; a raw opens at the user's Raw Defaults. + let raw_ppi = s.prefs().raw_defaults.resolution; let mut files = Vec::new(); let mut errors = Vec::new(); let mut written = OutputClaims::default(); @@ -1337,7 +1369,7 @@ fn process_outputs<'a>(outputs: impl IntoIterator, sav written.check(&out).map_err(EngineError::Other)?; let bytes = read_file(path)?; let mut scratch = Session::new(); - let doc = import(&file_name(path), &bytes)?; + let doc = import_at_raw_ppi(raw_ppi, &file_name(path), &bytes)?; scratch.add_document(doc, Some(path.to_string())); if !f(&mut scratch, &out)? { let d = scratch.active().ok_or(EngineError::NoDocument)?; @@ -1449,8 +1481,8 @@ fn batch(s: &mut Session, p: &Value) -> Result { } }; let r = match names { - Some(names) => process_outputs(inputs.iter().map(|i| i.path.as_str()).zip(names), save, &run), - None => process_tree(&inputs, &output, &format, save, "", &run), + Some(names) => process_outputs(s, inputs.iter().map(|i| i.path.as_str()).zip(names), save, &run), + None => process_tree(s, &inputs, &output, &format, save, "", &run), }; Ok(r) } @@ -1465,7 +1497,7 @@ fn image_processor(s: &mut Session, p: &Value) -> Result { (w, h) => Some(json!({"width": w.unwrap_or(1e9), "height": h.unwrap_or(1e9), "dontEnlarge": true})), }; let to_srgb = p.get("convertToSrgb").and_then(Value::as_bool).unwrap_or(false); - let r = process_tree(&inputs, &output, &format, SaveOpts::from_params(s, p, cmd)?.or_quality(8.0), "", &|scratch, _| { + let r = process_tree(s, &inputs, &output, &format, SaveOpts::from_params(s, p, cmd)?.or_quality(8.0), "", &|scratch, _| { if to_srgb && scratch.active().is_some_and(|d| d.doc.mode != ColorMode::Rgb) { scratch.execute("image.mode.rgb", json!({}))?; } @@ -1490,7 +1522,7 @@ fn load_files_into_stack(s: &mut Session, p: &Value) -> Result { let mut docs = Vec::new(); for path in &paths { let bytes = read_file(path)?; - docs.push((file_name(path), import(&file_name(path), &bytes)?)); + docs.push((file_name(path), import_opened(s, &file_name(path), &bytes)?)); } let first = &docs[0].1; let w = docs.iter().map(|(_, d)| d.size.width).max().unwrap_or(1); diff --git a/crates/engine/src/file_cmds/tests.rs b/crates/engine/src/file_cmds/tests.rs index 7d9d2f6e1f..3115d713b6 100644 --- a/crates/engine/src/file_cmds/tests.rs +++ b/crates/engine/src/file_cmds/tests.rs @@ -121,6 +121,32 @@ fn a_developed_raw_opens_at_the_raw_defaults_resolution() { assert_eq!(doc(&s).resolution_dpi, 72.0, "not a raw"); } +/// The other ways a raw becomes a document of its own take Raw Defaults › Resolution too: +/// File › Revert, Batch and Load Files into Stack (all of them opened a raw at 72 ppi). +#[test] +fn revert_batch_and_stack_open_a_raw_at_the_raw_defaults_resolution() { + use photocraft_raw::testgen::{DngSpec, mosaic, scene}; + let (w, h) = (24, 16); + let dir = tmp("raw-defaults-siblings"); + let path = join(&dir, "IMG_0003.dng"); + std::fs::write(&path, DngSpec::cfa(w, h, mosaic(&scene(w, h), w, [0, 1, 1, 2], 0, 65535)).build()).unwrap(); + let mut s = Session::new(); + s.execute("prefs.set", json!({"path": "rawDefaults.resolution", "value": 240})).unwrap(); + open_bytes_as(&mut s, &path, &std::fs::read(&path).unwrap(), None, Some(path.clone())).unwrap(); + s.execute("image.imageSize", json!({"resolution": 150, "resample": "none"})).unwrap(); + assert_eq!(doc(&s).resolution_dpi, 150.0); + s.execute("file.revert", json!({})).unwrap(); + assert_eq!(doc(&s).resolution_dpi, 240.0, "File › Revert"); + let out = join(&dir, "out"); + let r = s.execute("file.automate.batch", json!({"steps": [], "input": [path.clone()], "output": out, "format": "psd"})).unwrap(); + let files = r["files"].as_array().unwrap(); + assert_eq!(files.len(), 1, "{r}"); + let psd = photocraft_io::import("IMG_0003.psd", &std::fs::read(files[0].as_str().unwrap()).unwrap()).unwrap().document; + assert_eq!(psd.resolution_dpi, 240.0, "Batch"); + s.execute("file.scripts.loadFilesIntoStack", json!({"paths": [path]})).unwrap(); + assert_eq!(doc(&s).resolution_dpi, 240.0, "Load Files into Stack"); +} + #[test] fn revert_reloads_as_one_undoable_step() { let dir = tmp("revert"); diff --git a/crates/engine/src/lens_cmds.rs b/crates/engine/src/lens_cmds.rs index c813a4812f..c68469f40f 100644 --- a/crates/engine/src/lens_cmds.rs +++ b/crates/engine/src/lens_cmds.rs @@ -433,7 +433,7 @@ fn lens_batch(s: &mut Session, p: &Value) -> Result { // Batch default: the EXIF-driven profile, as Photoshop's dialog suggests. m.entry("profile").or_insert(json!("auto")); } - let r = crate::file_cmds::process_files(&inputs, &output, &format, crate::file_cmds::SaveOpts::from_params(s, p, LENS_BATCH)?, "", &|scratch| { + let r = crate::file_cmds::process_files(s, &inputs, &output, &format, crate::file_cmds::SaveOpts::from_params(s, p, LENS_BATCH)?, "", &|scratch| { if scratch.active().is_some_and(|d| d.doc.layers.len() > 1) { scratch.execute("layer.flattenImage", json!({}))?; }