diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 52e2333085ca2..db585c038b745 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -2133,6 +2133,8 @@ 'OC\\PhoneNumberUtil' => $baseDir . '/lib/private/PhoneNumberUtil.php', 'OC\\PreviewManager' => $baseDir . '/lib/private/PreviewManager.php', 'OC\\PreviewNotAvailableException' => $baseDir . '/lib/private/PreviewNotAvailableException.php', + 'OC\\Preview\\AVIF' => $baseDir . '/lib/private/Preview/AVIF.php', + 'OC\\Preview\\AVIFImagick' => $baseDir . '/lib/private/Preview/AVIFImagick.php', 'OC\\Preview\\BMP' => $baseDir . '/lib/private/Preview/BMP.php', 'OC\\Preview\\BackgroundCleanupJob' => $baseDir . '/lib/private/Preview/BackgroundCleanupJob.php', 'OC\\Preview\\Bitmap' => $baseDir . '/lib/private/Preview/Bitmap.php', @@ -2145,6 +2147,7 @@ 'OC\\Preview\\Generator' => $baseDir . '/lib/private/Preview/Generator.php', 'OC\\Preview\\GeneratorHelper' => $baseDir . '/lib/private/Preview/GeneratorHelper.php', 'OC\\Preview\\HEIC' => $baseDir . '/lib/private/Preview/HEIC.php', + 'OC\\Preview\\Heif' => $baseDir . '/lib/private/Preview/Heif.php', 'OC\\Preview\\IMagickSupport' => $baseDir . '/lib/private/Preview/IMagickSupport.php', 'OC\\Preview\\Illustrator' => $baseDir . '/lib/private/Preview/Illustrator.php', 'OC\\Preview\\Image' => $baseDir . '/lib/private/Preview/Image.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index f29ac210c3700..83f4ece61a404 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -2174,6 +2174,8 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\PhoneNumberUtil' => __DIR__ . '/../../..' . '/lib/private/PhoneNumberUtil.php', 'OC\\PreviewManager' => __DIR__ . '/../../..' . '/lib/private/PreviewManager.php', 'OC\\PreviewNotAvailableException' => __DIR__ . '/../../..' . '/lib/private/PreviewNotAvailableException.php', + 'OC\\Preview\\AVIF' => __DIR__ . '/../../..' . '/lib/private/Preview/AVIF.php', + 'OC\\Preview\\AVIFImagick' => __DIR__ . '/../../..' . '/lib/private/Preview/AVIFImagick.php', 'OC\\Preview\\BMP' => __DIR__ . '/../../..' . '/lib/private/Preview/BMP.php', 'OC\\Preview\\BackgroundCleanupJob' => __DIR__ . '/../../..' . '/lib/private/Preview/BackgroundCleanupJob.php', 'OC\\Preview\\Bitmap' => __DIR__ . '/../../..' . '/lib/private/Preview/Bitmap.php', @@ -2186,6 +2188,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Preview\\Generator' => __DIR__ . '/../../..' . '/lib/private/Preview/Generator.php', 'OC\\Preview\\GeneratorHelper' => __DIR__ . '/../../..' . '/lib/private/Preview/GeneratorHelper.php', 'OC\\Preview\\HEIC' => __DIR__ . '/../../..' . '/lib/private/Preview/HEIC.php', + 'OC\\Preview\\Heif' => __DIR__ . '/../../..' . '/lib/private/Preview/Heif.php', 'OC\\Preview\\IMagickSupport' => __DIR__ . '/../../..' . '/lib/private/Preview/IMagickSupport.php', 'OC\\Preview\\Illustrator' => __DIR__ . '/../../..' . '/lib/private/Preview/Illustrator.php', 'OC\\Preview\\Image' => __DIR__ . '/../../..' . '/lib/private/Preview/Image.php', diff --git a/lib/private/Image.php b/lib/private/Image.php index 015f42bfe6b22..85f3e94a2b3e4 100644 --- a/lib/private/Image.php +++ b/lib/private/Image.php @@ -248,6 +248,9 @@ private function _output(?string $filePath = null, ?string $mimeType = null): bo case 'image/webp': $imageType = IMAGETYPE_WEBP; break; + case 'image/avif': + $imageType = IMAGETYPE_AVIF; + break; default: throw new \Exception('Image::_output(): "' . $mimeType . '" is not supported when forcing a specific output format'); } @@ -281,6 +284,9 @@ private function _output(?string $filePath = null, ?string $mimeType = null): bo case IMAGETYPE_WEBP: $retVal = imagewebp($this->resource, null, $this->getWebpQuality()); break; + case IMAGETYPE_AVIF: + $retVal = imageavif($this->resource, $filePath); + break; default: $retVal = imagepng($this->resource, $filePath); } @@ -747,6 +753,19 @@ public function loadFromFile($imagePath = false) { $this->logger->debug('Image->loadFromFile, WEBP images not supported: ' . $imagePath, ['app' => 'core']); } break; + case IMAGETYPE_AVIF: + if (imagetypes() & IMG_AVIF) { + if (!$this->checkImageSize($imagePath)) { + return false; + } + // An animated AVIF decodes to its first frame, which is the + // right thing for a preview, and a sequence libgd cannot read + // returns false and is handled below like any other failure + $this->resource = @imagecreatefromavif($imagePath); + } else { + $this->logger->debug('Image->loadFromFile, AVIF images not supported: ' . $imagePath, ['app' => 'core']); + } + break; /* case IMAGETYPE_TIFF_II: // (intel byte order) break; diff --git a/lib/private/Preview/AVIF.php b/lib/private/Preview/AVIF.php new file mode 100644 index 0000000000000..8111998713f47 --- /dev/null +++ b/lib/private/Preview/AVIF.php @@ -0,0 +1,20 @@ +isAvailable($file)) { - return null; - } - - $tmpPath = $this->getLocalFile($file); - if ($tmpPath === false) { - Server::get(LoggerInterface::class)->error( - 'Failed to get local file to generate thumbnail for: ' . $file->getPath(), - ['app' => 'core'] - ); - return null; - } - - // Creates \Imagick object from the heic file - try { - $bp = $this->getResizedPreview($tmpPath, $maxX, $maxY); - $bp->setFormat('jpg'); - } catch (\Exception $e) { - Server::get(LoggerInterface::class)->error( - 'File: ' . $file->getPath() . ' Imagick says:', - [ - 'exception' => $e, - 'app' => 'core', - ] - ); - return null; - } - - $this->cleanTmpFiles(); - - //new bitmap image object - $image = new Image(); - $image->loadFromData((string)$bp); - //check if image object is valid - return $image->valid() ? $image : null; - } - - /** - * Returns a preview of maxX times maxY dimensions in JPG format - * - * * The default resolution is already 72dpi, no need to change it for a bitmap output - * * It's possible to have proper colour conversion using profileimage(). - * ICC profiles are here: http://www.color.org/srgbprofiles.xalter - * * It's possible to Gamma-correct an image via gammaImage() - * - * @param string $tmpPath the location of the file to convert - * @param int $maxX - * @param int $maxY - * - * @return \Imagick - * - * @throws \Exception - */ - private function getResizedPreview($tmpPath, $maxX, $maxY) { - $bp = new \Imagick(); - - // Some HEIC files just contain (or at least are identified as) other formats - // like JPEG. We just need to check if the image is safe to process. - $bp->pingImage('heic:' . $tmpPath . '[0]'); - $mimeType = $bp->getImageMimeType(); - if (!preg_match('/^image\/(x-)?(png|jpeg|gif|bmp|tiff|webp|hei(f|c)|avif)$/', $mimeType)) { - throw new \Exception('File mime type does not match the preview provider: ' . $mimeType); - } - - // Layer 0 contains either the bitmap or a flat representation of all vector layers - $bp->readImage('heic:' . $tmpPath . '[0]'); - - // Fix orientation from EXIF - $bp->autoOrient(); - - $bp->setImageFormat('jpg'); - - $bp = $this->resize($bp, $maxX, $maxY); - - return $bp; - } - - /** - * Returns a resized \Imagick object - * - * If you want to know more on the various methods available to resize an - * image, check out this link : @link https://stackoverflow.com/questions/8517304/what-the-difference-of-sample-resample-scale-resize-adaptive-resize-thumbnail-im - * - * @param \Imagick $bp - * @param int $maxX - * @param int $maxY - * - * @return \Imagick - */ - private function resize($bp, $maxX, $maxY) { - [$previewWidth, $previewHeight] = array_values($bp->getImageGeometry()); - - // We only need to resize a preview which doesn't fit in the maximum dimensions - if ($previewWidth > $maxX || $previewHeight > $maxY) { - // If we want a small image (thumbnail) let's be most space- and time-efficient - if ($maxX <= 500 && $maxY <= 500) { - $bp->thumbnailImage($maxY, $maxX, true); - $bp->stripImage(); - } else { - // A bigger image calls for some better resizing algorithm - // According to http://www.imagemagick.org/Usage/filter/#lanczos - // the catrom filter is almost identical to Lanczos2, but according - // to https://www.php.net/manual/en/imagick.resizeimage.php it is - // significantly faster - $bp->resizeImage($maxX, $maxY, \Imagick::FILTER_CATROM, 1, true); - } - } - - return $bp; + protected function queryFormat(): string { + return 'HEIC'; } } diff --git a/lib/private/Preview/Heif.php b/lib/private/Preview/Heif.php new file mode 100644 index 0000000000000..deb1cf1e982b2 --- /dev/null +++ b/lib/private/Preview/Heif.php @@ -0,0 +1,168 @@ +queryFormat(), \Imagick::queryFormats($this->queryFormat()), true); + } + + /** + * {@inheritDoc} + */ + #[\Override] + public function getThumbnail(File $file, int $maxX, int $maxY): ?IImage { + if (!$this->isAvailable($file)) { + return null; + } + + $tmpPath = $this->getLocalFile($file); + if ($tmpPath === false) { + Server::get(LoggerInterface::class)->error( + 'Failed to get local file to generate thumbnail for: ' . $file->getPath(), + ['app' => 'core'] + ); + return null; + } + + // Creates \Imagick object from the file + try { + $bp = $this->getResizedPreview($tmpPath, $maxX, $maxY); + $bp->setFormat('jpg'); + } catch (\Exception $e) { + Server::get(LoggerInterface::class)->error( + 'File: ' . $file->getPath() . ' Imagick says:', + [ + 'exception' => $e, + 'app' => 'core', + ] + ); + return null; + } + + $this->cleanTmpFiles(); + + //new bitmap image object + $image = new Image(); + $image->loadFromData((string)$bp); + //check if image object is valid + return $image->valid() ? $image : null; + } + + /** + * Returns a preview of maxX times maxY dimensions in JPG format + * + * * The default resolution is already 72dpi, no need to change it for a bitmap output + * * It's possible to have proper colour conversion using profileimage(). + * ICC profiles are here: http://www.color.org/srgbprofiles.xalter + * * It's possible to Gamma-correct an image via gammaImage() + * + * @param string $tmpPath the location of the file to convert + * @param int $maxX + * @param int $maxY + * + * @return \Imagick + * + * @throws \Exception + */ + private function getResizedPreview($tmpPath, $maxX, $maxY) { + $bp = new \Imagick(); + + // Some files just contain (or at least are identified as) other formats + // like JPEG. We just need to check if the image is safe to process. + $bp->pingImage($this->formatHint() . ':' . $tmpPath . '[0]'); + $mimeType = $bp->getImageMimeType(); + if (!preg_match('/^image\/(x-)?(png|jpeg|gif|bmp|tiff|webp|hei(f|c)|avif)$/', $mimeType)) { + throw new \Exception('File mime type does not match the preview provider: ' . $mimeType); + } + + // Layer 0 contains either the bitmap or a flat representation of all vector layers + $bp->readImage($this->formatHint() . ':' . $tmpPath . '[0]'); + + // Fix orientation from EXIF + $bp->autoOrient(); + + $bp->setImageFormat('jpg'); + + $bp = $this->resize($bp, $maxX, $maxY); + + return $bp; + } + + /** + * Returns a resized \Imagick object + * + * If you want to know more on the various methods available to resize an + * image, check out this link : @link https://stackoverflow.com/questions/8517304/what-the-difference-of-sample-resample-scale-resize-adaptive-resize-thumbnail-im + * + * @param \Imagick $bp + * @param int $maxX + * @param int $maxY + * + * @return \Imagick + */ + private function resize($bp, $maxX, $maxY) { + [$previewWidth, $previewHeight] = array_values($bp->getImageGeometry()); + + // We only need to resize a preview which doesn't fit in the maximum dimensions + if ($previewWidth > $maxX || $previewHeight > $maxY) { + // If we want a small image (thumbnail) let's be most space- and time-efficient + if ($maxX <= 500 && $maxY <= 500) { + $bp->thumbnailImage($maxY, $maxX, true); + $bp->stripImage(); + } else { + // A bigger image calls for some better resizing algorithm + // According to http://www.imagemagick.org/Usage/filter/#lanczos + // the catrom filter is almost identical to Lanczos2, but according + // to https://www.php.net/manual/en/imagick.resizeimage.php it is + // significantly faster + $bp->resizeImage($maxX, $maxY, \Imagick::FILTER_CATROM, 1, true); + } + } + + return $bp; + } +} diff --git a/lib/private/PreviewManager.php b/lib/private/PreviewManager.php index 47f0ea1230fee..d8020cc356fe6 100644 --- a/lib/private/PreviewManager.php +++ b/lib/private/PreviewManager.php @@ -10,6 +10,8 @@ use Closure; use OC\AppFramework\Bootstrap\Coordinator; +use OC\Preview\AVIF; +use OC\Preview\AVIFImagick; use OC\Preview\BMP; use OC\Preview\Db\PreviewMapper; use OC\Preview\EMF; @@ -271,6 +273,7 @@ protected function getEnabledDefaultProvider(): array { XBitmap::class, Krita::class, WebP::class, + AVIF::class, ]; $this->defaultProviders = $this->config->getSystemValue('enabledPreviewProviders', array_merge([ @@ -317,6 +320,7 @@ protected function registerCoreProviders(): void { $this->registerCoreProvider(BMP::class, '/image\/bmp/'); $this->registerCoreProvider(XBitmap::class, '/image\/x-xbitmap/'); $this->registerCoreProvider(WebP::class, '/image\/webp/'); + $this->registerCoreProvider(AVIF::class, '/image\/avif/'); $this->registerCoreProvider(Krita::class, '/application\/x-krita/'); $this->registerCoreProvider(MP3::class, '/audio\/mpeg$/'); $this->registerCoreProvider(OpenDocument::class, '/application\/vnd.oasis.opendocument.*/'); @@ -334,6 +338,7 @@ protected function registerCoreProviders(): void { 'EPS' => ['mimetype' => '/application\/postscript/', 'class' => Postscript::class], 'TTF' => ['mimetype' => '/application\/(?:font-sfnt|x-font$)/', 'class' => Font::class], 'HEIC' => ['mimetype' => '/image\/(x-)?hei(f|c)/', 'class' => HEIC::class], + 'AVIF' => ['mimetype' => '/image\/avif/', 'class' => AVIFImagick::class], 'TGA' => ['mimetype' => '/image\/(x-)?t(ar)?ga/', 'class' => TGA::class], 'SGI' => ['mimetype' => '/image\/(x-)?sgi/', 'class' => SGI::class], ]; diff --git a/tests/data/REUSE.toml b/tests/data/REUSE.toml index 3a19ce29f8384..34ea37845a0ef 100644 --- a/tests/data/REUSE.toml +++ b/tests/data/REUSE.toml @@ -34,7 +34,7 @@ SPDX-FileCopyrightText = "2012 ownCloud, Inc." SPDX-License-Identifier = "AGPL-3.0-only" [[annotations]] -path = ["testavatar.png", "testimage.gif", "testimage.jpg", "testimage.png"] +path = ["testavatar.png", "testimage.avif", "testimage.gif", "testimage.jpg", "testimage.png"] precedence = "aggregate" SPDX-FileCopyrightText = "2013 ownCloud, Inc." SPDX-License-Identifier = "AGPL-3.0-only" diff --git a/tests/data/testimage.avif b/tests/data/testimage.avif new file mode 100644 index 0000000000000..fede073100c57 Binary files /dev/null and b/tests/data/testimage.avif differ diff --git a/tests/lib/Preview/AVIFImagickTest.php b/tests/lib/Preview/AVIFImagickTest.php new file mode 100644 index 0000000000000..cc7e9b92624ef --- /dev/null +++ b/tests/lib/Preview/AVIFImagickTest.php @@ -0,0 +1,54 @@ +markTestSkipped('ImageMagick is not installed. Skipping tests'); + } + if (!in_array('AVIF', \Imagick::queryFormats('AVIF'), true)) { + $this->markTestSkipped('ImageMagick was built without AVIF. Skipping tests'); + } + + $fileName = 'testimage.avif'; + $sourcePath = \OC::$SERVERROOT . '/tests/data/' . $fileName; + + // Reporting the coder is not the same as being able to use it: the + // libheif delegate may be missing, or policy.xml may have disabled + // it, in which case decoding throws and the tests would fail rather + // than skip. Decode once for real before committing to them. + try { + (new \Imagick())->readImage('avif:' . $sourcePath . '[0]'); + } catch (\ImagickException $e) { + $this->markTestSkipped('ImageMagick cannot decode AVIF here: ' . $e->getMessage() . '. Skipping tests'); + } + + parent::setUp(); + + $this->imgPath = $this->prepareTestFile($fileName, $sourcePath); + $this->width = 1680; + $this->height = 1050; + $this->provider = new AVIFImagick(); + } + + public function testPreviewCarriesThePicture(): void { + $this->assertPreviewShowsThePicture(); + } +} diff --git a/tests/lib/Preview/AVIFTest.php b/tests/lib/Preview/AVIFTest.php new file mode 100644 index 0000000000000..48d91aecbe8e9 --- /dev/null +++ b/tests/lib/Preview/AVIFTest.php @@ -0,0 +1,55 @@ +loadFromFile($sourcePath); + if (!$probe->valid()) { + $this->markTestSkipped(sprintf( + 'libgd cannot read AVIF here (IMG_AVIF=%s, exif_imagetype=%s, getimagesize type=%s, imagecreatefromavif=%s). Skipping tests', + (imagetypes() & IMG_AVIF) ? 'yes' : 'no', + var_export(@exif_imagetype($sourcePath), true), + var_export(@getimagesize($sourcePath)[2] ?? false, true), + @imagecreatefromavif($sourcePath) === false ? 'failed' : 'ok', + )); + } + + parent::setUp(); + + $this->imgPath = $this->prepareTestFile($fileName, $sourcePath); + $this->width = 1680; + $this->height = 1050; + $this->provider = new AVIF(); + } + + public function testPreviewCarriesThePicture(): void { + $this->assertPreviewShowsThePicture(); + } +} diff --git a/tests/lib/Preview/AvifPreviewTrait.php b/tests/lib/Preview/AvifPreviewTrait.php new file mode 100644 index 0000000000000..5a8fb4fbc2846 --- /dev/null +++ b/tests/lib/Preview/AvifPreviewTrait.php @@ -0,0 +1,84 @@ +assertNotFalse($image, 'the preview is not a readable image'); + + $width = imagesx($image); + $height = imagesy($image); + $stepX = max(1, intdiv($width, 16)); + $stepY = max(1, intdiv($height, 16)); + + $red = $green = $blue = 0; + $samples = 0; + for ($y = 0; $y < $height; $y += $stepY) { + for ($x = 0; $x < $width; $x += $stepX) { + $colour = imagecolorat($image, $x, $y); + $red += ($colour >> 16) & 0xFF; + $green += ($colour >> 8) & 0xFF; + $blue += $colour & 0xFF; + $samples++; + } + } + imagedestroy($image); + + return [$red / $samples, $green / $samples, $blue / $samples]; + } + + /** + * Generate a preview and assert it carries the picture the file holds. + */ + protected function assertPreviewShowsThePicture(): void { + $file = new File(Server::get(IRootFolder::class), $this->rootView, $this->imgPath); + $preview = $this->provider->getThumbnail($file, 256, 256); + + $this->assertNotNull($preview, 'no preview was produced'); + $this->assertTrue($preview->valid()); + // Smaller than it was, so something actually resized it + $this->assertLessThanOrEqual(256, $preview->width()); + $this->assertLessThanOrEqual(256, $preview->height()); + + // The fixture is a re-encode of testimage.jpg, so the two hold the + // same picture: a strong magenta whose average survives both the + // encoding and the scaling. A blank or black canvas misses by ~250. + $expected = $this->meanColour(file_get_contents(\OC::$SERVERROOT . '/tests/data/testimage.jpg')); + $actual = $this->meanColour($preview->data()); + + foreach ([0 => 'red', 1 => 'green', 2 => 'blue'] as $channel => $name) { + $this->assertEqualsWithDelta( + $expected[$channel], + $actual[$channel], + $this->tolerance, + "the preview's average $name is not the picture's", + ); + } + } +}