Create a WebP variant of the source file
authorAlexander Ebert <ebert@woltlab.com>
Sat, 1 Jun 2024 12:38:11 +0000 (14:38 +0200)
committerAlexander Ebert <ebert@woltlab.com>
Sat, 8 Jun 2024 10:20:25 +0000 (12:20 +0200)
wcfsetup/install/files/lib/data/attachment/Attachment.class.php
wcfsetup/install/files/lib/data/file/File.class.php
wcfsetup/install/files/lib/system/bbcode/AttachmentBBCode.class.php
wcfsetup/install/files/lib/system/endpoint/controller/core/files/PostGenerateThumbnails.class.php
wcfsetup/install/files/lib/system/file/processor/FileProcessor.class.php
wcfsetup/install/files/lib/system/worker/FileRebuildDataWorker.class.php
wcfsetup/setup/db/install.sql

index e601e96de97e23151076d3d2d288bb9e8c98da44..647f085a113de20f828888d9e8e193ec833f5b4f 100644 (file)
@@ -81,6 +81,11 @@ class Attachment extends DatabaseObject implements ILinkableObject, IRouteContro
         ]);
     }
 
+    public function getFullSizeImageSource(): string
+    {
+        return $this->getFile()?->getFullSizeImageSource() ?: $this->getLink();
+    }
+
     /**
      * Returns true if a user has the permission to download this attachment.
      *
index 9ad660d93d6a8c69bacd6aee5eb62c4f743a7836..3802717fab71b9781e2339466b58bb22749efedd 100644 (file)
@@ -5,6 +5,7 @@ namespace wcf\data\file;
 use wcf\action\FileDownloadAction;
 use wcf\data\DatabaseObject;
 use wcf\data\file\thumbnail\FileThumbnail;
+use wcf\system\application\ApplicationHandler;
 use wcf\system\file\processor\FileProcessor;
 use wcf\system\file\processor\IFileProcessor;
 use wcf\system\request\LinkHandler;
@@ -27,6 +28,7 @@ use wcf\util\StringUtil;
  * @property-read string $mimeType
  * @property-read int|null $width
  * @property-read int|null $height
+ * @property-read string|null $fileHashWebp
  */
 class File extends DatabaseObject
 {
@@ -65,30 +67,68 @@ class File extends DatabaseObject
         return \sprintf(
             '%d-%s-%s.%s',
             $this->fileID,
-            $this->secret,
             $this->fileHash,
+            $this->secret,
             $this->fileExtension,
         );
     }
 
-    public function getPath(): string
+    public function getSourceFilenameWebp(): ?string
+    {
+        if ($this->fileHashWebp === null) {
+            return null;
+        }
+
+        // The filename uses the hash of the source file in order to keep the
+        // source and the variant next to each. At the same time we do not
+        // include the hash of the WebP variant in the filename because it would
+        // yield an excessive filename, just the two hashes are 128 characters
+        // in total.
+        //
+        // These variants are also a bit different because they are volatile and
+        // can be regenerated from the source file at any time. The database is
+        // the source of truth anyway thus we can safely discard files if they
+        // do not match our expectations.
+        return \sprintf(
+            '%d-%s-variant.webp',
+            $this->fileID,
+            $this->fileHash,
+        );
+    }
+
+    private function getRelativePath(): string
     {
         $folderA = \substr($this->fileHash, 0, 2);
         $folderB = \substr($this->fileHash, 2, 2);
 
         return \sprintf(
-            \WCF_DIR . '_data/%s/files/%s/%s/',
+            '_data/%s/files/%s/%s/',
             $this->isStaticFile() ? 'public' : 'private',
             $folderA,
             $folderB,
         );
     }
 
+    public function getPath(): string
+    {
+        return \WCF_DIR . $this->getRelativePath();
+    }
+
     public function getPathname(): string
     {
         return $this->getPath() . $this->getSourceFilename();
     }
 
+    public function getPathnameWebp(): ?string
+    {
+        $filename = $this->getSourceFilenameWebp();
+        if ($filename === null) {
+            return null;
+        }
+
+        return $this->getPath() . $filename;
+    }
+
     public function getLink(): string
     {
         return LinkHandler::getInstance()->getControllerLink(
@@ -97,6 +137,17 @@ class File extends DatabaseObject
         );
     }
 
