Skip to content

Commit cb37190

Browse files
committed
test: add composition, pyramid, animation, and trim integration tests
Composition tests (8): - Graph mode: DrawImageExact, CopyRectToCanvas, dual encode - Pyramid: 1 decode → 4 resizes → 4 formats (JPEG/WebP/PNG/JXL) - Pyramid with Constrain within mode - Watermark with alpha blending on JPEG and transparent PNG Animation tests (7, verifying issue fixes): - #606: GIF→WebP animation preservation (lossy + lossless) - #643: Double GIF encode (resize output, resize again — no EOF crash) - #653: Animated GIF with transparent background roundtrip + resize - Animated GIF → all 6 single-frame format encoders Trim tests (7): - CropWhitespace node on generated canvas, with padding - Trim transparent canvas, trim then resize - Photo trim with low and high thresholds
1 parent 364aaed commit cb37190

6 files changed

Lines changed: 935 additions & 1 deletion

File tree

imageflow_core/tests/integration/visuals/animation.rs

Lines changed: 182 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
use crate::common::*;
22
use imageflow_core::Context;
3-
use imageflow_types::{CommandStringKind, EncoderPreset, Execute001, Framewise, Node};
3+
use imageflow_types::{
4+
CommandStringKind, EncoderPreset, Execute001, Filter, Framewise, Node, ResampleHints,
5+
};
46

57
use super::smoke::build_animated_gif;
68

