diff --git a/lib/private/Preview/Generator.php b/lib/private/Preview/Generator.php index 8c0bd6f24ce7f..291a2dcdcb6bf 100644 --- a/lib/private/Preview/Generator.php +++ b/lib/private/Preview/Generator.php @@ -128,8 +128,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); @@ -180,12 +183,20 @@ 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); + $source = $maxPreviewImage === null ? $this->findResizeSource($previews, $maxPreview, $width, $height, $previewVersion) : null; + if ($source !== null) { + $sourceImage = $this->helper->getImage(new PreviewFile($source, $this->storageFactory, $this->previewMapper)); + $previewFile = $this->generatePreview($file, $sourceImage, $width, $height, $crop, $source->getWidth(), $source->getHeight(), $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); @@ -207,6 +218,28 @@ public function generatePreviews(File $file, array $specifications, ?string $mim return $previewFile; } + /** + * 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[] */ @@ -328,14 +361,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]; } } @@ -350,7 +384,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]; } } } @@ -359,10 +393,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 @@ -413,7 +448,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/tests/lib/Preview/GeneratorTest.php b/tests/lib/Preview/GeneratorTest.php index 526ac19cff10b..9b8e32ca62f3d 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') @@ -505,6 +506,54 @@ public function testHardwareConcurrencyIsCached(): void { $this->generator->getNumConcurrentPreviews('preview_concurrency_new'); } + #[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()); + } + public function testUnreadbleFile(): void { $file = $this->createMock(File::class); $file->method('isReadable')