+    public function getFullSizeImageSource(): ?string
+    {
+        if (!$this->isImage() || !$this->isStaticFile()) {
+            return null;
+        }
+
+        $filename = $this->getSourceFilenameWebp() ?: $this->getSourceFilename();
+
+        return ApplicationHandler::getInstance()->getWCF()->getPageURL() . $this->getRelativePath() . $filename;
+    }
+
     public function getProcessor(): ?IFileProcessor
     {
         return FileProcessor::getInstance()->getProcessorById($this->objectTypeID);
index ef4af5bc774e66842fbbb639ce3a5a41c35973cd..7e63a7b363f7c05306cfd144447668eb2ee0af81 100644 (file)
@@ -87,7 +87,7 @@ final class AttachmentBBCode extends AbstractBBCode
         $title = StringUtil::encodeHTML($attachment->filename);
         $imageElement = \sprintf(
             '<img src="%s" width="%d" height="%d" alt="" loading="lazy">',
-            $source,
+            $attachment->getFullSizeImageSource(),
             $attachment->width,
             $attachment->height,
         );
index 761cb1f1cecc6ebf7adddc4d971923d5b5e3a89e..e1cca557b0cd850d05def10e85317518193fbabe 100644 (file)
@@ -22,6 +22,7 @@ final class PostGenerateThumbnails implements IController
             throw new UserInputException('id');
         }
 
+        FileProcessor::getInstance()->generateWebpVariant($file);
         FileProcessor::getInstance()->generateThumbnails($file);
 
         $thumbnails = [];
index 72533080dc60af3f6bce8efb191be972d8474c2c..855cacf33abf6bdc84fd1ecdf16c3f5a45f94dc2 100644 (file)
@@ -3,6 +3,7 @@
 namespace wcf\system\file\processor;
 
 use wcf\data\file\File;
+use wcf\data\file\FileEditor;
 use wcf\data\file\thumbnail\FileThumbnail;
 use wcf\data\file\thumbnail\FileThumbnailEditor;
 use wcf\data\file\thumbnail\FileThumbnailList;
@@ -108,6 +109,48 @@ final class FileProcessor extends SingletonFactory
         );
     }
 
+    public function generateWebpVariant(File $file): void
+    {
+        $canGenerateThumbnail = match ($file->mimeType) {
+            'image/jpeg', 'image/png' => true,
+            default => false,
+        };
+
+        if (!$canGenerateThumbnail) {
+            if ($file->fileHashWebp !== null) {
+                (new FileEditor($file))->update([
+                    'fileHashWebp' => null,
+                ]);
+            }
+
+            return;
+        }
+
+        if ($file->fileHashWebp !== null) {
+            $pathname = $file->getPathnameWebp();
+            if (\file_exists($pathname) && \hash_file('sha256', $pathname) === $file->fileHashWebp) {
+                return;
+            }
+        }
+
+        $imageAdapter = ImageHandler::getInstance()->getAdapter();
+        $imageAdapter->loadFile($file->getPathname());
+
+        $filename = FileUtil::getTemporaryFilename(extension: 'webp');
+        $imageAdapter->saveImageAs($imageAdapter->getImage(), $filename, 'webp', 80);
+
+        (new FileEditor($file))->update([
+            'fileHashWebp' => \hash_file('sha256', $filename),
+        ]);
+
+        $file = new File($file->fileID);
+
+        $pathname = $file->getPathnameWebp();
+        \assert($pathname !== null);
+
+        \rename($filename, $pathname);
+    }
+
     public function generateThumbnails(File $file): void
     {
         if (!$file->isImage()) {
index 0920178e0f24ca40a3efa20e9fd2698f44b44121..7a15b88f8f58dff9fc39a62cd866990f78e201dd 100644 (file)
@@ -2,9 +2,7 @@
 
 namespace wcf\system\worker;
 
-use wcf\data\attachment\AttachmentAction;
 use wcf\data\file\FileList;
-use wcf\system\exception\SystemException;
 use wcf\system\file\processor\FileProcessor;
 
 /**
@@ -18,7 +16,7 @@ use wcf\system\file\processor\FileProcessor;
  * @method FileList getObjectList()
  * @property-read FileList $objectList
  */
-final class FileRebuildDataWorker extends AbstractRebuildDataWorker
+final class FileRebuildDataWorker extends AbstractLinearRebuildDataWorker
 {
     /**
      * @inheritDoc
@@ -44,6 +42,7 @@ final class FileRebuildDataWorker extends AbstractRebuildDataWorker
         parent::execute();
 
         foreach ($this->objectList as $file) {
+            FileProcessor::getInstance()->generateWebpVariant($file);
             FileProcessor::getInstance()->generateThumbnails($file);
         }
     }
index 907af402e7f08f7fce9de933a32cff9269037e96..faa9ae33d562136615f1f62a92c9f627a5b1d3c0 100644 (file)
@@ -609,7 +609,8 @@ CREATE TABLE wcf1_file (
        objectTypeID INT,
        mimeType VARCHAR(255) NOT NULL,
        width INT,
-       height INT
+       height INT,
+       fileHashWebp CHAR(64),
 );
 
 DROP TABLE IF EXISTS wcf1_file_temporary;