feat(images): cover-fit conversion mode

This commit is contained in:
Thorsten Bus 2026-05-30 23:56:05 +02:00
parent 0a345aa3b2
commit 73a523d0e1
4 changed files with 174 additions and 4 deletions

View file

@ -0,0 +1,7 @@
PASS Tests\Feature\FileConversionServiceTest
✓ contain conversion keeps black bars and fullCover false 0.89s
Tests: 1 passed (12 assertions)
Duration: 1.06s

View file

@ -0,0 +1,16 @@
PASS Tests\Feature\FileConversionServiceTest
✓ cover conversion fills 1920x1080 without black bars 0.71s
✓ contain conversion keeps black bars and fullCover false 0.16s
✓ cover conversion upscales small sources with German quality warning 0.16s
PASS Tests\Feature\FileConversionTest
✓ convert image creates 1920x1080 jpg with black bars and thumbnail 0.13s
✓ small image is upscaled with at most two black bars 0.14s
✓ exact 16:9 image has no black bars 0.26s
✓ small 16:9 image is upscaled without black bars 0.17s
✓ portrait image gets pillarbox bars on left and right 0.21s
Tests: 8 passed (67 assertions)
Duration: 2.11s

View file

@ -56,6 +56,45 @@ public function convertImage(UploadedFile|string|SplFileInfo $file): array
'filename' => $relativePath, 'filename' => $relativePath,
'thumbnail' => $thumbnailPath, 'thumbnail' => $thumbnailPath,
'warnings' => $warnings, 'warnings' => $warnings,
'fullCover' => false,
];
}
public function convertImageCover(UploadedFile|string|SplFileInfo $file, string $disk = 'public'): array
{
$sourcePath = $this->resolvePath($file);
$extension = $this->resolveExtension($file, $sourcePath);
$this->assertSupported($extension);
if (! in_array($extension, self::IMAGE_EXTENSIONS, true)) {
throw new InvalidArgumentException('Nur Bilddateien koennen mit convertImageCover verarbeitet werden.');
}
$this->assertSize($file, $sourcePath);
$filename = Str::uuid()->toString().'.jpg';
$relativePath = 'slides/'.$filename;
$targetPath = Storage::disk($disk)->path($relativePath);
Storage::disk($disk)->makeDirectory('slides');
$this->ensureDirectory(dirname($targetPath));
$manager = $this->createImageManager();
$image = $manager->read($sourcePath);
$originalWidth = $image->width();
$originalHeight = $image->height();
$warnings = $this->checkCoverImageDimensions($originalWidth, $originalHeight);
$image->cover(1920, 1080);
$image->save($targetPath, quality: 90);
$thumbnailPath = $this->generateThumbnail($relativePath, $disk);
return [
'filename' => $relativePath,
'thumbnail' => $thumbnailPath,
'warnings' => $warnings,
'fullCover' => true,
]; ];
} }
@ -143,11 +182,11 @@ public function processZip(UploadedFile|string|SplFileInfo $file): array
return $results; return $results;
} }
public function generateThumbnail(string $path): string public function generateThumbnail(string $path, string $disk = 'public'): string
{ {
$absolutePath = str_starts_with($path, DIRECTORY_SEPARATOR) $absolutePath = str_starts_with($path, DIRECTORY_SEPARATOR)
? $path ? $path
: Storage::disk('public')->path($path); : Storage::disk($disk)->path($path);
if (! is_file($absolutePath)) { if (! is_file($absolutePath)) {
throw new InvalidArgumentException('Datei fuer Thumbnail nicht gefunden.'); throw new InvalidArgumentException('Datei fuer Thumbnail nicht gefunden.');
@ -155,8 +194,8 @@ public function generateThumbnail(string $path): string
$filename = pathinfo($absolutePath, PATHINFO_FILENAME).'.jpg'; $filename = pathinfo($absolutePath, PATHINFO_FILENAME).'.jpg';
$thumbnailRelativePath = 'slides/thumbnails/'.$filename; $thumbnailRelativePath = 'slides/thumbnails/'.$filename;
$thumbnailAbsolutePath = Storage::disk('public')->path($thumbnailRelativePath); $thumbnailAbsolutePath = Storage::disk($disk)->path($thumbnailRelativePath);
Storage::disk('public')->makeDirectory('slides/thumbnails'); Storage::disk($disk)->makeDirectory('slides/thumbnails');
$this->ensureDirectory(dirname($thumbnailAbsolutePath)); $this->ensureDirectory(dirname($thumbnailAbsolutePath));
$manager = $this->createImageManager(); $manager = $this->createImageManager();
@ -279,6 +318,20 @@ private function checkImageDimensions(int $width, int $height): array
return $warnings; return $warnings;
} }
/** @return string[] */
private function checkCoverImageDimensions(int $width, int $height): array
{
$warnings = [];
if ($width < 1920 || $height < 1080) {
$warnings[] = "Das Bild ({$width}×{$height}) ist kleiner als 1920×1080 und wurde hochskaliert. "
.'Dadurch kann die Qualität schlechter sein. '
.'Lade am besten Bilder mit mindestens 1920×1080 Pixeln hoch.';
}
return $warnings;
}
private function ensureDirectory(string $directory): void private function ensureDirectory(string $directory): void
{ {
if (is_dir($directory)) { if (is_dir($directory)) {

View file

@ -0,0 +1,94 @@
<?php
use App\Services\FileConversionService;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
beforeEach(function () {
Storage::fake('public');
});
test('cover conversion fills 1920x1080 without black bars', function () {
$service = app(FileConversionService::class);
$file = makePngUploadForFileConversionService('cover.png', 1200, 900);
$result = $service->convertImageCover($file);
expect($result)->toHaveKeys(['filename', 'thumbnail', 'warnings', 'fullCover']);
expect($result['fullCover'])->toBeTrue();
expect(Storage::disk('public')->exists($result['filename']))->toBeTrue();
expect(Storage::disk('public')->exists($result['thumbnail']))->toBeTrue();
$mainPath = Storage::disk('public')->path($result['filename']);
[$width, $height] = getimagesize($mainPath);
expect($width)->toBe(1920);
expect($height)->toBe(1080);
$image = imagecreatefromjpeg($mainPath);
expect($image)->not->toBeFalse();
foreach ([[0, 0], [1919, 0], [0, 1079], [1919, 1079]] as [$x, $y]) {
$corner = imagecolorsforindex($image, imagecolorat($image, $x, $y));
expect($corner['red'] + $corner['green'] + $corner['blue'])->toBeGreaterThan(20);
}
});
test('contain conversion keeps black bars and fullCover false', function () {
$service = app(FileConversionService::class);
$file = makePngUploadForFileConversionService('contain.png', 1200, 900);
$result = $service->convertImage($file);
expect($result)->toHaveKeys(['filename', 'thumbnail', 'warnings', 'fullCover']);
expect($result['fullCover'])->toBeFalse();
$mainPath = Storage::disk('public')->path($result['filename']);
[$width, $height] = getimagesize($mainPath);
expect($width)->toBe(1920);
expect($height)->toBe(1080);
$image = imagecreatefromjpeg($mainPath);
expect($image)->not->toBeFalse();
foreach ([[0, 0], [1919, 0], [0, 1079], [1919, 1079]] as [$x, $y]) {
$corner = imagecolorsforindex($image, imagecolorat($image, $x, $y));
expect($corner['red'] + $corner['green'] + $corner['blue'])->toBeLessThan(20);
}
});
test('cover conversion upscales small sources with German quality warning', function () {
$service = app(FileConversionService::class);
$file = makePngUploadForFileConversionService('small-cover.png', 800, 600);
$result = $service->convertImageCover($file);
$mainPath = Storage::disk('public')->path($result['filename']);
[$width, $height] = getimagesize($mainPath);
expect($width)->toBe(1920);
expect($height)->toBe(1080);
expect($result['warnings'])->not->toBeEmpty();
expect(implode(' ', $result['warnings']))->toContain('kleiner als 1920×1080');
expect(implode(' ', $result['warnings']))->toContain('hochskaliert');
});
function makePngUploadForFileConversionService(string $name, int $width, int $height): UploadedFile
{
$path = tempnam(sys_get_temp_dir(), 'cts-cover-');
if ($path === false) {
throw new RuntimeException('Temp-Datei konnte nicht erstellt werden.');
}
$image = imagecreatetruecolor($width, $height);
if ($image === false) {
throw new RuntimeException('Bild konnte nicht erstellt werden.');
}
$red = imagecolorallocate($image, 255, 0, 0);
imagefill($image, 0, 0, $red);
imagepng($image, $path);
return new UploadedFile($path, $name, 'image/png', null, true);
}