mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-10 20:32:31 +00:00
fix: preserve Batch references, source images and script limits
This commit is contained in:
1 parent
4649808594
commit
003a8a5482
13 files changed
+271
-11
No files matched your search
@@ -46,6 +46,11 @@ fn le32(b: &[u8], at: usize) -> Result<u32, CodecError> {
|
||||
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<bool, CodecError> {
|
||||
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();
|
||||
|
||||
@@ -96,6 +96,16 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
/// Inspect APNG's declared images, including a separate default image, without decoding pixels.
|
||||
pub(crate) fn has_additional_images(bytes: &[u8]) -> Result<bool, CodecError> {
|
||||
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 {
|
||||
|
||||
@@ -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<DecodeWarning> {
|
||||
pub(crate) fn gif_more_frames(b: &[u8]) -> Option<DecodeWarning> {
|
||||
let (frames, total) = gif_walk(b, |_| true);
|
||||
(frames > 1).then_some(DecodeWarning::MoreFrames { total })
|
||||
}
|
||||
|
||||
@@ -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<bool, CodecError> {
|
||||
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 {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -152,7 +152,7 @@ pub(super) fn export_item(id: &str, params: &Value, set: &str) -> Option<AtnItem
|
||||
}
|
||||
let mut params = params.clone();
|
||||
if (id == "actions.play" || id == "actions.conditional" || id == "file.automate.batch")
|
||||
&& params.get("set").is_none()
|
||||
&& params.get("set").is_none_or(Value::is_null)
|
||||
&& !(id == "file.automate.batch" && super::steps_batch::original_batch(¶ms).is_some())
|
||||
{
|
||||
params["set"] = json!(set);
|
||||
|
||||
@@ -48,13 +48,14 @@ pub(super) fn load(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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 {
|
||||
|
||||
@@ -249,7 +249,7 @@ pub(super) fn batch_to(_: &str, p: &Value) -> Option<Descriptor> {
|
||||
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));
|
||||
|
||||
@@ -324,7 +324,8 @@ pub(crate) fn source_spec(p: &Value, key: &str, prefix: &str) -> Option<Value> {
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -459,7 +459,15 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
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<Value> {
|
||||
// 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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
}
|
||||
})();
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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}")))?;
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<u8> {
|
||||
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"));
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user