Skip to content

Commit 1d88fdc

Browse files
committed
perf(preview): generate small previews with Imaginary from the original
With Imaginary, only the max preview was generated by Imaginary. Every other size was then resized in PHP from the fully decoded max preview, which is exactly the work Imaginary is meant to take over. Let Imaginary generate them from the original file instead: libvips decodes a JPEG at a reduced size, so a 256px preview of a 12 MP photo takes about 80ms instead of a few hundred. A smaller cached preview is still preferred, resizing it is cheaper than sending the original to Imaginary. Cropped previews generated this way use the smartcrop of Imaginary instead of a center crop. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Carl Schwan <carl@carlschwan.eu>
1 parent 3347f42 commit 1d88fdc

4 files changed

Lines changed: 202 additions & 81 deletions

File tree

‎lib/private/Preview/Generator.php‎

Lines changed: 118 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
use OCP\IPreview;
2828
use OCP\IStreamImage;
2929
use OCP\Preview\BeforePreviewFetchedEvent;
30+
use OCP\Preview\IProviderV2;
3031
use OCP\Preview\IVersionedPreviewFile;
3132
use Psr\Log\LoggerInterface;
3233

@@ -182,13 +183,10 @@ public function generatePreviews(File $file, array $specifications, ?string $mim
182183
}
183184

