diff --git a/crates/codecs/src/codecs/dds.rs b/crates/codecs/src/codecs/dds.rs index cadc2b4bc8..a4223be54c 100644 --- a/crates/codecs/src/codecs/dds.rs +++ b/crates/codecs/src/codecs/dds.rs @@ -46,6 +46,11 @@ fn le32(b: &[u8], at: usize) -> Result { Ok(u32::from_le_bytes(bytes)) } +/// The reader opens only the top mip; rewriting cannot retain independently authored lower levels. +pub(crate) fn has_additional_images(bytes: &[u8]) -> Result { + Ok(le32(bytes, 28)? > 1) +} + /// A FourCC as text when it is printable, else as the number legacy writers store there. fn fourcc_name(fourcc: u32) -> String { let bytes = fourcc.to_le_bytes(); diff --git a/crates/codecs/src/codecs/png.rs b/crates/codecs/src/codecs/png.rs index e119e22548..9f069e1a2c 100644 --- a/crates/codecs/src/codecs/png.rs +++ b/crates/codecs/src/codecs/png.rs @@ -96,6 +96,16 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result Ok(img) } +/// Inspect APNG's declared images, including a separate default image, without decoding pixels. +pub(crate) fn has_additional_images(bytes: &[u8]) -> Result { + let limits = Limits::default(); + let mut decoder = png::Decoder::new_with_limits(Cursor::new(bytes), png::Limits { bytes: limits.alloc_usize() }); + decoder.set_ignore_text_chunk(true); + let reader = decoder.read_info().map_err(map_png_err)?; + let info = reader.info(); + Ok(info.animation_control.is_some_and(|a| u64::from(a.num_frames) + u64::from(info.frame_control.is_none()) > 1)) +} + /// The colour table of a palette PNG (`PLTE`) and the one entry its `tRNS` makes transparent. #[derive(Debug, Clone, PartialEq, Eq)] pub struct PngPalette { diff --git a/crates/codecs/src/codecs/via_image.rs b/crates/codecs/src/codecs/via_image.rs index 79b39fd2f6..057b9d0b58 100644 --- a/crates/codecs/src/codecs/via_image.rs +++ b/crates/codecs/src/codecs/via_image.rs @@ -162,7 +162,7 @@ fn gif_first_frame(b: &[u8]) -> Option<(u32, u32)> { /// Counts a GIF's frames (image descriptors) by walking its blocks, without decompressing any. /// The total is unknown when the blocks end before the trailer or break off. -fn gif_more_frames(b: &[u8]) -> Option { +pub(crate) fn gif_more_frames(b: &[u8]) -> Option { let (frames, total) = gif_walk(b, |_| true); (frames > 1).then_some(DecodeWarning::MoreFrames { total }) } diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 4c081ba074..bb95050958 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -190,6 +190,30 @@ pub fn webp_is_animated(bytes: &[u8]) -> bool { crate::codecs::webp::is_animated(bytes) } +/// Inspect additional frames in GIF/APNG/WebP, full TIFF pages, ICO entries and DDS mip levels. +/// Pixels are not decoded; TIFF thumbnails and masks do not count as extra document pages. +/// Other formats return false; this does not establish preservation of other format-specific data. +pub fn has_additional_images(bytes: &[u8]) -> Result { + match detect(bytes) { + Some(Format::Gif) => Ok(via_image::gif_more_frames(bytes).is_some()), + Some(Format::Png) => png::has_additional_images(bytes), + Some(Format::WebP) => Ok(webp_is_animated(bytes)), + Some(Format::Tiff) => { + let info = tiff_info(bytes)?; + if !info.complete { + return Err(CodecError::malformed(Format::Tiff, "cannot inspect every source image directory")); + } + Ok(info.pages.iter().filter(|page| page.kind == TiffPageKind::Page).take(2).count() > 1) + } + Some(Format::Ico) => { + let count = bytes.get(4..6).and_then(|b| <[u8; 2]>::try_from(b).ok()).ok_or_else(|| CodecError::malformed(Format::Ico, "truncated image count"))?; + Ok(u16::from_le_bytes(count) > 1) + } + Some(Format::Dds) => dds::has_additional_images(bytes), + _ => Ok(false), + } +} + /// The Orientation (1–8) of the page [`decode`] opens from a TIFF or BigTIFF file; 1 when it is /// absent, malformed or out of range. pub fn tiff_orientation(bytes: &[u8]) -> u16 { diff --git a/crates/codecs/tests/it/decode_warnings.rs b/crates/codecs/tests/it/decode_warnings.rs index 82bf75b57d..400f774f41 100644 --- a/crates/codecs/tests/it/decode_warnings.rs +++ b/crates/codecs/tests/it/decode_warnings.rs @@ -307,3 +307,45 @@ fn warnings_survive_orientation() { } assert_eq!(img.convert(ChannelLayout::Rgba, SampleType::U16).warnings, img.warnings); } + +/// Header-only inspection distinguishes actual frames/pages from stills, TIFF thumbnails and masks. +#[test] +fn additional_images_probe_uses_container_structure_without_decoding_pixels() { + use photocraft_codecs::has_additional_images; + let frames: Vec<_> = COLOURS.iter().take(2).map(|&c| solid(c)).collect(); + for (bytes, multiple) in [ + (gif(1), false), + (gif(2), true), + (apng(0, false, false), false), + (apng(1, false, false), false), + (apng(1, false, true), true), + (apng(2, true, false), true), + (animated_webp(&frames[..1]), false), + (animated_webp(&frames), true), + (tiff(&[(0, SampleType::U8)]), false), + (tiff(&[(0, SampleType::U8), (2, SampleType::U8)]), true), + (tiff(&[(0, SampleType::U8), (1, SampleType::U8), (4, SampleType::U8)]), false), + ] { + assert_eq!(has_additional_images(&bytes).unwrap(), multiple); + } + let image = solid(COLOURS[0]); + for mipmaps in [false, true] { + let mut options = EncodeOptions::default(); + options.dds.mipmaps = mipmaps; + let bytes = encode(&image, Format::Dds, &options).unwrap(); + assert_eq!(has_additional_images(&bytes).unwrap(), mipmaps); + } + let one = encode(&image, Format::Ico, &Default::default()).unwrap(); + assert!(!has_additional_images(&one).unwrap()); + let mut multiple = one[..6].to_vec(); + multiple[4..6].copy_from_slice(&2u16.to_le_bytes()); + let mut entry = one[6..22].to_vec(); + let offset = u32::from_le_bytes(entry[12..16].try_into().unwrap()) + 16; + entry[12..16].copy_from_slice(&offset.to_le_bytes()); + multiple.extend_from_slice(&entry); + multiple.extend_from_slice(&entry); + multiple.extend_from_slice(&one[22..]); + decode(&multiple).unwrap(); + assert!(has_additional_images(&multiple).unwrap()); + assert!(has_additional_images(b"\x89PNG\r\n\x1a\n").is_err()); +} diff --git a/crates/engine/src/cmd/atn_cmds/convert.rs b/crates/engine/src/cmd/atn_cmds/convert.rs index 512189197a..8123b0ac84 100644 --- a/crates/engine/src/cmd/atn_cmds/convert.rs +++ b/crates/engine/src/cmd/atn_cmds/convert.rs @@ -152,7 +152,7 @@ pub(super) fn export_item(id: &str, params: &Value, set: &str) -> Option Result { names.push(name); added.push(a); } - // Play and Conditional steps call actions by name: follow the renames in the actions just + // Play, Conditional and Batch steps call actions by name: follow the renames in the actions just // loaded, each target looked up once by the name the file gave it. for a in &mut added { for (id, params) in &mut a.steps { let keys: &[&str] = match id.as_str() { "actions.play" => &["action"], "actions.conditional" => &["then", "else"], + "file.automate.batch" if params.get("set").is_none_or(|set| set.is_null() || set.as_str() == Some(imp.set.as_str())) => &["action"], _ => &[], }; for key in keys { diff --git a/crates/engine/src/cmd/atn_cmds/steps_batch.rs b/crates/engine/src/cmd/atn_cmds/steps_batch.rs index ec1682e950..639d2a9402 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_batch.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_batch.rs @@ -249,7 +249,7 @@ pub(super) fn batch_to(_: &str, p: &Value) -> Option { let input = jstr(p, "input").filter(|s| !s.is_empty())?; let action = jstr(p, "action").filter(|s| !s.is_empty())?; let mut reference = vec![ReferenceItem::Name { class: class("Actn"), name: UnicodeString::new_nul(action) }]; - if let Some(set) = p.get("set") { + if let Some(set) = p.get("set").filter(|set| !set.is_null()) { reference.push(ReferenceItem::Name { class: class("ASet"), name: UnicodeString::new_nul(set.as_str()?) }); } let d = desc("Btch").with("null", path_value(input)).with("Fldr", D::Boolean(subfolders)).with("Usng", D::Reference(reference)); diff --git a/crates/engine/src/cmd/channel_cmds/refs.rs b/crates/engine/src/cmd/channel_cmds/refs.rs index f89fcd8d6f..2f25a731f6 100644 --- a/crates/engine/src/cmd/channel_cmds/refs.rs +++ b/crates/engine/src/cmd/channel_cmds/refs.rs @@ -324,7 +324,8 @@ pub(crate) fn source_spec(p: &Value, key: &str, prefix: &str) -> Option { for (k, field) in [("document", "Document"), ("layer", "Layer"), ("channel", "Channel"), ("invert", "Invert")] { match p.get(format!("{prefix}{field}")) { None | Some(Value::Null) => {} - Some(Value::String(s)) if s.is_empty() || (k == "layer" && s == "merged") => {} + Some(Value::String(s)) + if s.is_empty() || (k == "layer" && s == "merged" && p.get(format!("{prefix}Channel")).is_none_or(|v| v != "transparency")) => {} Some(v) => { m.insert(k.into(), v.clone()); } diff --git a/crates/engine/src/cmd/file_cmds/batch_run.rs b/crates/engine/src/cmd/file_cmds/batch_run.rs index b013f0de05..9f853b51e0 100644 --- a/crates/engine/src/cmd/file_cmds/batch_run.rs +++ b/crates/engine/src/cmd/file_cmds/batch_run.rs @@ -459,7 +459,15 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result { } let mut playback_stack = s.actions.playback_stack.clone(); playback_stack.extend(called_name); - let actions = crate::actions_cmds::ActionState { list, playing: s.actions.playing, playback_stack, playback_steps, ..Default::default() }; + let actions = crate::actions_cmds::ActionState { + list, + playing: s.actions.playing, + playback_stack, + playback_steps, + script_depth: s.actions.script_depth, + script_steps: s.actions.script_steps, + ..Default::default() + }; let names = if save_and_close { None } else { batch_naming::plan(&inputs, &output, &format, p)? }; let mut save = SaveOpts::from_params(s, p, cmd)?; if save_and_close { @@ -470,6 +478,8 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result { // one input to the next (failed or not) and back to this session, not restarted per input. let carried = std::cell::Cell::new(playback_steps); let carried = &carried; + let script_steps = std::cell::Cell::new(s.actions.script_steps); + let script_steps = &script_steps; let structured = !save_and_close && p.get("namingComponents").is_some_and(|v| !v.is_null()); let run_format = structured.then(|| format.clone()); let run = move |scratch: &mut Session, out: &str| { @@ -508,11 +518,9 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result { if !from_source { return Err(EngineError::Other("Save and Close needs the original source document or its recorded Save snapshot".into())); } - if extension(out).as_deref() == Some("exr") { - // All recorded Opens have passed their authorization gates before this read. - // Validate the original file, not only the selected part's imported document. - preserve_exr(&read_file(out)?)?; - } + // All recorded Opens passed their gates before inspecting the original source, + // including when this result is a saved snapshot whose document has been closed. + preserve_source(out)?; let tiff = extension(out).and_then(|ext| photocraft_codecs::from_extension(&ext)) == Some(photocraft_codecs::Format::Tiff); if tiff { if !matches!( @@ -531,6 +539,7 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result { scratch.authorize = authorize; scratch.actions = actions.clone(); scratch.actions.playback_steps = carried.get(); + scratch.actions.script_steps = script_steps.get(); let result = (|| { // The document as the last recorded save found it, written once the action has run so a // failing step still writes nothing. @@ -563,6 +572,7 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result { } })(); carried.set(scratch.actions.playback_steps); + script_steps.set(scratch.actions.script_steps); result }; let open = InputOpen { check_format: check_folder_format, from_action: override_open, quiet_profiles, quiet_raw }; @@ -576,6 +586,7 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result { if s.actions.playing > 0 { s.actions.playback_steps = carried.get(); } + s.actions.script_steps = script_steps.get(); // Nested Batch completion delivers its imports once to the enclosing input's diagnostics. if let (Some(notices), Some(imports)) = (&mut s.batch_open_notices, r.get("notices").and_then(Value::as_array)) { notices.extend(imports.iter().cloned()); @@ -583,6 +594,18 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result { Ok(r) } +/// Check source structure once at the common pre-write seam, after Open authorization and ownership. +fn preserve_source(path: &str) -> Result<()> { + let bytes = read_file(path)?; + if photocraft_codecs::has_additional_images(&bytes).map_err(|e| EngineError::Other(format!("cannot inspect source images: {e}")))? { + return Err(EngineError::Other("Save and Close cannot preserve additional source images; save to a separate output folder".into())); + } + if photocraft_codecs::detect(&bytes) == Some(photocraft_codecs::Format::OpenExr) { + preserve_exr(&bytes)?; + } + Ok(()) +} + /// Only a single flat, unprefixed RGB(A) source can be preserved by the EXR writer. fn preserve_exr(bytes: &[u8]) -> Result<()> { let parts = photocraft_codecs::exr_info(bytes, &Default::default()).map_err(|e| EngineError::Other(format!("cannot inspect EXR source: {e}")))?; diff --git a/crates/engine/tests/it/atn_calculations.rs b/crates/engine/tests/it/atn_calculations.rs index e03b99f6fd..f36260c757 100644 --- a/crates/engine/tests/it/atn_calculations.rs +++ b/crates/engine/tests/it/atn_calculations.rs @@ -178,6 +178,9 @@ fn atn_calculations_explicit_merged_transparency_reads_composite_alpha() { assert_eq!(alpha(&s, 0), vec![0.625, 0.5, 0.25], "top alpha plus base alpha times one minus top alpha"); s.execute("edit.undo", json!({})).unwrap(); assert_eq!(saved(&s), before); + s.execute("image.calculations",json!({"source1Layer":"merged","source1Channel":"transparency","source2Layer":"merged","source2Channel":"transparency","blending":"multiply","maskLayer":"merged"})).unwrap(); + assert_eq!(alpha(&s, 0), vec![0.390625, 0.25, 0.0625], "both flat sources retain merged alpha; a channel-less merged mask remains absent"); + s.execute("edit.undo", json!({})).unwrap(); s.execute("image.calculations", json!({"source1":{"channel":"transparency"},"source2":{"channel":{"color":"red"}},"blending":"normal"})).unwrap(); assert_eq!(alpha(&s, 0), vec![0.25, 0.5, 0.0], "omitting the layer still reads the active layer's alpha"); } @@ -272,3 +275,22 @@ fn atn_calculations_make_routing_retains_mask_creation() { assert!(doc.channels.is_empty()); assert!(doc.layer(s.active().unwrap().active_layer.unwrap()).unwrap().mask.is_some()); } + +/// Non-default valid scale and offset retain their wire types and affect the calculated pixels. +#[test] +fn atn_calculations_valid_scale_and_offset_roundtrip_and_pixels() { + for (scale, offset) in [(2.0, 0), (1.0, 32), (2.0, -32)] { + let mut d = make(channel("Chnl", "Rd "), channel("Chnl", "Grn ")); + let D::Descriptor(using) = &mut d.items[1].1 else { panic!("Calculations settings") }; + using.items.iter_mut().find(|(k, _)| k.is("Scl ")).unwrap().1 = D::Double(scale); + using.items.iter_mut().find(|(k, _)| k.is("Ofst")).unwrap().1 = D::Integer(offset); + let action = roundtrip(d); + let mut s = fixture(32); + let input = photocraft_compose::flatten(&s.active().unwrap().doc).into_px(); + assert_eq!(play(&mut s, action)["ran"], 1); + for (got, rgba) in alpha(&s, 0).iter().zip(input) { + let expected = ((rgba[0] + rgba[1]) / scale as f32 + offset as f32 / 255.0).clamp(0.0, 1.0); + assert!((got - expected).abs() < 1e-6, "scale={scale} offset={offset}: {got} vs {expected}"); + } + } +} diff --git a/crates/engine/tests/it/atn_review_regressions.rs b/crates/engine/tests/it/atn_review_regressions.rs index e0e265c238..0430b95e3c 100644 --- a/crates/engine/tests/it/atn_review_regressions.rs +++ b/crates/engine/tests/it/atn_review_regressions.rs @@ -314,3 +314,132 @@ fn batch_naming_components_rebuild_without_original_descriptor() { assert_eq!(exported(&action).get("fileNamingRules"), Some(&D::Descriptor(rules)), "{wire}"); } } + +/// Tiny encoded sources keep Batch tests independent of external image assets. +fn small_image(format: photocraft_codecs::Format) -> Vec { + let image = photocraft_codecs::Image::from_u8(2, 2, photocraft_codecs::ChannelLayout::Rgba, [32, 64, 128, 255].repeat(4)).unwrap(); + photocraft_codecs::encode(&image, format, &Default::default()).unwrap() +} + +/// A real one-name Batch import follows its own renamed action, never a same-name existing action. +#[test] +fn imported_batch_references_follow_collision_renames_once_and_keep_other_sets() { + let dir = tempfile::tempdir().unwrap(); + let input = dir.path().join("input.png"); + let output = dir.path().join("out"); + std::fs::write(&input, small_image(photocraft_codecs::Format::Png)).unwrap(); + let d = Descriptor::new("Btch") + .with("null", path(dir.path().to_str().unwrap())) + .with("T ", path(output.to_str().unwrap())) + .with("Usng", D::Reference(vec![named("Actn", "Child")])); + let mut file = atn::parse(&encoded(*b"Btch", d, true)).unwrap(); + file.name = UnicodeString::new_nul("Imported"); + for name in ["Child", "Child 2"] { + let child = Action::new(name, vec![("image.imageSize".into(), json!({"width":3,"height":1,"resample":"nearest"}))]); + file.actions.push(atn::parse(&atn_cmds::export("Imported", &[&child], &[], false).unwrap().0).unwrap().actions.remove(0)); + } + let mut external = file.actions[0].items[0].clone(); + external.descriptor.as_mut().unwrap().items.iter_mut().find(|(k, _)| k.is("Usng")).unwrap().1 = + D::Reference(vec![named("Actn", "Child"), named("ASet", "Other")]); + file.actions[0].items.push(external); + let mut s = Session::new(); + let mut old = Action::new("Child", vec![("select.deselect".into(), json!({}))]); + old.set = Some("Other".into()); + s.actions.list.push(old); + let loaded = s.execute("actions.load", json!({"data":photocraft_paint::tile::b64_encode(&atn::write(&file).unwrap()),"replace":false})).unwrap(); + let own_name = loaded["actions"][1].as_str().unwrap(); + let imported = &s.actions.list[1]; + assert_eq!(imported.steps[0].1["action"], own_name); + assert_eq!(imported.steps[1].1["action"], "Child"); + assert_eq!(imported.steps[1].1["set"], "Other"); + let batch = imported.steps[0].1.clone(); + let result = s.execute("file.automate.batch", batch).unwrap(); + assert_eq!(result["files"].as_array().unwrap().len(), 1, "{result}"); + let doc = photocraft_io::import("output.png", &std::fs::read(result["files"][0].as_str().unwrap()).unwrap()).unwrap().document; + assert_eq!(doc.size, photocraft_doc::Size::new(3, 1), "the renamed imported child ran"); +} + +/// Null is the optional default set, while invalid typed and explicit set values remain distinct. +#[test] +fn batch_null_set_exports_like_absent_and_preserves_explicit_sets() { + let p = json!({"input":"in","output":"out","action":"Child","stopOnError":true,"includeNonImages":true,"namingComponents":[{"component":"name"},{"component":"extension"}]}); + let absent = exported(&Action::new("Review", vec![("file.automate.batch".into(), p.clone())])); + for (set, allowed) in [(Value::Null, true), (json!("Other"), true), (json!(4), false)] { + let mut p = p.clone(); + p["set"] = set.clone(); + let action = Action::new("Review", vec![("file.automate.batch".into(), p)]); + let result = atn_cmds::export("Parent", &[&action], &[], false); + assert_eq!(result.is_ok(), allowed); + if allowed { + let bytes = result.unwrap().0; + let imported = atn_cmds::import(&bytes).unwrap(); + assert_eq!(imported.actions[0].steps[0].1["set"], if set.is_null() { json!("Parent") } else { set.clone() }); + if set.is_null() { + assert_eq!(atn::parse(&bytes).unwrap().actions[0].items[0].descriptor, Some(absent.clone())); + } + } + } +} + +/// Extra GIF image blocks must survive both ordinary and saved/closed Batch refusal paths. +#[test] +fn batch_save_close_preserves_animation_and_allows_still_gif() { + let dir = tempfile::tempdir().unwrap(); + let still = small_image(photocraft_codecs::Format::Gif); + let flags = still[10]; + let blocks = 13 + if flags & 0x80 != 0 { 3usize << ((flags & 7) + 1) } else { 0 }; + let mut animated = still[..still.len() - 1].to_vec(); + animated.extend_from_slice(&still[blocks..]); + assert_eq!(photocraft_codecs::decode_gif_frames(&animated, &Default::default()).unwrap().len(), 2); + let input = dir.path().join("source.gif"); + for steps in [ + json!([]), + json!([{"command":"file.openAs","params":{"path":"recorded.gif"}},{"command":"file.saveAs","params":{}},{"command":"file.close","params":{"force":true}}]), + ] { + std::fs::write(&input, &animated).unwrap(); + let override_open = !steps.as_array().unwrap().is_empty(); + let mut s = Session::new(); + let result = + s.execute("file.automate.batch", json!({"input":[input],"destination":"saveAndClose","overrideOpen":override_open,"steps":steps})).unwrap(); + assert!(result["files"].as_array().unwrap().is_empty(), "{result}"); + assert!(result["errors"][0]["error"].as_str().unwrap().contains("additional source images"), "{result}"); + assert_eq!(std::fs::read(&input).unwrap(), animated); + } + std::fs::write(&input, &still).unwrap(); + let result = Session::new().execute("file.automate.batch", json!({"input":[input],"destination":"saveAndClose","steps":[]})).unwrap(); + assert_eq!(result["files"].as_array().unwrap().len(), 1, "{result}"); + assert_eq!(photocraft_codecs::decode_gif_frames(&std::fs::read(input).unwrap(), &Default::default()).unwrap().len(), 1); +} + +/// Scripts nested through Batch retain the caller's depth and cumulative spending across failed inputs. +#[test] +fn batch_carries_script_limits_and_restores_balanced_depth() { + let dir = tempfile::tempdir().unwrap(); + let input = dir.path().join("source.png"); + std::fs::write(&input, small_image(photocraft_codecs::Format::Png)).unwrap(); + let step = json!({"command":"file.scripts.browse","params":{"steps":[{"command":"select.all"}]}}); + for (depth, spending, allowed) in [(16, 0, false), (1, photocraft_engine::actions_cmds::MAX_PLAYBACK_STEPS - 1, false), (1, 2, true)] { + let mut s = Session::new(); + s.actions.script_depth = depth; + s.actions.script_steps = spending; + let result = s.execute("file.automate.batch",json!({"input":[input,input],"output":dir.path().join("out"),"steps":[step],"namingComponents":[{"component":"serial2"},{"component":"extension"}]})).unwrap(); + assert_eq!(result["errors"].as_array().unwrap().is_empty(), allowed, "{result}"); + assert_eq!(s.actions.script_depth, depth); + if depth == 1 { + assert_eq!(s.actions.script_steps, spending + 2); + } + } +} + +/// Structured naming with Override Open keeps the actual input encoding when format defaults to same. +#[test] +fn structured_batch_override_open_saves_default_same_signature() { + let dir = tempfile::tempdir().unwrap(); + let input = dir.path().join("source.png"); + std::fs::write(&input, small_image(photocraft_codecs::Format::Png)).unwrap(); + let result=Session::new().execute("file.automate.batch",json!({"input":[input],"output":dir.path().join("out"),"overrideOpen":true,"namingComponents":[{"component":"name"},{"component":"extension"}],"steps":[{"command":"file.openAs","params":{"path":"recorded.jpg"}}]})).unwrap(); + assert_eq!(result["files"].as_array().unwrap().len(), 1, "{result}"); + let output = result["files"][0].as_str().unwrap(); + assert_eq!(std::path::Path::new(output).extension().unwrap(), "png"); + assert!(std::fs::read(output).unwrap().starts_with(b"\x89PNG\r\n\x1a\n")); +} diff --git a/crates/engine/tests/it/atn_shape_blur.rs b/crates/engine/tests/it/atn_shape_blur.rs index e0b3e66607..a697c575b1 100644 --- a/crates/engine/tests/it/atn_shape_blur.rs +++ b/crates/engine/tests/it/atn_shape_blur.rs @@ -120,6 +120,9 @@ fn imported_csh_id_resolves_in_an_action_after_reimport_and_preference_reload() let path = photocraft_engine::vector_cmds::path_json(&matches[0].path); let mut expected = fixture(16); expected.execute("filter.blur.shapeBlur", json!({"radius":5,"shapePath":path})).unwrap(); + let mut square = fixture(16); + square.execute("filter.blur.shapeBlur", json!({"radius":5,"shape":"square"})).unwrap(); + assert_ne!(pixels(&expected), pixels(&square), "the non-square imported triangle must differ from a square kernel"); let prefs = s.presets.to_json(&s); let mut s = fixture(16); s.load_presets_json(prefs);