From 73a523d0e1adc57676f38a9812eeb5e1a1bbd362 Mon Sep 17 00:00:00 2001 From: Thorsten Bus Date: Sat, 30 May 2026 23:56:05 +0200 Subject: [PATCH] feat(images): cover-fit conversion mode --- .../evidence/task-2-contain-regression.txt | 7 ++ .sisyphus/evidence/task-2-cover-fill.txt | 16 ++++ app/Services/FileConversionService.php | 61 +++++++++++- tests/Feature/FileConversionServiceTest.php | 94 +++++++++++++++++++ 4 files changed, 174 insertions(+), 4 deletions(-) create mode 100644 .sisyphus/evidence/task-2-contain-regression.txt create mode 100644 .sisyphus/evidence/task-2-cover-fill.txt create mode 100644 tests/Feature/FileConversionServiceTest.php diff --git a/.sisyphus/evidence/task-2-contain-regression.txt b/.sisyphus/evidence/task-2-contain-regression.txt new file mode 100644 index 0000000..2a6b52b --- /dev/null +++ b/.sisyphus/evidence/task-2-contain-regression.txt @@ -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 + diff --git a/.sisyphus/evidence/task-2-cover-fill.txt b/.sisyphus/evidence/task-2-cover-fill.txt new file mode 100644 index 0000000..ef0d6a0 --- /dev/null +++ b/.sisyphus/evidence/task-2-cover-fill.txt @@ -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 + diff --git a/app/Services/FileConversionService.php b/app/Services/FileConversionService.php index 768863c..846bb10 100644 --- a/app/Services/FileConversionService.php +++ b/app/Services/FileConversionService.php @@ -56,6 +56,45 @@ public function convertImage(UploadedFile|string|SplFileInfo $file): array 'filename' => $relativePath, 'thumbnail' => $thumbnailPath, '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; } - public function generateThumbnail(string $path): string + public function generateThumbnail(string $path, string $disk = 'public'): string { $absolutePath = str_starts_with($path, DIRECTORY_SEPARATOR) ? $path - : Storage::disk('public')->path($path); + : Storage::disk($disk)->path($path); if (! is_file($absolutePath)) { throw new InvalidArgumentException('Datei fuer Thumbnail nicht gefunden.'); @@ -155,8 +194,8 @@ public function generateThumbnail(string $path): string $filename = pathinfo($absolutePath, PATHINFO_FILENAME).'.jpg'; $thumbnailRelativePath = 'slides/thumbnails/'.$filename; - $thumbnailAbsolutePath = Storage::disk('public')->path($thumbnailRelativePath); - Storage::disk('public')->makeDirectory('slides/thumbnails'); + $thumbnailAbsolutePath = Storage::disk($disk)->path($thumbnailRelativePath); + Storage::disk($disk)->makeDirectory('slides/thumbnails'); $this->ensureDirectory(dirname($thumbnailAbsolutePath)); $manager = $this->createImageManager(); @@ -279,6 +318,20 @@ private function checkImageDimensions(int $width, int $height): array 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 { if (is_dir($directory)) { diff --git a/tests/Feature/FileConversionServiceTest.php b/tests/Feature/FileConversionServiceTest.php new file mode 100644 index 0000000..4f1070b --- /dev/null +++ b/tests/Feature/FileConversionServiceTest.php @@ -0,0 +1,94 @@ +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); +}