mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-02 18:13:39 +00:00
Integration: Raw Defaults resolution on Revert, Batch, Stack, headless open and CLI convert (#7811)
The resolution preference reached File › Open, but the other ways a raw becomes a document of its own still opened it at 72 ppi: File › Revert (a raw opened at 240 dropped to 72), Batch / Image Processor / Lens Correction batch (a raw saved as PSD came out at 72 while Open + Save gave 240), Load Files into Stack, the headless MCP `doc.open`, and the CLI (`convert` at 72 while `run`/`info`, which open headless, now take the default 300). They now apply the same `raw_defaults` rule; Batch passes the user's value into its scratch sessions. Tests that fail without this change (72 vs 240/300): revert_batch_and_stack_open_a_raw_at_the_raw_defaults_resolution (engine, one assertion per path), doc_open_gives_a_raw_the_raw_defaults_resolution (automation), convert_opens_a_raw_at_the_raw_defaults_resolution (CLI). Co-authored-by: Korg1ch <57859048+Korg1ch@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Generated
+2
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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())?;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -308,6 +308,24 @@ pub(crate) fn is_windows_device_name(component: &str) -> bool {
|
||||
}
|
||||
|
||||
pub(crate) fn import(name: &str, bytes: &[u8]) -> Result<Document> {
|
||||
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<Document> {
|
||||
import_at_raw_ppi(s.prefs().raw_defaults.resolution, name, bytes)
|
||||
}
|
||||
|
||||
fn import_at_raw_ppi(raw_ppi: f64, name: &str, bytes: &[u8]) -> Result<Document> {
|
||||
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<photocraft_io::ImportResult> {
|
||||
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<Document> {
|
||||
"{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<Value> {
|
||||
fn revert(s: &mut Session) -> Result<Value> {
|
||||
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<Vec
|
||||
|
||||
/// Opens each input in a scratch session, runs `f` on it and saves it to `output` as `format`
|
||||
/// (`"same"` keeps the input's extension). Errors per file are collected, not fatal.
|
||||
pub(crate) fn process_files(inputs: &[String], output: &str, format: &str, save: SaveOpts, suffix: &str, f: &dyn Fn(&mut Session) -> 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<BatchInput> = 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<Item = (&'a str, String)>, save: SaveOpts, f: &dyn Fn(&mut Session, &str) -> Result<bool>) -> Value {
|
||||
fn process_outputs<'a>(s: &Session, outputs: impl IntoIterator<Item = (&'a str, String)>, save: SaveOpts, f: &dyn Fn(&mut Session, &str) -> Result<bool>) -> 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<Item = (&'a str, String)>, 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<Value> {
|
||||
}
|
||||
};
|
||||
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<Value> {
|
||||
(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<Value> {
|
||||
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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -433,7 +433,7 @@ fn lens_batch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// 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!({}))?;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user