From 8ba4f36c50ca8104cb75bc25639dc6cd648df421 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 20 Jul 2026 21:23:44 +0530 Subject: [PATCH 1/2] fix: coalesce frames when cropping animated WebP GIF already coalesced before resize; animated WebP took the static path and scaled delta frames independently, causing blocky ghosting. Use the same coalesce/deconstruct path for any multi-frame image. Co-authored-by: Cursor --- src/Image/Image.php | 21 +++++++----- tests/Image/ImageTest.php | 44 +++++++++++++++++++++++++ tests/resources/disk-a/anim-delta.webp | Bin 0 -> 716 bytes 3 files changed, 56 insertions(+), 9 deletions(-) create mode 100644 tests/resources/disk-a/anim-delta.webp diff --git a/src/Image/Image.php b/src/Image/Image.php index 146c19a..66e1314 100644 --- a/src/Image/Image.php +++ b/src/Image/Image.php @@ -177,7 +177,11 @@ public function crop(int $width, int $height, string $gravity = Image::GRAVITY_C $x = intval($x); $y = intval($y); - if ($this->image->getImageFormat() == 'GIF') { + // Animated formats (GIF, WebP, etc.) store delta/partial frames. Resize + // each coalesced full frame, then deconstruct back to dirty rectangles. + // Without coalesce, partial frames are scaled independently and produce + // blocky ghosting artifacts — especially visible on animated WebP. + if ($this->image->getNumberImages() > 1) { $this->image = $this->image->coalesceImages(); foreach ($this->image as $frame) { @@ -188,19 +192,18 @@ public function crop(int $width, int $height, string $gravity = Image::GRAVITY_C $frame->cropImage($width, $height, $x, $y); $frame->thumbnailImage($width, $height); } + + $frame->setImagePage($width, $height, 0, 0); } $this->image->deconstructImages(); + } elseif ($gravity === self::GRAVITY_CENTER) { + $this->image->cropThumbnailImage($width, $height); } else { - foreach ($this->image as $frame) { - if ($gravity === self::GRAVITY_CENTER) { - $this->image->cropThumbnailImage($width, $height); - } else { - $this->image->scaleImage($resizeWidth, $resizeHeight, false); - $this->image->cropImage($width, $height, $x, $y); - } - } + $this->image->scaleImage($resizeWidth, $resizeHeight, false); + $this->image->cropImage($width, $height, $x, $y); } + $this->height = $height; $this->width = $width; diff --git a/tests/Image/ImageTest.php b/tests/Image/ImageTest.php index 01ef850..4c7bc95 100644 --- a/tests/Image/ImageTest.php +++ b/tests/Image/ImageTest.php @@ -859,4 +859,48 @@ public function test_gif_small_last_frame(): void \unlink($target); } + + /** + * Animated WebP stores delta/partial frames. Cropping without coalesce + * scales those fragments independently and produces ghosting artifacts. + */ + public function test_crop_animated_webp_preserves_frames(): void + { + $source = __DIR__.'/../resources/disk-a/anim-delta.webp'; + $image = new Image(\file_get_contents($source) ?: ''); + $target = __DIR__.'/anim-delta-32x32.webp'; + + $image->crop(32, 32); + $image->save($target, 'webp', 100); + + $this->assertFileExists($target); + $this->assertNotEmpty(\file_get_contents($target)); + + $output = new \Imagick($target); + $this->assertGreaterThan(1, $output->getNumberImages()); + $this->assertTrue(\in_array($output->getImageFormat(), ['PAM', 'WEBP'], true)); + + $coalesced = $output->coalesceImages(); + foreach ($coalesced as $frame) { + $this->assertEquals(32, $frame->getImageWidth()); + $this->assertEquals(32, $frame->getImageHeight()); + } + + // Frame 0 has a red square near the top-left; after a correct resize + // that region must stay red — not blended with later frames. + $coalesced->setFirstIterator(); + $pixel = $coalesced->getImagePixelColor(8, 8)->getColor(); + $this->assertGreaterThan(200, $pixel['r']); + $this->assertLessThan(100, $pixel['g']); + $this->assertLessThan(100, $pixel['b']); + + // Frame 1 replaces that region with green on a correct crop. + $coalesced->nextImage(); + $pixel = $coalesced->getImagePixelColor(8, 8)->getColor(); + $this->assertLessThan(100, $pixel['r']); + $this->assertGreaterThan(180, $pixel['g']); + $this->assertLessThan(150, $pixel['b']); + + \unlink($target); + } } diff --git a/tests/resources/disk-a/anim-delta.webp b/tests/resources/disk-a/anim-delta.webp new file mode 100644 index 0000000000000000000000000000000000000000..30f5db7ebbc87d79f8998e3fb882c6a0483691b7 GIT binary patch literal 716 zcmWIYbaOkx#J~{l>J$(bU=hK^z`y|HGT4KWqo1cQ8<6)O2pAX~{e0an0C^AqQIP^v z4%DZx11K24#xR#r%Yngx!EU0{Trbtdix#fnVAvqUnapthf@sy6Io#FK6Br~s%bs^btd@+6E@tF1F#P-B>Hk7;yXV2zvQ?&XN0;0?V*PA>{v__0yH`J4l8T%- z>7@IE-xuz`nz&27>`MFV@J)R)j)ZRv_ZKoLRr8elH}kfoPX2|yx4%~1Iq}EaN7=EU zn7!smsQ#;8dsNmX+*xz*;k*O+RgAgS_k62=id}6vQo4pKhIdiW3J;*WLH-H@(m;O$ zkqiTa1Oo%mUko6B*#YSK#lhfpx_5qM7%r90mu6)nG>Lh zPi3gokufP|=sW+wEatgPvfRX0zs1LH*0fvM?_R Date: Mon, 20 Jul 2026 21:34:32 +0530 Subject: [PATCH 2/2] fix: skip deconstruct after animated crop to preserve hold frames deconstructImages() can collapse identical pause frames into empty deltas that WebP encoding drops, zeroing playback delay. Keep coalesced frames and assert total delay is preserved. Co-authored-by: Cursor --- src/Image/Image.php | 7 ------- tests/Image/ImageTest.php | 42 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/src/Image/Image.php b/src/Image/Image.php index 66e1314..9b56ca0 100644 --- a/src/Image/Image.php +++ b/src/Image/Image.php @@ -177,10 +177,6 @@ public function crop(int $width, int $height, string $gravity = Image::GRAVITY_C $x = intval($x); $y = intval($y); - // Animated formats (GIF, WebP, etc.) store delta/partial frames. Resize - // each coalesced full frame, then deconstruct back to dirty rectangles. - // Without coalesce, partial frames are scaled independently and produce - // blocky ghosting artifacts — especially visible on animated WebP. if ($this->image->getNumberImages() > 1) { $this->image = $this->image->coalesceImages(); @@ -195,15 +191,12 @@ public function crop(int $width, int $height, string $gravity = Image::GRAVITY_C $frame->setImagePage($width, $height, 0, 0); } - - $this->image->deconstructImages(); } elseif ($gravity === self::GRAVITY_CENTER) { $this->image->cropThumbnailImage($width, $height); } else { $this->image->scaleImage($resizeWidth, $resizeHeight, false); $this->image->cropImage($width, $height, $x, $y); } - $this->height = $height; $this->width = $width; diff --git a/tests/Image/ImageTest.php b/tests/Image/ImageTest.php index 4c7bc95..741e269 100644 --- a/tests/Image/ImageTest.php +++ b/tests/Image/ImageTest.php @@ -903,4 +903,46 @@ public function test_crop_animated_webp_preserves_frames(): void \unlink($target); } + + /** + * Consecutive identical frames are hold/pause frames. Cropping must keep + * total playback delay — deconstructImages() + WebP encode can zero it out. + */ + public function test_crop_animated_webp_preserves_hold_frames(): void + { + $sequence = new \Imagick; + foreach (['#ff0000', '#ff0000', '#0000ff'] as $color) { + $frame = new \Imagick; + $frame->newImage(40, 40, new \ImagickPixel($color)); + $frame->setImageDelay(40); + $frame->setImageDispose(\Imagick::DISPOSE_NONE); + $frame->setImageFormat('gif'); + $sequence->addImage($frame); + } + + $blob = $sequence->getImagesBlob(); + $this->assertNotFalse($blob); + + $image = new Image($blob); + $image->crop(20, 20); + $outputBlob = $image->output('webp', 100); + $this->assertNotFalse($outputBlob); + $this->assertNotNull($outputBlob); + + $output = new \Imagick; + $output->readImageBlob($outputBlob); + + $totalDelay = 0; + foreach ($output as $frame) { + $totalDelay += $frame->getImageDelay(); + } + $this->assertEquals(120, $totalDelay); + $this->assertGreaterThanOrEqual(2, $output->getNumberImages()); + + $coalesced = $output->coalesceImages(); + foreach ($coalesced as $frame) { + $this->assertEquals(20, $frame->getImageWidth()); + $this->assertEquals(20, $frame->getImageHeight()); + } + } }