@@ -467,3 +469,182 @@ fn test_animated_gif_pixel_colors_preserved() {
467469
}
468470
}
469471
}
472+
473+
// ============================================================================
474+
// Issue #606: GIF → WebP animation preservation
475+
// ============================================================================
476+
477+
#[test]
478+
fn test_animated_gif_to_webp_preserves_animation() {
479+
test_init();
480+
let input = build_animated_gif(8, 8, &["FF0000", "00FF00", "0000FF"], 10);
481+
let output = roundtrip_animated_gif(input, EncoderPreset::WebPLossy { quality: 80.0 });
482+
assert!(output.starts_with(b"RIFF"), "Output should be WebP");
483+
// WebP animated files should have ANIM chunk
484+
// At minimum, the file should be significantly larger than a single-frame WebP
485+
assert!(
486+
output.len() > 200,
487+
"Animated WebP should be larger than a trivial single-frame output (got {} bytes)",
488+
output.len()
489+
);
490+
}
491+
492+
#[test]
493+
fn test_animated_gif_to_webp_lossless_preserves_animation() {
494+
test_init();
495+
let input = build_animated_gif(8, 8, &["FF0000", "00FF00", "0000FF", "FFFF00"], 5);
496+
let output = roundtrip_animated_gif(input, EncoderPreset::WebPLossless);
497+
assert!(output.starts_with(b"RIFF"), "Output should be WebP");
498+
assert!(
499+
output.len() > 200,
500+
"Animated WebP lossless should have multiple frames (got {} bytes)",
501+
output.len()
502+
);
503+
}
504+
505+
// ============================================================================
506+
// Issue #643: Double GIF encode (resize GIF, then resize the output again)
507+
// ============================================================================
508+
509+
#[test]
510+
fn test_gif_double_encode_no_eof_crash() {
511+
test_init();
512+
let input = build_animated_gif(16, 16, &["FF0000", "00FF00", "0000FF"], 10);
513+
514+
// First pass: resize the animated GIF
515+
let steps1 = vec![
516+
Node::Decode { io_id: 0, commands: None },
517+
Node::Resample2D {
518+
w: 8,
519+
h: 8,
520+
hints: Some(ResampleHints::new().with_bi_filter(Filter::Hermite)),
521+
},
522+
Node::Encode { io_id: 1, preset: EncoderPreset::Gif },
523+
];
524+
let mut ctx1 = Context::create().unwrap();
525+
ctx1.add_input_vector(0, input).unwrap();
526+
ctx1.add_output_buffer(1).unwrap();
527+
ctx1.execute_1(Execute001 {
528+
graph_recording: default_graph_recording(false),
529+
security: None,
530+
framewise: Framewise::Steps(steps1),
531+
})
532+
.unwrap();
533+
let intermediate = ctx1.take_output_buffer(1).unwrap();
534+
assert_eq!(count_gif_frames(&intermediate), 3, "First pass should produce 3 frames");
535+
536+
// Second pass: resize the already-encoded GIF output (this was the crash in #643)
537+
let steps2 = vec![
538+
Node::Decode { io_id: 0, commands: None },
539+
Node::Resample2D {
540+
w: 4,
541+
h: 4,
542+
hints: Some(ResampleHints::new().with_bi_filter(Filter::Hermite)),
543+
},
544+
Node::Encode { io_id: 1, preset: EncoderPreset::Gif },
545+
];
546+
let mut ctx2 = Context::create().unwrap();
547+
ctx2.add_input_vector(0, intermediate).unwrap();
548+
ctx2.add_output_buffer(1).unwrap();
549+
ctx2.execute_1(Execute001 {
550+
graph_recording: default_graph_recording(false),
551+
security: None,
552+
framewise: Framewise::Steps(steps2),
553+
})
554+
.unwrap();
555+
let final_output = ctx2.take_output_buffer(1).unwrap();
556+
assert_eq!(
557+
count_gif_frames(&final_output),
558+
3,
559+
"Second pass should also produce 3 frames without EOF crash"
560+
);
561+
}
562+
563+
// ============================================================================
564+
// Issue #653: Animated GIF with transparent background
565+
// ============================================================================
566+
567+
#[test]
568+
fn test_animated_gif_transparent_bg_roundtrip() {
569+
test_init();
570+
// Build GIF with semi-transparent frames
571+
let input = build_animated_gif(8, 8, &["FF000080", "00FF0080", "0000FF80"], 10);
572+
let output = roundtrip_animated_gif(input, EncoderPreset::Gif);
573+
assert_eq!(count_gif_frames(&output), 3, "Transparent animated GIF should preserve 3 frames");
574+
}
575+
576+
#[test]
577+
fn test_animated_gif_transparent_bg_resize() {
578+
test_init();
579+
// Transparent animated GIF → resize → GIF should not lose transparency
580+
let input = build_animated_gif(16, 16, &["FF000000", "00FF0000"], 10);
581+
let steps = vec![
582+
Node::Decode { io_id: 0, commands: None },
583+
Node::Resample2D {
584+
w: 8,
585+
h: 8,
586+
hints: Some(ResampleHints::new().with_bi_filter(Filter::Hermite)),
587+
},
588+
Node::Encode { io_id: 1, preset: EncoderPreset::Gif },
589+
];
590+
let mut ctx = Context::create().unwrap();
591+
ctx.add_input_vector(0, input).unwrap();
592+
ctx.add_output_buffer(1).unwrap();
593+
ctx.execute_1(Execute001 {
594+
graph_recording: default_graph_recording(false),
595+
security: None,
596+
framewise: Framewise::Steps(steps),
597+
})
598+
.unwrap();
599+
let output = ctx.take_output_buffer(1).unwrap();
600+
assert_eq!(count_gif_frames(&output), 2, "Should preserve 2 frames");
601+
}
602+
603+
// ============================================================================
604+
// Animated GIF with resize to various single-frame formats (verify no crash)
605+
// ============================================================================
606+
607+
#[test]
608+
fn test_animated_gif_resize_to_all_single_frame_formats() {
609+
test_init();
610+
let input = build_animated_gif(16, 16, &["FF0000", "00FF00", "0000FF"], 10);
611+
612+
let presets: Vec<(&str, EncoderPreset)> = vec![
613+
("png", EncoderPreset::Lodepng { maximum_deflate: None }),
614+
("mozjpeg", EncoderPreset::Mozjpeg { progressive: None, quality: Some(80), matte: None }),
615+
("webp_lossy", EncoderPreset::WebPLossy { quality: 80.0 }),
616+
("webp_lossless", EncoderPreset::WebPLossless),
617+
("jxl_lossy", EncoderPreset::JxlLossy { distance: 2.0 }),
618+
("jxl_lossless", EncoderPreset::JxlLossless),
619+
];
620+
621+
for (name, preset) in presets {
622+
let steps = vec![
623+
Node::Decode {
624+
io_id: 0,
625+
commands: Some(vec![imageflow_types::DecoderCommand::SelectFrame(1)]),
626+
},
627+
Node::Resample2D {
628+
w: 8,
629+
h: 8,
630+
hints: Some(ResampleHints::new().with_bi_filter(Filter::Hermite)),
631+
},
632+
Node::Encode { io_id: 1, preset },
633+
];
634+
let mut ctx = Context::create().unwrap();
635+
ctx.add_copied_input_buffer(0, &input).unwrap();
636+
ctx.add_output_buffer(1).unwrap();
637+
ctx.execute_1(Execute001 {
638+
graph_recording: default_graph_recording(false),
639+
security: None,
640+
framewise: Framewise::Steps(steps),
641+
})
642+
.unwrap_or_else(|e| panic!("Failed to encode animated GIF frame to {name}: {e}"));
643+
let output = ctx.take_output_buffer(1).unwrap();
644+
assert!(
645+
output.len() > 10,
646+
"{name}: output should have content (got {} bytes)",
647+
output.len()
648+
);
649+
}
650+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# composition.checksums — v1
2+
3+
## test_draw_image_exact_on_canvas dice_at_50_50
4+
tolerance off-by-one
5+
~ oily-bass-2e02345744:sea x86_64-avx512 @364aaede new-baseline
6+
7+
## test_watermark_alpha_on_alpha dice_on_shirt_70pct
8+
tolerance off-by-one
9+
~ cheap-bird-3d51cccc2c:sea x86_64-avx512 @364aaede new-baseline
10+
11+
## test_watermark_alpha_on_jpeg dice_center_50pct_opacity
12+
tolerance off-by-one
13+
~ rural-bay-61a0fb8816:sea x86_64-avx512 @364aaede new-baseline

0 commit comments

Comments
 (0)