|
1 | 1 | use crate::common::*; |
2 | 2 | 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 | +}; |
4 | 6 |
|
5 | 7 | use super::smoke::build_animated_gif; |
6 | 8 |
|
@@ -467,3 +469,182 @@ fn test_animated_gif_pixel_colors_preserved() { |
467 | 469 | } |
468 | 470 | } |
469 | 471 | } |
| 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 | +} |
0 commit comments