From c58d7e470a12c7eb034e33ecc1016ee9b7d9207a Mon Sep 17 00:00:00 2001 From: Diogo Peralta Cordeiro Date: Sat, 1 May 2021 22:48:44 +0100 Subject: [PATCH] [CORE][ImageEncoder] Add width and height back in attachment entity and allow for differently sized thumbs The strategy adopted involves predicting the thumb size as we did in v2 before having vips resize --- plugins/ImageEncoder/ImageEncoder.php | 23 +++-- src/Entity/Attachment.php | 31 ++++++- src/Entity/AttachmentThumbnail.php | 120 ++++++++++++++++++++------ 3 files changed, 139 insertions(+), 35 deletions(-) diff --git a/plugins/ImageEncoder/ImageEncoder.php b/plugins/ImageEncoder/ImageEncoder.php index e777f458a1..6452e2347f 100644 --- a/plugins/ImageEncoder/ImageEncoder.php +++ b/plugins/ImageEncoder/ImageEncoder.php @@ -56,12 +56,18 @@ class ImageEncoder extends Plugin /** * Encodes the image to self::preferredType() format ensuring it's valid. * - * @param SymfonyFile $sfile i/o - * @param null|string $mimetype out + * @param \SplFileInfo $file + * @param null|string $mimetype in/out + * @param null|string $title in/out + * @param null|int $width out + * @param null|int $height out + * + * @throws Vips\Exception + * @throws \App\Util\Exception\TemporaryFileException * * @return bool */ - public function onAttachmentValidation(\SplFileInfo &$file, ?string &$mimetype, ?string &$title): bool + public function onAttachmentValidation(\SplFileInfo &$file, ?string &$mimetype, ?string &$title, ?int &$width, ?int &$height): bool { $original_mimetype = $mimetype; if (GSFile::mimetypeMajor($original_mimetype) != 'image') { @@ -97,11 +103,6 @@ class ImageEncoder extends Plugin return Event::stop; } - public function onResizeImage(Attachment $attachment, AttachmentThumbnail $thumbnail, int $width, int $height, bool $smart_crop): bool - { - return $this->onResizeImagePath($attachment->getPath(), $thumbnail->getPath(), $width, $height, $smart_crop, $__mimetype); - } - /** * Resizes an image. It will encode the image in the * `self::preferredType()` format. This only applies henceforward, @@ -123,7 +124,7 @@ class ImageEncoder extends Plugin * @return bool * */ - public function onResizeImagePath(string $source, string $destination, int $width, int $height, bool $smart_crop, ?string &$mimetype) + public function onResizeImagePath(string $source, string $destination, int &$width, int &$height, bool $smart_crop, ?string &$mimetype) { $old_limit = ini_set('memory_limit', Common::config('attachments', 'memory_limit')); try { @@ -145,6 +146,10 @@ class ImageEncoder extends Plugin if ($smart_crop) { $image = $image->smartcrop($width, $height); } + + $width = $image->width; + $height = $image->height; + $image->writeToFile($destination); unset($image); } finally { diff --git a/src/Entity/Attachment.php b/src/Entity/Attachment.php index ca943b6d11..23196ce169 100644 --- a/src/Entity/Attachment.php +++ b/src/Entity/Attachment.php @@ -55,6 +55,8 @@ class Attachment extends Entity private ?int $source; private ?int $scope; private ?int $size; + private ?int $width; + private ?int $height; private \DateTimeInterface $modified; public function setId(int $id): self @@ -189,17 +191,40 @@ class Attachment extends Entity return $this->size; } - public function setModified(DateTimeInterface $modified): self + public function setWidth(?int $width): self + { + $this->width = $width; + return $this; + } + + public function getWidth(): ?int + { + return $this->width; + } + + public function setHeight(?int $height): self + { + $this->height = $height; + return $this; + } + + public function getHeight(): ?int + { + return $this->height; + } + + public function setModified(\DateTimeInterface $modified): self { $this->modified = $modified; return $this; } - public function getModified(): DateTimeInterface + public function getModified(): \DateTimeInterface { return $this->modified; } + // }}} Autocode const URLHASH_ALGO = 'sha256'; @@ -272,6 +297,8 @@ class Attachment extends Entity 'source' => ['type' => 'int', 'default' => null, 'description' => 'Source of the Attachment (upload, TFN, embed)'], 'scope' => ['type' => 'int', 'default' => null, 'description' => 'visibility scope for this attachment'], 'size' => ['type' => 'int', 'description' => 'size of resource when available'], + 'width' => ['type' => 'int', 'description' => 'width in pixels, if it can be described as such and data is available'], + 'height' => ['type' => 'int', 'description' => 'height in pixels, if it can be described as such and data is available'], 'modified' => ['type' => 'timestamp', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was modified'], ], 'primary key' => ['id'], diff --git a/src/Entity/AttachmentThumbnail.php b/src/Entity/AttachmentThumbnail.php index 1b25b03e73..e604ae0eb1 100644 --- a/src/Entity/AttachmentThumbnail.php +++ b/src/Entity/AttachmentThumbnail.php @@ -27,10 +27,10 @@ use App\Core\Entity; use App\Core\Event; use App\Core\GSFile; use App\Core\Log; -use App\Core\Router; use App\Util\Common; use App\Util\Exception\NotFoundException; use App\Util\Exception\ServerException; +use App\Util\TemporaryFile; use DateTimeInterface; /** @@ -53,6 +53,7 @@ class AttachmentThumbnail extends Entity private int $attachment_id; private int $width; private int $height; + private string $filename; private \DateTimeInterface $modified; public function setAttachmentId(int $attachment_id): self @@ -88,17 +89,29 @@ class AttachmentThumbnail extends Entity return $this->height; } - public function setModified(DateTimeInterface $modified): self + public function setFilename(string $filename): self + { + $this->filename = $filename; + return $this; + } + + public function getFilename(): string + { + return $this->filename; + } + + public function setModified(\DateTimeInterface $modified): self { $this->modified = $modified; return $this; } - public function getModified(): DateTimeInterface + public function getModified(): \DateTimeInterface { return $this->modified; } + // }}} Autocode private Attachment $attachment; @@ -117,19 +130,27 @@ class AttachmentThumbnail extends Entity } } - public static function getOrCreate(Attachment $attachment, ?int $width = null, ?int $height = null, ?bool $crop = null) + public static function getOrCreate(Attachment $attachment, int $width, int $height, bool $crop) { try { return Cache::get('thumb-' . $attachment->getId() . "-{$width}x{$height}", - function () use ($attachment, $width, $height) { - return DB::findOneBy('attachment_thumbnail', ['attachment_id' => $attachment->getId(), 'width' => $width, 'height' => $height]); - }); + function () use ($crop, $attachment, $width, $height) { + [$predicted_width, $predicted_height] = self::predictScalingValues($attachment->getWidth(),$attachment->getHeight(), $width, $height, $crop); + return DB::findOneBy('attachment_thumbnail', ['attachment_id' => $attachment->getId(), 'width' => $predicted_width, 'height' => $predicted_height]); + }); } catch (NotFoundException $e) { - $thumbnail = self::create(['attachment_id' => $attachment->getId(), 'width' => $width, 'height' => $height, 'attachment' => $attachment]); - $event_map = ['image' => 'ResizeImage', 'video' => 'ResizeVideo']; + $ext = image_type_to_extension(IMAGETYPE_WEBP, include_dot: true); + $temp = new TemporaryFile(prefix: null, suffix: $ext); + $thumbnail = self::create(['attachment_id' => $attachment->getId()]); + $event_map = ['image' => 'ResizeImagePath', 'video' => 'ResizeVideoPath']; $major_mime = GSFile::mimetypeMajor($attachment->getMimetype()); if (in_array($major_mime, array_keys($event_map))) { - Event::handle($event_map[$major_mime], [$attachment, $thumbnail, $width, $height, $crop]); + Event::handle($event_map[$major_mime], [$attachment->getPath(), $temp->getRealPath(), &$width, &$height, $crop, &$mimetype]); + $thumbnail->setWidth($width); + $thumbnail->setHeight($height); + $filename = "{$width}x{$height}{$ext}-" . $attachment->getFileHash(); + $temp->commit(Common::config('thumbnail', 'dir') . $filename); + $thumbnail->setFilename($filename); DB::persist($thumbnail); DB::flush(); return $thumbnail; @@ -140,13 +161,6 @@ class AttachmentThumbnail extends Entity } } - public function getFilename() - { - // TODO only for images - $ext = image_type_to_extension(IMAGETYPE_WEBP, include_dot: true); - return $this->getAttachment()->getFileHash() . "-{$this->width}x{$this->height}{$ext}"; - } - public function getPath() { return Common::config('thumbnail', 'dir') . $this->getFilename(); @@ -164,8 +178,8 @@ class AttachmentThumbnail extends Entity { $attrs = [ 'height' => $this->getHeight(), - 'width' => $this->getWidth(), - 'src' => $this->getUrl(), + 'width' => $this->getWidth(), + 'src' => $this->getUrl(), ]; return $overwrite ? array_merge($orig, $attrs) : array_merge($attrs, $orig); } @@ -187,18 +201,76 @@ class AttachmentThumbnail extends Entity } } + /** + * Gets scaling values for images of various types. Cropping can be enabled. + * + * Values will scale _up_ to fit max values if cropping is enabled! + * With cropping disabled, the max value of each axis will be respected. + * + * @param $width int Original width + * @param $height int Original height + * @param $maxW int Resulting max width + * @param $maxH int Resulting max height + * @param $crop bool Crop to the size (not preserving aspect ratio) + * + * @return array [predicted width, predicted height] + */ + public static function predictScalingValues( + int $width, + int $height, + int $maxW, + int $maxH, + bool $crop + ): array + { + // Cropping data (for original image size). Default values, 0 and null, + // imply no cropping and with preserved aspect ratio (per axis). + $cx = 0; // crop x + $cy = 0; // crop y + $cw = null; // crop area width + $ch = null; // crop area height + + if ($crop) { + $s_ar = $width / $height; + $t_ar = $maxW / $maxH; + + $rw = $maxW; + $rh = $maxH; + + // Source aspect ratio differs from target, recalculate crop points! + if ($s_ar > $t_ar) { + $cx = floor($width / 2 - $height * $t_ar / 2); + $cw = ceil($height * $t_ar); + } elseif ($s_ar < $t_ar) { + $cy = floor($height / 2 - $width / $t_ar / 2); + $ch = ceil($width / $t_ar); + } + } else { + $rw = $maxW; + $rh = ceil($height * $rw / $width); + + // Scaling caused too large height, decrease to max accepted value + if ($rh > $maxH) { + $rh = $maxH; + $rw = ceil($width * $rh / $height); + } + } + return [(int)$rw, (int)$rh]; + } + public static function schemaDef(): array { return [ - 'name' => 'attachment_thumbnail', + 'name' => 'attachment_thumbnail', 'fields' => [ 'attachment_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Attachment.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'thumbnail for what attachment'], - 'width' => ['type' => 'int', 'not null' => true, 'description' => 'width of thumbnail'], - 'height' => ['type' => 'int', 'not null' => true, 'description' => 'height of thumbnail'], - 'modified' => ['type' => 'timestamp', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was modified'], + 'width' => ['type' => 'int', 'not null' => true, 'description' => 'width of thumbnail'], + 'height' => ['type' => 'int', 'not null' => true, 'description' => 'height of thumbnail'], + 'filename' => ['type' => 'varchar', 'length' => 191, 'not null' => true, 'description' => 'thubmnail filename'], + 'modified' => ['type' => 'timestamp', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was modified'], ], 'primary key' => ['attachment_id', 'width', 'height'], - 'indexes' => [ + 'indexes' => [ 'attachment_thumbnail_attachment_id_idx' => ['attachment_id'], ], ];