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'], ], ];