From 63cee1ea66bbd3ddfc6667afdf7c299e72acf468 Mon Sep 17 00:00:00 2001 From: Carl Schwan Date: Mon, 5 Oct 2026 14:44:20 +0200 Subject: [PATCH 1/2] 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 8c0bd6f24ce7f..6e0a5ed1a7e1e 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); @@ -328,14 +331,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 +354,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 +363,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 +418,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..590f72b77e2d9 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 b204f1efa670a4312e0e9c7f8365d4386326b7c0 Mon Sep 17 00:00:00 2001 From: Carl Schwan Date: Mon, 5 Oct 2026 14:45:15 +0200 Subject: [PATCH 2/2] 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 6e0a5ed1a7e1e..291a2dcdcb6bf 100644 --- a/lib/private/Preview/Generator.php +++ b/lib/private/Preview/Generator.php @@ -183,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); @@ -210,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[] */ diff --git a/tests/lib/Preview/GeneratorTest.php b/tests/lib/Preview/GeneratorTest.php index 590f72b77e2d9..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; @@ -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')