From 550c74ea378aebd22e78558dfe16f3040f133ac7 Mon Sep 17 00:00:00 2001 From: Carl Schwan Date: Mon, 5 Oct 2026 14:44:20 +0200 Subject: [PATCH 1/3] perf(preview): reuse the generated max preview image When the max preview was just generated, its image is still in memory. Use it to resize the requested preview instead of reading the stored max preview back and decoding it again, which for a 12 MP photo takes a few hundred milliseconds. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Carl Schwan --- lib/private/Preview/Generator.php | 19 ++++++++++++------- tests/lib/Preview/GeneratorTest.php | 12 ++++++------ 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/lib/private/Preview/Generator.php b/lib/private/Preview/Generator.php index 94fea7314b565..fca27f5fe00eb 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); @@ -314,14 +317,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 +340,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 +349,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 +404,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 1b62768ef390d..97459ba52b9f8 100644 --- a/tests/lib/Preview/GeneratorTest.php +++ b/tests/lib/Preview/GeneratorTest.php @@ -169,6 +169,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 +182,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 +213,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') From 75b8b5883698ae8de25f955201ef2e2c9dd9be38 Mon Sep 17 00:00:00 2001 From: Carl Schwan Date: Mon, 5 Oct 2026 14:45:15 +0200 Subject: [PATCH 2/3] perf(preview): resize from the smallest suitable cached preview New preview sizes were always resized from the max preview, so each one paid for decoding and resampling the full resolution image. Use the smallest cached, uncropped preview that is still larger than the requested size as the source instead, e.g. the 256px grid view preview for the 64px list view one. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Carl Schwan --- lib/private/Preview/Generator.php | 40 ++++++++++++++++++++--- tests/lib/Preview/GeneratorTest.php | 49 +++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 5 deletions(-) diff --git a/lib/private/Preview/Generator.php b/lib/private/Preview/Generator.php index fca27f5fe00eb..89fe94b9393e8 100644 --- a/lib/private/Preview/Generator.php +++ b/lib/private/Preview/Generator.php @@ -181,12 +181,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); @@ -208,6 +216,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[] */ diff --git a/tests/lib/Preview/GeneratorTest.php b/tests/lib/Preview/GeneratorTest.php index 97459ba52b9f8..bb07b7646ce6a 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; @@ -483,6 +484,54 @@ 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()); + } + public function testUnreadbleFile(): void { $file = $this->createMock(File::class); $file->method('isReadable') From b56a6f6b0af3acf4d35198bf717665d54a99f646 Mon Sep 17 00:00:00 2001 From: Carl Schwan Date: Mon, 5 Oct 2026 15:28:30 +0200 Subject: [PATCH 3/3] perf(preview): decode the max preview at a reduced size with Imagick Small previews like the 64px and 256px ones of the files list and grid view were resized from the fully decoded max preview, a 12 MP image for a camera photo. libjpeg can scale by 1/2, 1/4 or 1/8 while decoding: with Imagick, decoding a 12 MP JPEG this way takes about 30ms instead of about 90ms with GD, and the resize then starts from a 500x380 px image instead of a 4032x3024 px one. Use it when Imagick is available and the requested preview is at most 1/8 of the max preview, otherwise decode the full max preview as before. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Carl Schwan --- lib/private/Preview/Generator.php | 25 +++++++-- lib/private/Preview/GeneratorHelper.php | 36 +++++++++++++ lib/private/PreviewManager.php | 2 +- tests/lib/Preview/GeneratorHelperTest.php | 62 +++++++++++++++++++++++ tests/lib/Preview/GeneratorTest.php | 37 ++++++++++++++ 5 files changed, 157 insertions(+), 5 deletions(-) create mode 100644 tests/lib/Preview/GeneratorHelperTest.php diff --git a/lib/private/Preview/Generator.php b/lib/private/Preview/Generator.php index 89fe94b9393e8..6442c0c0a6af4 100644 --- a/lib/private/Preview/Generator.php +++ b/lib/private/Preview/Generator.php @@ -182,10 +182,9 @@ public function generatePreviews(File $file, array $specifications, ?string $mim } $this->logger->debug('Cached preview not found for file {path}, generating a new preview.', ['path' => $file->getPath()]); - $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); + $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(); } @@ -216,6 +215,24 @@ 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 * 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 bb07b7646ce6a..bc7eae3215a80 100644 --- a/tests/lib/Preview/GeneratorTest.php +++ b/tests/lib/Preview/GeneratorTest.php @@ -532,6 +532,43 @@ public function testResizeFromSmallestCachedPreview(bool $cropped, string $expec $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')