184185
$this->logger->debug('Cached preview not found for file {path}, generating a new preview.', ['path' => $file->getPath()]);
185-
$sourceImage = $maxPreviewImage === null ? $this->loadSmallerSource($previews, $maxPreview, $width, $height, $previewVersion) : null;
186-
if ($sourceImage !== null) {
187-
$previewFile = $this->generatePreview($file, $sourceImage, $width, $height, $crop, $sourceImage->width(), $sourceImage->height(), $previewVersion, $cacheResult);
188-
if ($sourceImage instanceof Image) {
189-
$sourceImage->destroy();
190-
}
191-
} else {
186+
$previewFile = $maxPreviewImage === null
187+
? $this->generateFromCheaperSource($file, $previews, $maxPreview, $mimeType, $width, $height, $crop, $previewVersion, $cacheResult)
188+
: null;
189+
if ($previewFile === null) {
192190
if ($maxPreviewImage === null) {
193191
$maxPreviewImage = $this->helper->getImage(new PreviewFile($maxPreview, $this->storageFactory, $this->previewMapper));
194192
}
@@ -216,23 +214,106 @@ public function generatePreviews(File $file, array $specifications, ?string $mim
216214
}
217215

218216
/**
219-
* Load an image smaller than the max preview to resize a preview from:
220-
* the smallest suitable cached preview, or the max preview decoded at a
221-
* reduced size. Both are much cheaper than decoding the full max preview.
217+
* Generate a preview without decoding the full max preview: from the
218+
* smallest suitable cached preview, from the original file with a provider
219+
* that resizes efficiently, or from the max preview decoded at a reduced size.
222220
*
223221
* @param Preview[] $previews
222+
* @return ISimpleFile|null null if there is no cheaper source than the max preview
224223
*/
225-
private function loadSmallerSource(array $previews, Preview $maxPreview, int $width, int $height, ?string $version): ?IImage {
224+
private function generateFromCheaperSource(File $file, array $previews, Preview $maxPreview, string $mimeType, int $width, int $height, bool $crop, ?string $version, bool $cacheResult): ?ISimpleFile {
225+
$sourceImage = null;
226226
$source = $this->findResizeSource($previews, $maxPreview, $width, $height, $version);
227227
if ($source !== null) {
228-
return $this->helper->getImage(new PreviewFile($source, $this->storageFactory, $this->previewMapper));
228+
$sourceImage = $this->helper->getImage(new PreviewFile($source, $this->storageFactory, $this->previewMapper));
229+
} else {
230+
if ($cacheResult) {
231+
$previewFile = $this->generateWithResizingProvider($file, $maxPreview, $mimeType, $width, $height, $crop, $version);
232+
if ($previewFile !== null) {
233+
return $previewFile;
234+
}
235+
}
236+
237+
// Decoding at a reduced size only pays off for previews much smaller than the max preview
238+
if (8 * $width <= $maxPreview->getWidth() && 8 * $height <= $maxPreview->getHeight()) {
239+
$sourceImage = $this->helper->getScaledImage(new PreviewFile($maxPreview, $this->storageFactory, $this->previewMapper), $width, $height);
240+
}
229241
}
230242

231-
// Decoding at a reduced size only pays off for previews much smaller than the max preview
232-
if (8 * $width <= $maxPreview->getWidth() && 8 * $height <= $maxPreview->getHeight()) {
233-
return $this->helper->getScaledImage(new PreviewFile($maxPreview, $this->storageFactory, $this->previewMapper), $width, $height);
243+
if ($sourceImage === null) {
244+
return null;
245+
}
246+
try {
247+
return $this->generatePreview($file, $sourceImage, $width, $height, $crop, $sourceImage->width(), $sourceImage->height(), $version, $cacheResult);
248+
} finally {
249+
if ($sourceImage instanceof Image) {
250+
$sourceImage->destroy();
251+
}
234252
}
235-
return null;
253+
}
254+
255+
/**
256+
* Let the provider generate the preview from the original file, if it can
257+
* resize efficiently like Imaginary. Only the provider that generates the
258+
* max preview is considered, so that all previews of a file look the same.
259+
*/
260+
private function generateWithResizingProvider(File $file, Preview $maxPreview, string $mimeType, int $width, int $height, bool $crop, ?string $version): ?ISimpleFile {
261+
$provider = $this->getAvailableProviders($file, $mimeType)->current();
262+
if ($provider === null || !$this->helper->resizesEfficiently($provider)) {
263+
return null;
264+
}
265+
266+
$previewConcurrency = $this->getNumConcurrentPreviews('preview_concurrency_new');
267+
$sem = self::guardWithSemaphore(self::SEMAPHORE_ID_NEW, $previewConcurrency);
268+
try {
269+
$preview = $this->helper->getThumbnail($provider, $file, $width, $height, $crop);
270+
} finally {
271+
self::unguardWithSemaphore($sem);
272+
}
273+
274+
// A different format would not match the lookup of cached previews
275+
if (!($preview instanceof IImage) || $preview->dataMimeType() !== $maxPreview->getMimetype()) {
276+
return null;
277+
}
278+
279+
$previewEntry = $this->createPreviewEntry($file, $width, $height, false, $crop, $preview->dataMimeType(), $version);
280+
return new PreviewFile($this->savePreview($previewEntry, $preview), $this->storageFactory, $this->previewMapper);
281+
}
282+
283+
/**
284+
* @return \Generator<IProviderV2> the available providers for the mime type, by priority
285+
*/
286+
private function getAvailableProviders(File $file, string $mimeType): \Generator {
287+
foreach ($this->previewManager->getProviders() as $supportedMimeType => $providers) {
288+
if (!preg_match($supportedMimeType, $mimeType)) {
289+
continue;
290+
}
291+
292+
foreach ($providers as $providerClosure) {
293+
$provider = $this->helper->getProvider($providerClosure);
294+
if ($provider && $provider->isAvailable($file)) {
295+
yield $provider;
296+
}
297+
}
298+
}
299+
}
300+
301+
private function createPreviewEntry(File $file, int $width, int $height, bool $max, bool $crop, ?string $mimeType, ?string $version): Preview {
302+
$previewEntry = new Preview();
303+
$previewEntry->generateId();
304+
$previewEntry->setFileId($file->getId());
305+
$previewEntry->setStorageId($file->getMountPoint()->getNumericStorageId());
306+
$previewEntry->setSourceMimeType($file->getMimeType());
307+
$previewEntry->setWidth($width);
308+
$previewEntry->setHeight($height);
309+
$previewEntry->setVersion($version);
310+
$previewEntry->setMax($max);
311+
$previewEntry->setCropped($crop);
312+
$previewEntry->setEncrypted(false);
313+
$previewEntry->setMimetype($mimeType);
314+
$previewEntry->setEtag($file->getEtag());
315+
$previewEntry->setMtime((new \DateTime())->getTimestamp());
316+
return $previewEntry;
236317
}
237318

238319
/**
@@ -405,60 +486,29 @@ private function getMaxPreview(array $previews, File $file, string $mimeType, ?s
405486
* @throws NotFoundException
406487
*/
407488
private function generateProviderPreview(File $file, int $width, int $height, bool $crop, bool $max, string $mimeType, ?string $version): array {
408-
$previewProviders = $this->previewManager->getProviders();
409-
foreach ($previewProviders as $supportedMimeType => $providers) {
410-
// Filter out providers that does not support this mime
411-
if (!preg_match($supportedMimeType, $mimeType)) {
412-
continue;
489+
foreach ($this->getAvailableProviders($file, $mimeType) as $provider) {
490+
$previewConcurrency = $this->getNumConcurrentPreviews('preview_concurrency_new');
491+
$sem = self::guardWithSemaphore(self::SEMAPHORE_ID_NEW, $previewConcurrency);
492+
try {
493+
$this->logger->debug('Calling preview provider for {mimeType} with width={width}, height={height}', [
494+
'mimeType' => $mimeType,
495+
'width' => $width,
496+
'height' => $height,
497+
]);
498+
$preview = $this->helper->getThumbnail($provider, $file, $width, $height);
499+
} finally {
500+
self::unguardWithSemaphore($sem);
413501
}
414502

415-
foreach ($providers as $providerClosure) {
416-
417-
$provider = $this->helper->getProvider($providerClosure);
418-
if (!$provider) {
419-
continue;
420-
}
421-
422-
if (!$provider->isAvailable($file)) {
423-
continue;
424-
}
425-
426-
$previewConcurrency = $this->getNumConcurrentPreviews('preview_concurrency_new');
427-
$sem = self::guardWithSemaphore(self::SEMAPHORE_ID_NEW, $previewConcurrency);
428-
try {
429-
$this->logger->debug('Calling preview provider for {mimeType} with width={width}, height={height}', [
430-
'mimeType' => $mimeType,
431-
'width' => $width,
432-
'height' => $height,
433-
]);
434-
$preview = $this->helper->getThumbnail($provider, $file, $width, $height);
435-
} finally {
436-
self::unguardWithSemaphore($sem);
437-
}
438-
439-
if (!($preview instanceof IImage)) {
440-
continue;
441-
}
503+
if (!($preview instanceof IImage)) {
504+
continue;
505+
}
442506

443-
try {
444-
$previewEntry = new Preview();
445-
$previewEntry->generateId();
446-
$previewEntry->setFileId($file->getId());
447-
$previewEntry->setStorageId($file->getMountPoint()->getNumericStorageId());
448-
$previewEntry->setSourceMimeType($file->getMimeType());
449-
$previewEntry->setWidth($preview->width());
450-
$previewEntry->setHeight($preview->height());
451-
$previewEntry->setVersion($version);
452-
$previewEntry->setMax($max);
453-
$previewEntry->setCropped($crop);
454-
$previewEntry->setEncrypted(false);
455-
$previewEntry->setMimetype($preview->dataMimeType());
456-
$previewEntry->setEtag($file->getEtag());
457-
$previewEntry->setMtime((new \DateTime())->getTimestamp());
458-
return [$this->savePreview($previewEntry, $preview), $preview];
459-
} catch (NotPermittedException) {
460-
throw new NotFoundException();
461-
}
507+
try {
508+
$previewEntry = $this->createPreviewEntry($file, $preview->width(), $preview->height(), $max, $crop, $preview->dataMimeType(), $version);
509+
return [$this->savePreview($previewEntry, $preview), $preview];
510+
} catch (NotPermittedException) {
511+
throw new NotFoundException();
462512
}
463513
}
464514

@@ -599,20 +649,7 @@ private function generatePreview(
599649
throw new \InvalidArgumentException('Preview generation failed: invalid or null MIME type');
600650
}
601651

602-
$previewEntry = new Preview();
603-
$previewEntry->generateId();
604-
$previewEntry->setFileId($file->getId());
605-
$previewEntry->setStorageId($file->getMountPoint()->getNumericStorageId());
606-
$previewEntry->setWidth($width);
607-
$previewEntry->setSourceMimeType($file->getMimeType());
608-
$previewEntry->setHeight($height);
609-
$previewEntry->setVersion($version);
610-
$previewEntry->setMax(false);
611-
$previewEntry->setCropped($crop);
612-
$previewEntry->setEncrypted(false);
613-
$previewEntry->setMimeType($preview->dataMimeType());
614-
$previewEntry->setEtag($file->getEtag());
615-
$previewEntry->setMtime((new \DateTime())->getTimestamp());
652+
$previewEntry = $this->createPreviewEntry($file, $width, $height, false, $crop, $preview->dataMimeType(), $version);
616653

617654
if ($cacheResult) {
618655
$previewEntry = $this->savePreview($previewEntry, $preview);

‎lib/private/Preview/GeneratorHelper.php‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,14 @@ public function getThumbnail(IProviderV2 $provider, File $file, int $maxWidth, i
3131
return $provider->getThumbnail($file, $maxWidth, $maxHeight) ?? false;
3232
}
3333

34+
/**
35+
* Whether the provider generates small previews from the original file
36+
* faster than they can be resized from the max preview
37+
*/
38+
public function resizesEfficiently(IProviderV2 $provider): bool {
39+
return $provider instanceof Imaginary;
40+
}
41+
3442
public function getImage(ISimpleFile $maxPreview): IImage {
3543
$image = new OCPImage();
3644
$image->loadFromData($maxPreview->getContent());

‎tests/lib/Preview/GeneratorHelperTest.php‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111

1212
use OC\Preview\GeneratorHelper;
1313
use OC\Preview\IMagickSupport;
14+
use OC\Preview\Imaginary;
15+
use OC\Preview\JPEG;
1416
use OCP\Files\SimpleFS\ISimpleFile;
1517
use Test\TestCase;
1618

@@ -56,6 +58,12 @@ public function testGetScaledImageWithoutImagick(): void {
5658
$this->assertNull($this->getHelper(false)->getScaledImage($this->getPreviewFile('image/jpeg', 800, 600), 64, 48));
5759
}
5860

61+
public function testResizesEfficiently(): void {
62+
$helper = $this->getHelper(false);
63+
$this->assertTrue($helper->resizesEfficiently(new Imaginary([])));
64+
$this->assertFalse($helper->resizesEfficiently(new JPEG()));
65+
}
66+
5967
public function testGetScaledImageOfPng(): void {
6068
$this->assertNull($this->getHelper(true)->getScaledImage($this->getPreviewFile('image/png', 800, 600), 64, 48));
6169
}

‎tests/lib/Preview/GeneratorTest.php‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -577,6 +577,74 @@ public function testResizeFromScaledMaxPreview(int $size, bool $scaledAvailable,
577577
$this->assertSame($expectedName, $result->getName());
578578
}
579579

580+
/**
581+
* A provider that resizes efficiently, like Imaginary, generates new sizes
582+
* from the original file, as long as the result has the format of the max
583+
* preview and no smaller cached preview can be resized instead.
584+
*/
585+
#[TestWith(['image/png', false, true])]
586+
#[TestWith(['image/jpeg', false, false])]
587+
#[TestWith(['image/png', true, false])]
588+
public function testResizeWithResizingProvider(string $maxMimeType, bool $hasSmallerPreview, bool $expectProviderResult): void {
589+
$file = $this->getFile(42, 'myMimeType');
590+
591+
$this->getAutoMock(IPreview::class)->method('isMimeSupported')
592+
->willReturn(true);
593+
594+
$maxPreview = new Preview();
595+
$maxPreview->setWidth(2048);
596+
$maxPreview->setHeight(2048);
597+
$maxPreview->setMax(true);
598+
$maxPreview->setSize(1000);
599+
$maxPreview->setVersion(null);
600+
$maxPreview->setMimeType($maxMimeType);
601+
$previews = [$maxPreview];
602+
603+
if ($hasSmallerPreview) {
604+
$smallerPreview = new Preview();
605+
$smallerPreview->setWidth(256);
606+
$smallerPreview->setHeight(256);
607+
$smallerPreview->setMax(false);
608+
$smallerPreview->setSize(1000);
609+
$smallerPreview->setCropped(false);
610+
$smallerPreview->setVersion(null);
611+
$smallerPreview->setMimeType($maxMimeType);
612+
$previews[] = $smallerPreview;
613+
}
614+
615+
$this->getAutoMock(PreviewMapper::class)->method('getAvailablePreviews')
616+
->willReturn([42 => $previews]);
617+
618+
$provider = $this->createMock(IProviderV2::class);
619+
$provider->method('isAvailable')->willReturn(true);
620+
$this->getAutoMock(IPreview::class)->method('getProviders')
621+
->willReturn(['/.*/' => ['provider']]);
622+
$this->getAutoMock(GeneratorHelper::class)->method('getProvider')
623+
->willReturn($provider);
624+
$this->getAutoMock(GeneratorHelper::class)->method('resizesEfficiently')
625+
->with($provider)
626+
->willReturn(true);
627+
628+
$this->getAutoMock(GeneratorHelper::class)->expects($hasSmallerPreview ? $this->never() : $this->once())
629+
->method('getThumbnail')
630+
->with($provider, $file, 64, 64, false)
631+
->willReturn($this->getMockImage(64, 64, 'provider data'));
632+
$this->getAutoMock(GeneratorHelper::class)->expects($expectProviderResult ? $this->never() : $this->once())
633+
->method('getImage')
634+
->willReturn($this->getMockImage(256, 256, 'resized data'));
635+
636+
$this->getAutoMock(PreviewMapper::class)->method('insert')
637+
->willReturnCallback(fn (Preview $preview): Preview => $preview);
638+
$this->getAutoMock(StorageFactory::class)->method('writePreview')
639+
->willReturnCallback(function (Preview $preview, mixed $data) use ($expectProviderResult): int {
640+
$this->assertSame($expectProviderResult ? 'provider data' : 'resized data', stream_get_contents($data));
641+
return 1000;
642+
});
643+
644+
$result = $this->generator->getPreview($file, 32, 32);
645+
$this->assertSame('64-64.png', $result->getName());
646+
}
647+
580648
public function testUnreadbleFile(): void {
581649
$file = $this->createMock(File::class);
582650
$file->method('isReadable')

0 commit comments

Comments
 (0)