diff --git a/lib/private/Preview/Generator.php b/lib/private/Preview/Generator.php index 94fea7314b565..6442c0c0a6af4 100644 --- a/lib/private/Preview/Generator.php +++ b/lib/private/Preview/Generator.php @@ -126,8 +126,11 @@ public function generatePreviews(File $file, array $specifications, ?string $mim } // Get the max preview and infer the max preview sizes from that - $maxPreview = $this->getMaxPreview($previews, $file, $mimeType, $previewVersion); - $maxPreviewImage = null; // only load the image when we need it + // The image is only set if the max preview was just generated + [$maxPreview, $maxPreviewImage] = $this->getMaxPreview($previews, $file, $mimeType, $previewVersion); + if ($maxPreviewImage instanceof IStreamImage) { + $maxPreviewImage = null; + } if ($maxPreview->getSize() === 0) { $this->storageFactory->deletePreview($maxPreview); $this->previewMapper->delete($maxPreview); @@ -178,12 +181,19 @@ public function generatePreviews(File $file, array $specifications, ?string $mim throw new NotFoundException(); } - if ($maxPreviewImage === null) { - $maxPreviewImage = $this->helper->getImage(new PreviewFile($maxPreview, $this->storageFactory, $this->previewMapper)); - } - $this->logger->debug('Cached preview not found for file {path}, generating a new preview.', ['path' => $file->getPath()]); - $previewFile = $this->generatePreview($file, $maxPreviewImage, $width, $height, $crop, $maxWidth, $maxHeight, $previewVersion, $cacheResult); + $sourceImage = $maxPreviewImage === null ? $this->loadSmallerSource($previews, $maxPreview, $width, $height, $previewVersion) : null; + if ($sourceImage !== null) { + $previewFile = $this->generatePreview($file, $sourceImage, $width, $height, $crop, $sourceImage->width(), $sourceImage->height(), $previewVersion, $cacheResult); + if ($sourceImage instanceof Image) { + $sourceImage->destroy(); + } + } else { + if ($maxPreviewImage === null) { + $maxPreviewImage = $this->helper->getImage(new PreviewFile($maxPreview, $this->storageFactory, $this->previewMapper)); + } + $previewFile = $this->generatePreview($file, $maxPreviewImage, $width, $height, $crop, $maxWidth, $maxHeight, $previewVersion, $cacheResult); + } } } catch (\InvalidArgumentException $e) { throw new NotFoundException('', 0, $e); @@ -205,6 +215,46 @@ public function generatePreviews(File $file, array $specifications, ?string $mim return $previewFile; } + /** + * A cheaper resize source than the full max preview, if any + * + * @param Preview[] $previews + */ + private function loadSmallerSource(array $previews, Preview $maxPreview, int $width, int $height, ?string $version): ?IImage { + $source = $this->findResizeSource($previews, $maxPreview, $width, $height, $version); + if ($source !== null) { + return $this->helper->getImage(new PreviewFile($source, $this->storageFactory, $this->previewMapper)); + } + + // Only pays off for previews much smaller than the max preview + if (8 * $width <= $maxPreview->getWidth() && 8 * $height <= $maxPreview->getHeight()) { + return $this->helper->getScaledImage(new PreviewFile($maxPreview, $this->storageFactory, $this->previewMapper), $width, $height); + } + return null; + } + + /** + * Smallest cached, uncropped preview larger than the requested size + * + * @param Preview[] $previews + */ + private function findResizeSource(array $previews, Preview $maxPreview, int $width, int $height, ?string $version): ?Preview { + $source = null; + foreach ($previews as $preview) { + if ($preview->isMax() || $preview->isCropped() + || $preview->getVersion() !== $version + || $preview->getMimetype() !== $maxPreview->getMimetype() + || $preview->getWidth() < $width || $preview->getHeight() < $height + || ($preview->getWidth() === $width && $preview->getHeight() === $height)) { + continue; + } + if ($source === null || $preview->getWidth() * $preview->getHeight() < $source->getWidth() * $source->getHeight()) { + $source = $preview; + } + } + return $source; + } + /** * @return Preview[] */ @@ -314,14 +364,15 @@ public function getNumConcurrentPreviews(string $type): int { /** * @param Preview[] $previews + * @return array{Preview, ?IImage} the max preview and its image, if it was generated * @throws NotFoundException */ - private function getMaxPreview(array $previews, File $file, string $mimeType, ?string $version): Preview { + private function getMaxPreview(array $previews, File $file, string $mimeType, ?string $version): array { // We don't know the max preview size, so we can't use getCachedPreview. // It might have been generated with a higher resolution than the current value. foreach ($previews as $preview) { if ($preview->isMax() && ($version === $preview->getVersion())) { - return $preview; + return [$preview, null]; } } @@ -336,7 +387,7 @@ private function getMaxPreview(array $previews, File $file, string $mimeType, ?s [$file->getId() => $previews] = $this->previewMapper->getAvailablePreviews([$file->getId()]); foreach ($previews as $preview) { if ($preview->isMax() && ($version === $preview->getVersion())) { - return $preview; + return [$preview, null]; } } } @@ -345,10 +396,11 @@ private function getMaxPreview(array $previews, File $file, string $mimeType, ?s } /** + * @return array{Preview, IImage} * @throws DBException * @throws NotFoundException */ - private function generateProviderPreview(File $file, int $width, int $height, bool $crop, bool $max, string $mimeType, ?string $version): Preview { + private function generateProviderPreview(File $file, int $width, int $height, bool $crop, bool $max, string $mimeType, ?string $version): array { $previewProviders = $this->previewManager->getProviders(); foreach ($previewProviders as $supportedMimeType => $providers) { // Filter out providers that does not support this mime @@ -399,7 +451,7 @@ private function generateProviderPreview(File $file, int $width, int $height, bo $previewEntry->setMimetype($preview->dataMimeType()); $previewEntry->setEtag($file->getEtag()); $previewEntry->setMtime((new \DateTime())->getTimestamp()); - return $this->savePreview($previewEntry, $preview); + return [$this->savePreview($previewEntry, $preview), $preview]; } catch (NotPermittedException) { throw new NotFoundException(); } diff --git a/lib/private/Preview/GeneratorHelper.php b/lib/private/Preview/GeneratorHelper.php index 5078124f26660..cc30ec6642847 100644 --- a/lib/private/Preview/GeneratorHelper.php +++ b/lib/private/Preview/GeneratorHelper.php @@ -19,6 +19,11 @@ * @psalm-import-type ProviderClosure from IPreview */ class GeneratorHelper { + public function __construct( + private readonly IMagickSupport $imagickSupport, + ) { + } + public function getThumbnail(IProviderV2 $provider, File $file, int $maxWidth, int $maxHeight, bool $crop = false): IImage|false { if ($provider instanceof Imaginary) { return $provider->getCroppedThumbnail($file, $maxWidth, $maxHeight, $crop) ?? false; @@ -32,6 +37,37 @@ public function getImage(ISimpleFile $maxPreview): IImage { return $image; } + /** + * Decode a JPEG preview at a reduced size of at least $minWidth x $minHeight, + * much faster than a full decode. Requires Imagick. + */ + public function getScaledImage(ISimpleFile $preview, int $minWidth, int $minHeight): ?IImage { + if ($preview->getMimeType() !== 'image/jpeg' || !$this->imagickSupport->hasExtension()) { + return null; + } + + try { + $imagick = new \Imagick(); + // libjpeg scales by multiples of 1/8, twice the size keeps enough detail + $imagick->setOption('jpeg:size', ($minWidth * 2) . 'x' . ($minHeight * 2)); + $imagick->readImageBlob($preview->getContent()); + if ($imagick->getImageWidth() < $minWidth || $imagick->getImageHeight() < $minHeight) { + return null; + } + // Re-encoded as JPEG, so derived previews keep the mimetype + $imagick->setImageFormat('jpeg'); + $imagick->setImageCompressionQuality(95); + $data = $imagick->getImageBlob(); + $imagick->clear(); + } catch (\ImagickException) { + return null; + } + + $image = new OCPImage(); + $image->loadFromData($data); + return $image->valid() ? $image : null; + } + /** * @param \Closure|string $providerClosure (string is only authorized in unit tests) */ diff --git a/lib/private/PreviewManager.php b/lib/private/PreviewManager.php index c3f780de39ecb..c005c133d1081 100644 --- a/lib/private/PreviewManager.php +++ b/lib/private/PreviewManager.php @@ -155,7 +155,7 @@ private function getGenerator(): Generator { $this->config, $this->container->get(IAppConfig::class), $this, - new GeneratorHelper(), + $this->helper, $this->eventDispatcher, $this->container->get(LoggerInterface::class), $this->container->get(PreviewMapper::class), diff --git a/tests/lib/Preview/GeneratorHelperTest.php b/tests/lib/Preview/GeneratorHelperTest.php new file mode 100644 index 0000000000000..4d3163ef1acbf --- /dev/null +++ b/tests/lib/Preview/GeneratorHelperTest.php @@ -0,0 +1,62 @@ +createMock(ISimpleFile::class); + $file->method('getMimeType')->willReturn($mimeType); + $file->method('getContent')->willReturn($data); + return $file; + } + + private function getHelper(bool $hasImagick): GeneratorHelper { + $imagickSupport = $this->createMock(IMagickSupport::class); + $imagickSupport->method('hasExtension')->willReturn($hasImagick); + return new GeneratorHelper($imagickSupport); + } + + public function testGetScaledImage(): void { + if (!extension_loaded('imagick')) { + $this->markTestSkipped('Imagick is required to decode JPEG images at a reduced size'); + } + + $image = $this->getHelper(true)->getScaledImage($this->getPreviewFile('image/jpeg', 4000, 3000), 256, 192); + + $this->assertNotNull($image); + $this->assertSame('image/jpeg', $image->dataMimeType()); + $this->assertGreaterThanOrEqual(256, $image->width()); + $this->assertGreaterThanOrEqual(192, $image->height()); + $this->assertLessThan(4000, $image->width()); + $this->assertEqualsWithDelta(4 / 3, $image->width() / $image->height(), 0.01); + } + + public function testGetScaledImageWithoutImagick(): void { + $this->assertNull($this->getHelper(false)->getScaledImage($this->getPreviewFile('image/jpeg', 800, 600), 64, 48)); + } + + public function testGetScaledImageOfPng(): void { + $this->assertNull($this->getHelper(true)->getScaledImage($this->getPreviewFile('image/png', 800, 600), 64, 48)); + } +} diff --git a/tests/lib/Preview/GeneratorTest.php b/tests/lib/Preview/GeneratorTest.php index 1b62768ef390d..bc7eae3215a80 100644 --- a/tests/lib/Preview/GeneratorTest.php +++ b/tests/lib/Preview/GeneratorTest.php @@ -18,6 +18,7 @@ use OCP\Files\File; use OCP\Files\Mount\IMountPoint; use OCP\Files\NotFoundException; +use OCP\Files\SimpleFS\ISimpleFile; use OCP\IAppConfig; use OCP\IConfig; use OCP\IImage; @@ -169,6 +170,9 @@ public function testGetNewPreview(bool $hasVersion): void { $image->method('height')->willReturn(2048); $image->method('valid')->willReturn(true); $image->method('dataMimeType')->willReturn('image/png'); + $image->method('data')->willReturn('my data'); + $image->method('resizeCopy') + ->willReturnCallback(fn (int $size): IImage => $this->getMockImage($size, $size, 'my resized data')); $this->getAutoMock(GeneratorHelper::class)->method('getThumbnail') ->willReturnCallback(function ($provider, $file, $x, $y) use ($invalidProvider, $validProvider, $image): false|IImage { @@ -179,9 +183,6 @@ public function testGetNewPreview(bool $hasVersion): void { } }); - $image->method('data') - ->willReturn('my data'); - $this->getAutoMock(PreviewMapper::class)->method('insert') ->willReturnCallback(fn (Preview $preview): Preview => $preview); @@ -213,9 +214,9 @@ public function testGetNewPreview(bool $hasVersion): void { $this->fail('file name is wrong:' . $preview->getName()); }); - $image = $this->getMockImage(2048, 2048, 'my resized data'); - $this->getAutoMock(GeneratorHelper::class)->method('getImage') - ->willReturn($image); + // The generated max preview is reused, not decoded again + $this->getAutoMock(GeneratorHelper::class)->expects($this->never()) + ->method('getImage'); $this->getAutoMock(IEventDispatcher::class)->expects($this->once()) ->method('dispatchTyped') @@ -483,6 +484,91 @@ public function testCorrectSize(int $maxX, int $maxY, int $reqX, int $reqY, bool } } + #[TestWith([false, '256-256.png'])] + #[TestWith([true, '2048-2048-max.png'])] + public function testResizeFromSmallestCachedPreview(bool $cropped, string $expectedSource): void { + $file = $this->getFile(42, 'myMimeType'); + + $this->getAutoMock(IPreview::class)->method('isMimeSupported') + ->willReturn(true); + + $maxPreview = new Preview(); + $maxPreview->setWidth(2048); + $maxPreview->setHeight(2048); + $maxPreview->setMax(true); + $maxPreview->setSize(1000); + $maxPreview->setVersion(null); + $maxPreview->setMimeType('image/png'); + + $previews = [$maxPreview]; + foreach ([1024, 256] as $size) { + $preview = new Preview(); + $preview->setWidth($size); + $preview->setHeight($size); + $preview->setMax(false); + $preview->setSize(1000); + $preview->setCropped($cropped); + $preview->setVersion(null); + $preview->setMimeType('image/png'); + $previews[] = $preview; + } + + $this->getAutoMock(PreviewMapper::class)->method('getAvailablePreviews') + ->willReturn([42 => $previews]); + + $this->getAutoMock(GeneratorHelper::class)->expects($this->once()) + ->method('getImage') + ->willReturnCallback(function (ISimpleFile $source) use ($expectedSource): IImage { + $this->assertSame($expectedSource, $source->getName()); + return $this->getMockImage(256, 256); + }); + + $this->getAutoMock(PreviewMapper::class)->method('insert') + ->willReturnCallback(fn (Preview $preview): Preview => $preview); + $this->getAutoMock(StorageFactory::class)->method('writePreview') + ->willReturn(1000); + + $result = $this->generator->getPreview($file, 32, 32); + $this->assertSame('64-64.png', $result->getName()); + } + + #[TestWith([32, true, false, '64-64.png'])] + #[TestWith([32, false, true, '64-64.png'])] + #[TestWith([512, false, true, '1024-1024.png'])] + public function testResizeFromScaledMaxPreview(int $size, bool $scaledAvailable, bool $expectFullDecode, string $expectedName): void { + $file = $this->getFile(42, 'myMimeType'); + + $this->getAutoMock(IPreview::class)->method('isMimeSupported') + ->willReturn(true); + + $maxPreview = new Preview(); + $maxPreview->setWidth(2048); + $maxPreview->setHeight(2048); + $maxPreview->setMax(true); + $maxPreview->setSize(1000); + $maxPreview->setVersion(null); + $maxPreview->setMimeType('image/jpeg'); + + $this->getAutoMock(PreviewMapper::class)->method('getAvailablePreviews') + ->willReturn([42 => [$maxPreview]]); + + $this->getAutoMock(GeneratorHelper::class)->expects($size === 32 ? $this->once() : $this->never()) + ->method('getScaledImage') + ->with($this->anything(), 64, 64) + ->willReturn($scaledAvailable ? $this->getMockImage(256, 256) : null); + $this->getAutoMock(GeneratorHelper::class)->expects($expectFullDecode ? $this->once() : $this->never()) + ->method('getImage') + ->willReturn($this->getMockImage(2048, 2048)); + + $this->getAutoMock(PreviewMapper::class)->method('insert') + ->willReturnCallback(fn (Preview $preview): Preview => $preview); + $this->getAutoMock(StorageFactory::class)->method('writePreview') + ->willReturn(1000); + + $result = $this->generator->getPreview($file, $size, $size); + $this->assertSame($expectedName, $result->getName()); + } + public function testUnreadbleFile(): void { $file = $this->createMock(File::class); $file->method('isReadable')