]> BookStack Code Mirror - bookstack/commitdiff
Images: Updated GIF handling to use native methods
authorDan Brown <redacted>
Sun, 9 Jun 2024 15:58:23 +0000 (16:58 +0100)
committerDan Brown <redacted>
Sun, 9 Jun 2024 16:00:58 +0000 (17:00 +0100)
Changes GIF image thumbnail handling to direcly load via gd instead of
going through interventions own handling (which supports frames) since
we don't need animation for our thumbnails, and since performance issues
could arise with GIFs that have large frame counts.

For #5029

app/Uploads/ImageResizer.php
tests/Uploads/ImageTest.php
tests/test-data/animated.gif [new file with mode: 0644]

index d09177fff5c71966bbaab354ae5dce1e7505fe99..fa6b1cac2d447651af07f599e4618d422e85b3c2 100644 (file)
@@ -7,11 +7,13 @@ use Exception;
 use GuzzleHttp\Psr7\Utils;
 use Illuminate\Support\Facades\Cache;
 use Intervention\Image\Decoders\BinaryImageDecoder;
+use Intervention\Image\Drivers\Gd\Decoders\NativeObjectDecoder;
 use Intervention\Image\Drivers\Gd\Driver;
 use Intervention\Image\Encoders\AutoEncoder;
 use Intervention\Image\Encoders\PngEncoder;
 use Intervention\Image\Interfaces\ImageInterface as InterventionImage;
 use Intervention\Image\ImageManager;
+use Intervention\Image\Origin;
 
 class ImageResizer
 {
@@ -99,7 +101,7 @@ class ImageResizer
         }
 
         // If not in cache and thumbnail does not exist, generate thumb and cache path
-        $thumbData = $this->resizeImageData($imageData, $width, $height, $keepRatio);
+        $thumbData = $this->resizeImageData($imageData, $width, $height, $keepRatio, $this->getExtension($image));
         $disk->put($thumbFilePath, $thumbData, true);
         Cache::put($thumbCacheKey, $thumbFilePath, static::THUMBNAIL_CACHE_TIME);
 
@@ -120,7 +122,7 @@ class ImageResizer
         ?string $format = null,
     ): string {
         try {
-            $thumb = $this->interventionFromImageData($imageData);
+            $thumb = $this->interventionFromImageData($imageData, $format);
         } catch (Exception $e) {
             throw new ImageUploadException(trans('errors.cannot_create_thumbs'));
         }
@@ -154,11 +156,23 @@ class ImageResizer
      * Performs some manual library usage to ensure image is specifically loaded
      * from given binary data instead of data being misinterpreted.
      */
-    protected function interventionFromImageData(string $imageData): InterventionImage
+    protected function interventionFromImageData(string $imageData, ?string $fileType): InterventionImage
     {
         $manager = new ImageManager(new Driver());
 
-        return $manager->read($imageData, BinaryImageDecoder::class);
+        // Ensure gif images are decoded natively instead of deferring to intervention GIF
+        // handling since we don't need the added animation support.
+        $isGif = $fileType === 'gif';
+        $decoder = $isGif ? NativeObjectDecoder::class : BinaryImageDecoder::class;
+        $input = $isGif ? @imagecreatefromstring($imageData) : $imageData;
+
+        $image = $manager->read($input, $decoder);
+
+        if ($isGif) {
+            $image->setOrigin(new Origin('image/gif'));
+        }
+
+        return $image;
     }
 
     /**
@@ -209,7 +223,15 @@ class ImageResizer
      */
     protected function isGif(Image $image): bool
     {
-        return strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'gif';
+        return $this->getExtension($image) === 'gif';
+    }
+
+    /**
+     * Get the extension for the given image, normalised to lower-case.
+     */
+    protected function getExtension(Image $image): string
+    {
+        return strtolower(pathinfo($image->path, PATHINFO_EXTENSION));
     }
 
     /**
index d24b6202b3770d4adf235d7a19f2dbe6a824e4a2..db500f606691fa84de8aedea8eb68ee19d6268e8 100644 (file)
@@ -599,6 +599,40 @@ class ImageTest extends TestCase
         $this->files->deleteAtRelativePath($relPath);
     }
 
+    public function test_gif_thumbnail_generation()
+    {
+        $this->asAdmin();
+        $originalFile = $this->files->testFilePath('animated.gif');
+        $originalFileSize = filesize($originalFile);
+
+        $imgDetails = $this->files->uploadGalleryImageToPage($this, $this->entities->page(), 'animated.gif');
+        $relPath = $imgDetails['path'];
+
+        $this->assertTrue(file_exists(public_path($relPath)), 'Uploaded image found at path: ' . public_path($relPath));
+        $galleryThumb = $imgDetails['response']->thumbs->gallery;
+        $displayThumb = $imgDetails['response']->thumbs->display;
+
+        // Ensure display thumbnail is original image
+        $this->assertStringEndsWith($imgDetails['path'], $displayThumb);
+        $this->assertStringNotContainsString('thumbs', $displayThumb);
+
+        // Ensure gallery thumbnail is reduced image (single frame)
+        $galleryThumbRelPath = implode('/', array_slice(explode('/', $galleryThumb), 3));
+        $galleryThumbPath = public_path($galleryThumbRelPath);
+        $galleryFileSize = filesize($galleryThumbPath);
+
+        // Basic scan of GIF content to check frame count
+        $originalFrameCount = count(explode("\x00\x21\xF9", file_get_contents($originalFile)));
+        $galleryFrameCount = count(explode("\x00\x21\xF9", file_get_contents($galleryThumbPath)));
+
+        $this->files->deleteAtRelativePath($relPath);
+        $this->files->deleteAtRelativePath($galleryThumbRelPath);
+
+        $this->assertNotEquals($originalFileSize, $galleryFileSize);
+        $this->assertEquals(3, $originalFrameCount);
+        $this->assertEquals(1, $galleryFrameCount);
+    }
+
     protected function getTestProfileImage()
     {
         $imageName = 'profile.png';
diff --git a/tests/test-data/animated.gif b/tests/test-data/animated.gif
new file mode 100644 (file)
index 0000000..13c9d05
Binary files /dev/null and b/tests/test-data/animated.gif differ