chore: complete update
This commit is contained in:
216
src/Domain/Media/GdImageProcessor.php
Normal file
216
src/Domain/Media/GdImageProcessor.php
Normal file
@@ -0,0 +1,216 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Media;
|
||||
|
||||
/**
|
||||
* GD-basierter Bildprozessor als Fallback
|
||||
*/
|
||||
final readonly class GdImageProcessor implements ImageProcessorInterface
|
||||
{
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function createAllVariants(Image $image): array
|
||||
{
|
||||
$variants = [];
|
||||
|
||||
foreach (ImageVariantConfig::getAllVariants() as $variantConfig) {
|
||||
try {
|
||||
$variant = $this->createVariant($image, $variantConfig);
|
||||
$variants[] = $variant;
|
||||
} catch (\Exception $e) {
|
||||
error_log("Failed to create variant {$variantConfig['type']->value}_{$variantConfig['size']->value}_{$variantConfig['format']->value}: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return $variants;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function createVariantsForType(Image $image, ImageVariantType $type): array
|
||||
{
|
||||
$variants = [];
|
||||
|
||||
foreach (ImageVariantConfig::getVariantsForType($type) as $variantConfig) {
|
||||
try {
|
||||
$variant = $this->createVariant($image, $variantConfig);
|
||||
$variants[] = $variant;
|
||||
} catch (\Exception $e) {
|
||||
error_log("Failed to create variant {$variantConfig['type']->value}_{$variantConfig['size']->value}_{$variantConfig['format']->value}: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return $variants;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt eine einzelne Bildvariante
|
||||
*
|
||||
* @param Image $image
|
||||
* @param array $config
|
||||
* @return ImageVariant
|
||||
*/
|
||||
private function createVariant(Image $image, array $config): ImageVariant
|
||||
{
|
||||
$sourcePath = $image->path . $image->filename;
|
||||
|
||||
/** @var ImageVariantType $type */
|
||||
/** @var ImageSize $size */
|
||||
/** @var ImageFormat $format */
|
||||
$type = $config['type'];
|
||||
$size = $config['size'];
|
||||
$format = $config['format'];
|
||||
$width = $config['width'];
|
||||
|
||||
$filename = $this->generateVariantFilename(
|
||||
$image->filename,
|
||||
$type->value,
|
||||
$size->value,
|
||||
$format->value
|
||||
);
|
||||
|
||||
$destination = $image->path . $filename;
|
||||
|
||||
$actualDimensions = $this->processImage($sourcePath, $destination, $width, $format);
|
||||
|
||||
$fileSize = filesize($destination);
|
||||
|
||||
return new ImageVariant(
|
||||
imageId: $image->ulid,
|
||||
variantType: $type->value,
|
||||
size: $size->value,
|
||||
format: $format->value,
|
||||
mimeType: $format->getMimeType(),
|
||||
fileSize: $fileSize,
|
||||
width: $actualDimensions['width'],
|
||||
height: $actualDimensions['height'],
|
||||
filename: $filename,
|
||||
path: $image->path,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert einen Dateinamen für eine Bildvariante
|
||||
*/
|
||||
private function generateVariantFilename(string $originalFilename, string $type, string $size, string $format): string
|
||||
{
|
||||
$pathInfo = pathinfo($originalFilename);
|
||||
return $pathInfo['filename'] . "_{$type}_{$size}.{$format}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Verarbeitet das Bild mit GD
|
||||
*
|
||||
* @param string $sourcePath
|
||||
* @param string $destination
|
||||
* @param int $maxWidth
|
||||
* @param ImageFormat $format
|
||||
* @return array{width: int, height: int}
|
||||
*/
|
||||
private function processImage(string $sourcePath, string $destination, int $maxWidth, ImageFormat $format): array
|
||||
{
|
||||
$imageInfo = getimagesize($sourcePath);
|
||||
if ($imageInfo === false) {
|
||||
throw new \RuntimeException('Could not get image info');
|
||||
}
|
||||
|
||||
$source = $this->createImageFromFile($sourcePath, $imageInfo[2]);
|
||||
|
||||
$originalWidth = imagesx($source);
|
||||
$originalHeight = imagesy($source);
|
||||
|
||||
// Nur die Breite begrenzen, Höhe proportional berechnen
|
||||
$scale = $maxWidth / $originalWidth;
|
||||
|
||||
// Nur skalieren wenn nötig
|
||||
if ($originalWidth > $maxWidth) {
|
||||
$newWidth = $maxWidth;
|
||||
$newHeight = (int)round($originalHeight * $scale);
|
||||
} else {
|
||||
$newWidth = $originalWidth;
|
||||
$newHeight = $originalHeight;
|
||||
// Kopie für konsistente Verarbeitung
|
||||
$dst = imagecreatetruecolor($newWidth, $newHeight);
|
||||
imagecopy($dst, $source, 0, 0, 0, 0, $newWidth, $newHeight);
|
||||
imagedestroy($source);
|
||||
$this->saveImageInFormat($dst, $destination, $format);
|
||||
imagedestroy($dst);
|
||||
return ['width' => $newWidth, 'height' => $newHeight];
|
||||
}
|
||||
|
||||
$dst = imagecreatetruecolor($newWidth, $newHeight);
|
||||
|
||||
// Transparenz erhalten
|
||||
if ($format === ImageFormat::WEBP || $imageInfo[2] === IMAGETYPE_PNG) {
|
||||
imagealphablending($dst, false);
|
||||
imagesavealpha($dst, true);
|
||||
$transparent = imagecolorallocatealpha($dst, 0, 0, 0, 127);
|
||||
imagefilledrectangle($dst, 0, 0, $newWidth, $newHeight, $transparent);
|
||||
}
|
||||
|
||||
// Bessere Qualität mit IMAGICK_FILTER_LANCZOS Equivalent
|
||||
imagecopyresampled($dst, $source, 0, 0, 0, 0, $newWidth, $newHeight, $originalWidth, $originalHeight);
|
||||
|
||||
// Einfache Schärfung bei Verkleinerung
|
||||
if ($scale < 0.8) {
|
||||
$this->sharpenImage($dst);
|
||||
}
|
||||
|
||||
$this->saveImageInFormat($dst, $destination, $format);
|
||||
|
||||
imagedestroy($source);
|
||||
imagedestroy($dst);
|
||||
|
||||
return ['width' => $newWidth, 'height' => $newHeight];
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt ein Bild aus einer Datei
|
||||
*/
|
||||
private function createImageFromFile(string $sourcePath, int $imageType): \GdImage
|
||||
{
|
||||
return match ($imageType) {
|
||||
IMAGETYPE_PNG => imagecreatefrompng($sourcePath),
|
||||
IMAGETYPE_JPEG => imagecreatefromjpeg($sourcePath),
|
||||
IMAGETYPE_GIF => imagecreatefromgif($sourcePath),
|
||||
IMAGETYPE_WEBP => imagecreatefromwebp($sourcePath),
|
||||
default => throw new \RuntimeException('Unsupported image type'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Speichert ein Bild im angegebenen Format
|
||||
*/
|
||||
private function saveImageInFormat(\GdImage $image, string $destination, ImageFormat $format): void
|
||||
{
|
||||
match ($format) {
|
||||
ImageFormat::JPEG => imagejpeg($image, $destination, $format->getQuality()),
|
||||
ImageFormat::WEBP => imagewebp($image, $destination, $format->getQuality()),
|
||||
ImageFormat::AVIF => function_exists('imageavif')
|
||||
? imageavif($image, $destination, $format->getQuality())
|
||||
: imagewebp($image, $destination, $format->getQuality()), // Fallback auf WebP
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Einfache Schärfung mit Konvolution
|
||||
*/
|
||||
private function sharpenImage(\GdImage $image): void
|
||||
{
|
||||
// Einfacher Sharpen-Kernel
|
||||
$sharpenMatrix = [
|
||||
[-1, -1, -1],
|
||||
[-1, 16, -1],
|
||||
[-1, -1, -1]
|
||||
];
|
||||
$divisor = 8;
|
||||
$offset = 0;
|
||||
|
||||
if (function_exists('imageconvolution')) {
|
||||
imageconvolution($image, $sharpenMatrix, $divisor, $offset);
|
||||
}
|
||||
}
|
||||
}
|
||||
41
src/Domain/Media/Image.php
Normal file
41
src/Domain/Media/Image.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Media;
|
||||
|
||||
use App\Framework\Database\Attributes\Column;
|
||||
use App\Framework\Database\Attributes\Entity;
|
||||
use App\Framework\Database\Attributes\Type;
|
||||
|
||||
#[Entity(tableName: 'images', idColumn: 'ulid')]
|
||||
final readonly class Image
|
||||
{
|
||||
/** @var ImageVariant[] $variants */
|
||||
#[Type(ImageVariant::class, foreignKey: 'image_id', localKey: 'ulid')]
|
||||
public array $variants;
|
||||
|
||||
public function __construct(
|
||||
/*#[Column(name: 'id', primary: true)]
|
||||
public int $id,*/
|
||||
#[Column(name: 'ulid', primary: true)]
|
||||
public string $ulid,
|
||||
#[Column(name: 'filename')]
|
||||
public string $filename,
|
||||
#[Column(name: 'original_filename')]
|
||||
public string $originalFilename,
|
||||
#[Column(name: 'mime_type')]
|
||||
public string $mimeType,
|
||||
#[Column(name: 'file_size')]
|
||||
public int $fileSize,
|
||||
#[Column(name: 'width')]
|
||||
public int $width,
|
||||
#[Column(name: 'height')]
|
||||
public int $height,
|
||||
#[Column(name: 'hash'/*, unique: true*/)]
|
||||
public string $hash,
|
||||
#[Column(name: 'path')]
|
||||
public string $path,
|
||||
#[Column(name: 'alt_text')]
|
||||
public string $altText,
|
||||
){}
|
||||
}
|
||||
28
src/Domain/Media/ImageFormat.php
Normal file
28
src/Domain/Media/ImageFormat.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Media;
|
||||
|
||||
enum ImageFormat: string
|
||||
{
|
||||
case AVIF = 'avif';
|
||||
case WEBP = 'webp';
|
||||
case JPEG = 'jpeg';
|
||||
|
||||
public function getMimeType(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::AVIF => 'image/avif',
|
||||
self::WEBP => 'image/webp',
|
||||
self::JPEG => 'image/jpeg',
|
||||
};
|
||||
}
|
||||
|
||||
public function getQuality(): int
|
||||
{
|
||||
return match ($this) {
|
||||
self::AVIF => 80,
|
||||
self::WEBP => 85,
|
||||
self::JPEG => 85,
|
||||
};
|
||||
}
|
||||
}
|
||||
11
src/Domain/Media/ImageGallery.php
Normal file
11
src/Domain/Media/ImageGallery.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Media;
|
||||
|
||||
use App\Framework\Database\Attributes\Entity;
|
||||
|
||||
#[Entity(tableName: 'image_galleries')]
|
||||
class ImageGallery
|
||||
{
|
||||
|
||||
}
|
||||
41
src/Domain/Media/ImageProcessor.php
Normal file
41
src/Domain/Media/ImageProcessor.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Media;
|
||||
|
||||
/**
|
||||
* Adapter-Klasse für Abwärtskompatibilität
|
||||
* Delegiert an die konkrete Implementierung
|
||||
*/
|
||||
final readonly class ImageProcessor implements ImageProcessorInterface
|
||||
{
|
||||
/**
|
||||
* @var ImageProcessorInterface
|
||||
*/
|
||||
private ImageProcessorInterface $processor;
|
||||
|
||||
/**
|
||||
* Konstruktor mit automatischer Dependency-Injection
|
||||
*/
|
||||
public function __construct(?ImageProcessorInterface $processor = null)
|
||||
{
|
||||
// Wenn kein Processor übergeben wurde, Factory verwenden
|
||||
$this->processor = $processor ?? ImageProcessorFactory::create();
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function createAllVariants(Image $image): array
|
||||
{
|
||||
return $this->processor->createAllVariants($image);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function createVariantsForType(Image $image, ImageVariantType $type): array
|
||||
{
|
||||
return $this->processor->createVariantsForType($image, $type);
|
||||
}
|
||||
}
|
||||
40
src/Domain/Media/ImageProcessorFactory.php
Normal file
40
src/Domain/Media/ImageProcessorFactory.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Media;
|
||||
|
||||
/**
|
||||
* Factory-Klasse zum Erstellen des richtigen ImageProcessor
|
||||
*/
|
||||
final readonly class ImageProcessorFactory
|
||||
{
|
||||
/**
|
||||
* Erstellt den optimalen ImageProcessor je nach verfügbaren Extensions
|
||||
*
|
||||
* @return ImageProcessorInterface Die beste verfügbare Implementation
|
||||
*/
|
||||
public static function create(): ImageProcessorInterface
|
||||
{
|
||||
// Prüfen ob ImageMagick verfügbar ist
|
||||
if (extension_loaded('imagick') && class_exists('\Imagick')) {
|
||||
try {
|
||||
// Test ob ImageMagick funktioniert
|
||||
$test = new \Imagick();
|
||||
$test->clear();
|
||||
return new ImagickImageProcessor();
|
||||
} catch (\Exception $e) {
|
||||
error_log('ImageMagick is installed but not working: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback auf GD
|
||||
if (extension_loaded('gd') && function_exists('imagecreatetruecolor')) {
|
||||
return new GdImageProcessor();
|
||||
}
|
||||
|
||||
throw new \RuntimeException(
|
||||
'No image processing library available. ' .
|
||||
'Please install and enable either the ImageMagick or GD PHP extension.'
|
||||
);
|
||||
}
|
||||
}
|
||||
24
src/Domain/Media/ImageProcessorInterface.php
Normal file
24
src/Domain/Media/ImageProcessorInterface.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Media;
|
||||
|
||||
interface ImageProcessorInterface
|
||||
{
|
||||
/**
|
||||
* Erstellt alle konfigurierten Bildvarianten für ein Bild
|
||||
*
|
||||
* @param Image $image
|
||||
* @return array<ImageVariant>
|
||||
*/
|
||||
public function createAllVariants(Image $image): array;
|
||||
|
||||
/**
|
||||
* Erstellt alle Bildvarianten für einen bestimmten Typ
|
||||
*
|
||||
* @param Image $image
|
||||
* @param ImageVariantType $type
|
||||
* @return array<ImageVariant>
|
||||
*/
|
||||
public function createVariantsForType(Image $image, ImageVariantType $type): array;
|
||||
}
|
||||
50
src/Domain/Media/ImageRepository.php
Normal file
50
src/Domain/Media/ImageRepository.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Media;
|
||||
|
||||
use App\Framework\Database\ConnectionInterface;
|
||||
use App\Framework\Database\EntityManager;
|
||||
use App\Framework\Database\Transaction;
|
||||
|
||||
final readonly class ImageRepository
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManager $entityManager,
|
||||
private ConnectionInterface $connection,
|
||||
) {}
|
||||
|
||||
|
||||
public function save(Image $image, string $tempPath): void
|
||||
{
|
||||
new SaveImageFile()($image, $tempPath);
|
||||
|
||||
$this->entityManager->save($image);
|
||||
}
|
||||
|
||||
public function findBySlotName(string $slotName): Image
|
||||
{
|
||||
return $this->entityManager->findOneBy(ImageSlot::class, ['slot_name' => $slotName])->image;
|
||||
}
|
||||
|
||||
|
||||
public function findById(string $id): ?Image
|
||||
{
|
||||
return $this->entityManager->find(Image::class, $id);
|
||||
}
|
||||
|
||||
public function findByFilename(string $filename): ?Image
|
||||
{
|
||||
return $this->entityManager->findOneBy(Image::class, ['filename' => $filename]);
|
||||
}
|
||||
|
||||
public function findByHash(string $hash): ?Image
|
||||
{
|
||||
return $this->entityManager->findOneBy(Image::class, ['hash' => $hash]);
|
||||
}
|
||||
|
||||
public function findAll(): array
|
||||
{
|
||||
return $this->entityManager->findAll(Image::class);
|
||||
}
|
||||
}
|
||||
95
src/Domain/Media/ImageResizer.php
Normal file
95
src/Domain/Media/ImageResizer.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Media;
|
||||
|
||||
use App\Framework\DateTime\SystemClock;
|
||||
use App\Framework\Ulid\StringConverter;
|
||||
use App\Framework\Ulid\Ulid;
|
||||
|
||||
final readonly class ImageResizer
|
||||
{
|
||||
public function __construct() {}
|
||||
|
||||
public function __invoke(
|
||||
Image $image,
|
||||
int $maxWidth,
|
||||
int $maxHeight,
|
||||
int $quality = 9
|
||||
): ImageVariant
|
||||
{
|
||||
$sourcePath = $image->path . $image->filename;
|
||||
|
||||
$filename = str_replace('original', 'thumbnail', $image->filename);
|
||||
|
||||
$destination = $image->path . $filename;
|
||||
|
||||
$imageInfo = getimagesize($sourcePath);
|
||||
|
||||
if($imageInfo === false) {
|
||||
throw new \RuntimeException('Could not get image info');
|
||||
}
|
||||
|
||||
$imageType = $imageInfo[2];
|
||||
|
||||
$source = $this->createImageFromFile($sourcePath, $imageType);
|
||||
|
||||
$orignalWidth = imagesx($source);
|
||||
$orignalHeight = imagesy($source);
|
||||
|
||||
$scale = min($maxWidth / $orignalWidth, $maxHeight / $orignalHeight);
|
||||
|
||||
$newWidth = (int)round($orignalWidth * $scale);
|
||||
$newHeight = (int)round($orignalHeight * $scale);
|
||||
|
||||
#$src = imagecreatefrompng($sourcePath);
|
||||
$dst = imagecreatetruecolor($newWidth, $newHeight);
|
||||
|
||||
imagecopyresampled($dst, $source, 0, 0, 0, 0, $newWidth, $newHeight, $orignalWidth, $orignalHeight);
|
||||
|
||||
$this->saveImage($dst, $destination, $imageType, $quality);
|
||||
|
||||
imagedestroy($source);
|
||||
imagedestroy($dst);
|
||||
|
||||
$fileSize = filesize($destination);
|
||||
|
||||
// ULID mit StringConverter in binäres Format konvertieren
|
||||
$binaryImageId = $image->ulid;
|
||||
|
||||
|
||||
return new ImageVariant(
|
||||
imageId : $binaryImageId,
|
||||
variantType: 'thumbnail',
|
||||
format : '',
|
||||
mimeType : $image->mimeType,
|
||||
fileSize : $fileSize,
|
||||
width : $newWidth,
|
||||
height : $newHeight,
|
||||
filename : $filename,
|
||||
path: $image->path,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
private function createImageFromFile(string $sourcePath, mixed $imageType) {
|
||||
return match ($imageType) {
|
||||
IMAGETYPE_PNG => imagecreatefrompng($sourcePath),
|
||||
IMAGETYPE_JPEG => imagecreatefromjpeg($sourcePath),
|
||||
IMAGETYPE_GIF => imagecreatefromgif($sourcePath),
|
||||
IMAGETYPE_WEBP => imagecreatefromwebp($sourcePath),
|
||||
default => throw new \RuntimeException('Unsupported image type'),
|
||||
};
|
||||
}
|
||||
|
||||
private function saveImage($image, string $destination, int $imageType, int $quality): void
|
||||
{
|
||||
match ($imageType) {
|
||||
IMAGETYPE_JPEG => imagejpeg($image, $destination, $quality),
|
||||
IMAGETYPE_PNG => imagepng($image, $destination, (int)round(9 - ($quality * 9 / 100))),
|
||||
IMAGETYPE_GIF => imagegif($image, $destination),
|
||||
IMAGETYPE_WEBP => imagewebp($image, $destination, $quality),
|
||||
default => throw new \RuntimeException('Unsupported image type'),
|
||||
};
|
||||
}
|
||||
}
|
||||
40
src/Domain/Media/ImageSize.php
Normal file
40
src/Domain/Media/ImageSize.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Media;
|
||||
|
||||
enum ImageSize: string
|
||||
{
|
||||
case SMALL = 'small';
|
||||
case MEDIUM = 'medium';
|
||||
case LARGE = 'large';
|
||||
case XLARGE = 'xlarge';
|
||||
|
||||
public function getWidth(ImageVariantType $type): int
|
||||
{
|
||||
return match ([$type, $this]) {
|
||||
[ImageVariantType::THUMBNAIL, self::SMALL] => 150,
|
||||
[ImageVariantType::THUMBNAIL, self::MEDIUM] => 300,
|
||||
|
||||
[ImageVariantType::GALLERY, self::SMALL] => 400,
|
||||
[ImageVariantType::GALLERY, self::MEDIUM] => 800,
|
||||
[ImageVariantType::GALLERY, self::LARGE] => 1200,
|
||||
|
||||
[ImageVariantType::HERO, self::SMALL] => 768,
|
||||
[ImageVariantType::HERO, self::MEDIUM] => 1024,
|
||||
[ImageVariantType::HERO, self::LARGE] => 1920,
|
||||
[ImageVariantType::HERO, self::XLARGE] => 2560,
|
||||
|
||||
default => throw new \InvalidArgumentException("Invalid combination: {$type->value} - {$this->value}"),
|
||||
};
|
||||
}
|
||||
|
||||
public function getBreakpoint(): ?int
|
||||
{
|
||||
return match ($this) {
|
||||
self::SMALL => 768,
|
||||
self::MEDIUM => 1024,
|
||||
self::LARGE => 1920,
|
||||
self::XLARGE => null, // Keine Breakpoint-Beschränkung
|
||||
};
|
||||
}
|
||||
}
|
||||
21
src/Domain/Media/ImageSlot.php
Normal file
21
src/Domain/Media/ImageSlot.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Media;
|
||||
|
||||
use App\Framework\Database\Attributes\Column;
|
||||
use App\Framework\Database\Attributes\Entity;
|
||||
|
||||
#[Entity(tableName: 'image_slots', idColumn: 'id')]
|
||||
final readonly class ImageSlot
|
||||
{
|
||||
public ?Image $image;
|
||||
|
||||
public function __construct(
|
||||
#[Column(name: 'id', primary: true)]
|
||||
public int $id,
|
||||
#[Column(name: 'slot_name')]
|
||||
public string $slotName,
|
||||
#[Column(name: 'image_id')]
|
||||
public string $imageId,
|
||||
){}
|
||||
}
|
||||
32
src/Domain/Media/ImageSlotRepository.php
Normal file
32
src/Domain/Media/ImageSlotRepository.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Media;
|
||||
|
||||
use App\Framework\Database\EntityManager;
|
||||
|
||||
final readonly class ImageSlotRepository
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManager $entityManager
|
||||
){}
|
||||
|
||||
public function getSlots(): array
|
||||
{
|
||||
return $this->entityManager->findAll(ImageSlot::class);
|
||||
}
|
||||
|
||||
public function findBySlotName(string $slotName): ImageSlot
|
||||
{
|
||||
return $this->entityManager->findOneBy(ImageSlot::class, ['slot_name' => $slotName]);
|
||||
}
|
||||
|
||||
public function findById(string $id): ImageSlot
|
||||
{
|
||||
return $this->entityManager->find(ImageSlot::class, $id);
|
||||
}
|
||||
|
||||
public function save(ImageSlot $imageSlot): object
|
||||
{
|
||||
return $this->entityManager->save($imageSlot);
|
||||
}
|
||||
}
|
||||
163
src/Domain/Media/ImageSourceSetGenerator.php
Normal file
163
src/Domain/Media/ImageSourceSetGenerator.php
Normal file
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Media;
|
||||
|
||||
/**
|
||||
* Erzeugt HTML-Source-Sets für responsive Bilder
|
||||
*/
|
||||
final readonly class ImageSourceSetGenerator
|
||||
{
|
||||
/**
|
||||
* Generiert ein vollständiges Picture-Element mit Source-Tags für verschiedene Formate und Größen
|
||||
*
|
||||
* @param Image $image Das Originalbild mit seinen Varianten
|
||||
* @param string $variantType Der Variantentyp (thumbnail, gallery, hero)
|
||||
* @param array $attributes Zusätzliche Attribute für das img-Tag (alt, class, etc.)
|
||||
* @return string HTML Picture-Element
|
||||
*/
|
||||
public function generatePictureElement(Image $image, string $variantType = 'gallery', array $attributes = []): string
|
||||
{
|
||||
$sources = [];
|
||||
|
||||
// Varianten nach Format gruppieren
|
||||
$variantsByFormat = $this->groupVariantsByFormat($image, $variantType);
|
||||
|
||||
// Source-Tags für jedes Format generieren
|
||||
foreach (['avif', 'webp', 'jpeg'] as $format) {
|
||||
if (isset($variantsByFormat[$format])) {
|
||||
$sources[] = $this->generateSourceElement($variantsByFormat[$format], ImageFormat::from($format));
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback img-Tag
|
||||
$fallbackImage = $this->getFallbackImage($variantsByFormat, $image);
|
||||
$attributes['src'] = $fallbackImage->getUrl();
|
||||
$attributes['width'] = $fallbackImage->width;
|
||||
$attributes['height'] = $fallbackImage->height;
|
||||
|
||||
if (!isset($attributes['alt'])) {
|
||||
$attributes['alt'] = '';
|
||||
}
|
||||
|
||||
$sources[] = $this->generateImgTag($attributes);
|
||||
|
||||
return '<picture>' . implode('', $sources) . '</picture>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert einen einfachen source-Tag mit srcset
|
||||
*
|
||||
* @param array $variants Bildvarianten für ein bestimmtes Format
|
||||
* @param ImageFormat $format Das Bildformat
|
||||
* @return string HTML source-Element
|
||||
*/
|
||||
private function generateSourceElement(array $variants, ImageFormat $format): string
|
||||
{
|
||||
$srcset = [];
|
||||
$sizes = [];
|
||||
|
||||
// Nach Größe sortieren (klein nach groß)
|
||||
usort($variants, function (ImageVariant $a, ImageVariant $b) {
|
||||
return $a->width <=> $b->width;
|
||||
});
|
||||
|
||||
foreach ($variants as $variant) {
|
||||
// URL und Breite für srcset
|
||||
$srcset[] = $variant->getUrl() . ' ' . $variant->width . 'w';
|
||||
|
||||
// Sizes basierend auf Breakpoints
|
||||
$size = ImageSize::from($variant->size);
|
||||
$breakpoint = $size->getBreakpoint();
|
||||
|
||||
if ($breakpoint !== null) {
|
||||
$sizes[] = "(max-width: {$breakpoint}px) {$variant->width}px";
|
||||
}
|
||||
}
|
||||
|
||||
// Größte Variante als Standardgröße hinzufügen
|
||||
$sizes[] = $variants[count($variants) - 1]->width . 'px';
|
||||
|
||||
return sprintf(
|
||||
'<source type="%s" srcset="%s" sizes="%s">',
|
||||
$format->getMimeType(),
|
||||
implode(', ', $srcset),
|
||||
implode(', ', $sizes)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt einen img-Tag mit den angegebenen Attributen
|
||||
*
|
||||
* @param array $attributes HTML-Attribute für das img-Tag
|
||||
* @return string HTML img-Element
|
||||
*/
|
||||
private function generateImgTag(array $attributes): string
|
||||
{
|
||||
$htmlAttributes = [];
|
||||
|
||||
foreach ($attributes as $name => $value) {
|
||||
$htmlAttributes[] = sprintf('%s="%s"', $name, htmlspecialchars((string)$value));
|
||||
}
|
||||
|
||||
return '<img ' . implode(' ', $htmlAttributes) . '>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Gruppiert Bildvarianten nach Format
|
||||
*
|
||||
* @param Image $image Das Originalbild
|
||||
* @param string $variantType Der Variantentyp
|
||||
* @return array Varianten gruppiert nach Format
|
||||
*/
|
||||
private function groupVariantsByFormat(Image $image, string $variantType): array
|
||||
{
|
||||
$variantsByFormat = [];
|
||||
|
||||
foreach ($image->variants as $variant) {
|
||||
if ($variant->variantType === $variantType) {
|
||||
$variantsByFormat[$variant->format][] = $variant;
|
||||
}
|
||||
}
|
||||
|
||||
return $variantsByFormat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wählt das beste Fallback-Bild aus
|
||||
*
|
||||
* @param array $variantsByFormat Varianten gruppiert nach Format
|
||||
* @param Image $image Das Originalbild
|
||||
* @return ImageVariant Die beste Fallback-Variante
|
||||
*/
|
||||
private function getFallbackImage(array $variantsByFormat, Image $image): ImageVariant
|
||||
{
|
||||
// Bevorzugen JPEG als Fallback
|
||||
if (isset($variantsByFormat['jpeg']) && !empty($variantsByFormat['jpeg'])) {
|
||||
// Mittlere Größe als Fallback verwenden
|
||||
$variants = $variantsByFormat['jpeg'];
|
||||
return $variants[min(1, count($variants) - 1)];
|
||||
}
|
||||
|
||||
// Alternativen, falls kein JPEG verfügbar
|
||||
foreach (['webp', 'avif'] as $format) {
|
||||
if (isset($variantsByFormat[$format]) && !empty($variantsByFormat[$format])) {
|
||||
return $variantsByFormat[$format][0];
|
||||
}
|
||||
}
|
||||
|
||||
// Wenn keine Varianten gefunden wurden, das Originalbild verwenden
|
||||
return new ImageVariant(
|
||||
imageId: $image->ulid,
|
||||
variantType: 'original',
|
||||
size: 'original',
|
||||
format: pathinfo($image->filename, PATHINFO_EXTENSION),
|
||||
mimeType: $image->mimeType,
|
||||
fileSize: $image->fileSize,
|
||||
width: $image->width,
|
||||
height: $image->height,
|
||||
filename: $image->filename,
|
||||
path: $image->path,
|
||||
);
|
||||
}
|
||||
}
|
||||
65
src/Domain/Media/ImageVariant.php
Normal file
65
src/Domain/Media/ImageVariant.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Media;
|
||||
|
||||
use App\Framework\Database\Attributes\Column;
|
||||
use App\Framework\Database\Attributes\Entity;
|
||||
|
||||
#[Entity(tableName: 'image_variants')]
|
||||
final readonly class ImageVariant
|
||||
{
|
||||
/*#[Column(name: 'id', primary: true, autoIncrement: true)]
|
||||
public int $id;*/
|
||||
public function __construct(
|
||||
#[Column(name: 'image_id')]
|
||||
public string $imageId,
|
||||
#[Column(name: 'variant_type')]
|
||||
public string $variantType,
|
||||
// Todo: Implement Size
|
||||
#[Column(name: 'size')]
|
||||
public string $size,
|
||||
#[Column(name: 'format')]
|
||||
public string $format,
|
||||
#[Column(name: 'mime_type')]
|
||||
public string $mimeType,
|
||||
#[Column(name: 'file_size')]
|
||||
public int $fileSize,
|
||||
#[Column(name: 'width')]
|
||||
public int $width,
|
||||
#[Column(name: 'height')]
|
||||
public int $height,
|
||||
#[Column(name: 'filename')]
|
||||
public string $filename,
|
||||
#[Column(name: 'path')]
|
||||
public string $path,
|
||||
|
||||
#[Column(name: 'id', primary: true, autoIncrement: true)]
|
||||
public ?int $id = null,
|
||||
) {}
|
||||
|
||||
public function getUrl(): string
|
||||
{
|
||||
return '/images/'. $this->filename;
|
||||
}
|
||||
|
||||
public function getVariantType(): ImageVariantType
|
||||
{
|
||||
return ImageVariantType::from($this->variantType);
|
||||
}
|
||||
|
||||
public function getSize(): ImageSize
|
||||
{
|
||||
return ImageSize::from($this->size);
|
||||
}
|
||||
|
||||
public function getFormat(): ImageFormat
|
||||
{
|
||||
return ImageFormat::from($this->format);
|
||||
}
|
||||
|
||||
public function getVariantKey(): string
|
||||
{
|
||||
return "{$this->variantType}_{$this->size}_{$this->format}";
|
||||
}
|
||||
}
|
||||
45
src/Domain/Media/ImageVariantConfig.php
Normal file
45
src/Domain/Media/ImageVariantConfig.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Media;
|
||||
|
||||
final readonly class ImageVariantConfig
|
||||
{
|
||||
public static function getAllVariants(): array
|
||||
{
|
||||
$variants = [];
|
||||
|
||||
foreach (ImageVariantType::cases() as $type) {
|
||||
foreach ($type->getSizes() as $size) {
|
||||
foreach (ImageFormat::cases() as $format) {
|
||||
$variants[] = [
|
||||
'type' => $type,
|
||||
'size' => $size,
|
||||
'format' => $format,
|
||||
'width' => $size->getWidth($type),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $variants;
|
||||
}
|
||||
|
||||
public static function getVariantsForType(ImageVariantType $type): array
|
||||
{
|
||||
$variants = [];
|
||||
|
||||
foreach ($type->getSizes() as $size) {
|
||||
foreach (ImageFormat::cases() as $format) {
|
||||
$variants[] = [
|
||||
'type' => $type,
|
||||
'size' => $size,
|
||||
'format' => $format,
|
||||
'width' => $size->getWidth($type),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $variants;
|
||||
}
|
||||
}
|
||||
22
src/Domain/Media/ImageVariantRepository.php
Normal file
22
src/Domain/Media/ImageVariantRepository.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Media;
|
||||
|
||||
use App\Framework\Database\EntityManager;
|
||||
|
||||
final class ImageVariantRepository
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManager $entityManager
|
||||
){}
|
||||
|
||||
public function save(ImageVariant $imageVariant): void
|
||||
{
|
||||
$this->entityManager->save($imageVariant);
|
||||
}
|
||||
|
||||
public function findByFilename(string $filename): ?ImageVariant {
|
||||
return $this->entityManager->findOneBy(ImageVariant::class, ['filename' => $filename]);
|
||||
}
|
||||
}
|
||||
31
src/Domain/Media/ImageVariantType.php
Normal file
31
src/Domain/Media/ImageVariantType.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Media;
|
||||
|
||||
enum ImageVariantType: string
|
||||
{
|
||||
case THUMBNAIL = 'thumbnail';
|
||||
case GALLERY = 'gallery';
|
||||
case HERO = 'hero';
|
||||
|
||||
public function getSizes(): array
|
||||
{
|
||||
return match ($this) {
|
||||
self::THUMBNAIL => [
|
||||
ImageSize::SMALL,
|
||||
ImageSize::MEDIUM,
|
||||
],
|
||||
self::GALLERY => [
|
||||
ImageSize::SMALL,
|
||||
ImageSize::MEDIUM,
|
||||
ImageSize::LARGE,
|
||||
],
|
||||
self::HERO => [
|
||||
ImageSize::SMALL,
|
||||
ImageSize::MEDIUM,
|
||||
ImageSize::LARGE,
|
||||
ImageSize::XLARGE,
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
244
src/Domain/Media/ImagickImageProcessor.php
Normal file
244
src/Domain/Media/ImagickImageProcessor.php
Normal file
@@ -0,0 +1,244 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Media;
|
||||
|
||||
use Imagick;
|
||||
use ImagickException;
|
||||
use ImagickPixel;
|
||||
|
||||
/**
|
||||
* ImageMagick-basierter Bildprozessor für optimale Bildqualität
|
||||
*/
|
||||
final readonly class ImagickImageProcessor implements ImageProcessorInterface
|
||||
{
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function createAllVariants(Image $image): array
|
||||
{
|
||||
$variants = [];
|
||||
|
||||
foreach (ImageVariantConfig::getAllVariants() as $variantConfig) {
|
||||
try {
|
||||
$variant = $this->createVariant($image, $variantConfig);
|
||||
$variants[] = $variant;
|
||||
} catch (\Exception $e) {
|
||||
error_log("Failed to create variant {$variantConfig['type']->value}_{$variantConfig['size']->value}_{$variantConfig['format']->value}: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return $variants;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function createVariantsForType(Image $image, ImageVariantType $type): array
|
||||
{
|
||||
$variants = [];
|
||||
|
||||
foreach (ImageVariantConfig::getVariantsForType($type) as $variantConfig) {
|
||||
try {
|
||||
$variant = $this->createVariant($image, $variantConfig);
|
||||
$variants[] = $variant;
|
||||
} catch (\Exception $e) {
|
||||
error_log("Failed to create variant {$variantConfig['type']->value}_{$variantConfig['size']->value}_{$variantConfig['format']->value}: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return $variants;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt eine einzelne Bildvariante
|
||||
*
|
||||
* @param Image $image
|
||||
* @param array $config
|
||||
* @return ImageVariant
|
||||
*/
|
||||
private function createVariant(Image $image, array $config): ImageVariant
|
||||
{
|
||||
$sourcePath = $image->path . $image->filename;
|
||||
|
||||
/** @var ImageVariantType $type */
|
||||
/** @var ImageSize $size */
|
||||
/** @var ImageFormat $format */
|
||||
$type = $config['type'];
|
||||
$size = $config['size'];
|
||||
$format = $config['format'];
|
||||
$width = $config['width'];
|
||||
|
||||
$filename = $this->generateVariantFilename(
|
||||
$image->filename,
|
||||
$type->value,
|
||||
$size->value,
|
||||
$format->value
|
||||
);
|
||||
|
||||
$destination = $image->path . $filename;
|
||||
|
||||
$actualDimensions = $this->processImage($sourcePath, $destination, $width, $format);
|
||||
|
||||
$fileSize = filesize($destination);
|
||||
|
||||
return new ImageVariant(
|
||||
imageId: $image->ulid,
|
||||
variantType: $type->value,
|
||||
size: $size->value,
|
||||
format: $format->value,
|
||||
mimeType: $format->getMimeType(),
|
||||
fileSize: $fileSize,
|
||||
width: $actualDimensions['width'],
|
||||
height: $actualDimensions['height'],
|
||||
filename: $filename,
|
||||
path: $image->path,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert einen Dateinamen für eine Bildvariante
|
||||
*/
|
||||
private function generateVariantFilename(string $originalFilename, string $type, string $size, string $format): string
|
||||
{
|
||||
$pathInfo = pathinfo($originalFilename);
|
||||
return $pathInfo['filename'] . "_{$type}_{$size}.{$format}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Verarbeitet das Bild mit ImageMagick
|
||||
*
|
||||
* @param string $sourcePath
|
||||
* @param string $destination
|
||||
* @param int $maxWidth
|
||||
* @param ImageFormat $format
|
||||
* @return array{width: int, height: int}
|
||||
*/
|
||||
private function processImage(string $sourcePath, string $destination, int $maxWidth, ImageFormat $format): array
|
||||
{
|
||||
try {
|
||||
$imagick = new Imagick($sourcePath);
|
||||
|
||||
// Bildorientierung korrigieren (EXIF)
|
||||
$imagick->autoOrientImage();
|
||||
|
||||
// Farbprofil entfernen für kleinere Dateien
|
||||
$imagick->stripImage();
|
||||
|
||||
// Bessere Resampling-Methode
|
||||
$imagick->setImageResolution(72, 72);
|
||||
|
||||
// Original-Dimensionen speichern
|
||||
$originalWidth = $imagick->getImageWidth();
|
||||
$originalHeight = $imagick->getImageHeight();
|
||||
|
||||
// Skalierung nur wenn nötig
|
||||
if ($originalWidth > $maxWidth) {
|
||||
$scale = $maxWidth / $originalWidth;
|
||||
$newWidth = $maxWidth;
|
||||
$newHeight = (int)round($originalHeight * $scale);
|
||||
|
||||
// Lanczos-Filter für beste Qualität
|
||||
$imagick->resizeImage($newWidth, $newHeight, Imagick::FILTER_LANCZOS, 1);
|
||||
|
||||
// Schärfen bei Verkleinerung
|
||||
$this->applySharpeningAfterResize($imagick, $originalWidth, $newWidth);
|
||||
} else {
|
||||
$newWidth = $originalWidth;
|
||||
$newHeight = $originalHeight;
|
||||
}
|
||||
|
||||
// Format-spezifische Optimierungen
|
||||
$this->optimizeForFormat($imagick, $format);
|
||||
|
||||
// Speichern
|
||||
$imagick->writeImage($destination);
|
||||
|
||||
$imagick->destroy();
|
||||
|
||||
return ['width' => $newWidth, 'height' => $newHeight];
|
||||
|
||||
} catch (ImagickException $e) {
|
||||
throw new \RuntimeException('ImageMagick error: ' . $e->getMessage(), 0, $e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wendet formatspezifische Optimierungen an
|
||||
*/
|
||||
private function optimizeForFormat(Imagick $imagick, ImageFormat $format): void
|
||||
{
|
||||
match ($format) {
|
||||
ImageFormat::JPEG => $this->optimizeJpeg($imagick),
|
||||
ImageFormat::WEBP => $this->optimizeWebP($imagick),
|
||||
ImageFormat::AVIF => $this->optimizeAvif($imagick),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimiert JPEG-Einstellungen
|
||||
*/
|
||||
private function optimizeJpeg(Imagick $imagick): void
|
||||
{
|
||||
$imagick->setImageFormat('jpeg');
|
||||
$imagick->setImageCompressionQuality(ImageFormat::JPEG->getQuality());
|
||||
$imagick->setImageCompression(Imagick::COMPRESSION_JPEG);
|
||||
|
||||
// Progressive JPEG für bessere Ladezeiten
|
||||
$imagick->setInterlaceScheme(Imagick::INTERLACE_PLANE);
|
||||
|
||||
// Chroma-Subsampling für kleinere Dateien
|
||||
$imagick->setSamplingFactors(['2x2', '1x1', '1x1']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimiert WebP-Einstellungen
|
||||
*/
|
||||
private function optimizeWebP(Imagick $imagick): void
|
||||
{
|
||||
$imagick->setImageFormat('webp');
|
||||
$imagick->setImageCompressionQuality(ImageFormat::WEBP->getQuality());
|
||||
|
||||
// WebP-spezifische Optionen
|
||||
$imagick->setOption('webp:lossless', 'false');
|
||||
$imagick->setOption('webp:alpha-quality', '85');
|
||||
$imagick->setOption('webp:method', '4'); // Balancierte Kompression
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimiert AVIF-Einstellungen
|
||||
*/
|
||||
private function optimizeAvif(Imagick $imagick): void
|
||||
{
|
||||
// Wenn die ImageMagick-Version AVIF direkt unterstützt
|
||||
if (in_array('AVIF', Imagick::queryFormats())) {
|
||||
$imagick->setImageFormat('avif');
|
||||
$imagick->setImageCompressionQuality(ImageFormat::AVIF->getQuality());
|
||||
|
||||
// AVIF-spezifische Optionen, falls unterstützt
|
||||
$imagick->setOption('avif:speed', '6'); // Balancierte Kompression
|
||||
} else {
|
||||
// Fallback: Speichern als WebP wenn AVIF nicht unterstützt wird
|
||||
$this->optimizeWebP($imagick);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wendet intelligente Schärfung an, um Details zu verbessern
|
||||
*/
|
||||
private function applySharpeningAfterResize(Imagick $imagick, int $originalWidth, int $newWidth): void
|
||||
{
|
||||
$scaleFactor = $newWidth / $originalWidth;
|
||||
|
||||
// Nur bei signifikanter Verkleinerung schärfen
|
||||
if ($scaleFactor < 0.8) {
|
||||
// Stärke der Schärfung an die Verkleinerung anpassen
|
||||
$radius = 0.5;
|
||||
$sigma = 0.5;
|
||||
$amount = 0.8 + ((1 - $scaleFactor) * 0.3); // Mehr Schärfung bei größerer Verkleinerung
|
||||
$threshold = 0.05;
|
||||
|
||||
$imagick->unsharpMaskImage($radius, $sigma, $amount, $threshold);
|
||||
}
|
||||
}
|
||||
}
|
||||
30
src/Domain/Media/Migrations/AddSizeToImageVariantsTable.php
Normal file
30
src/Domain/Media/Migrations/AddSizeToImageVariantsTable.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Media\Migrations;
|
||||
|
||||
use App\Framework\Database\ConnectionInterface;
|
||||
use App\Framework\Database\Migration\Migration;
|
||||
|
||||
final readonly class AddSizeToImageVariantsTable implements Migration
|
||||
{
|
||||
|
||||
public function up(ConnectionInterface $connection): void
|
||||
{
|
||||
$connection->execute("ALTER TABLE image_variants ADD COLUMN size VARCHAR(25) NOT NULL DEFAULT ''");
|
||||
}
|
||||
|
||||
public function down(ConnectionInterface $connection): void
|
||||
{
|
||||
$connection->execute("ALTER TABLE image_variants DROP COLUMN size");
|
||||
}
|
||||
|
||||
public function getVersion(): string
|
||||
{
|
||||
return "005";
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return "Add Size to Image Slot Table";
|
||||
}
|
||||
}
|
||||
43
src/Domain/Media/Migrations/CreateImageSlotsTable.php
Normal file
43
src/Domain/Media/Migrations/CreateImageSlotsTable.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Media\Migrations;
|
||||
|
||||
use App\Framework\Database\ConnectionInterface;
|
||||
use App\Framework\Database\Migration\Migration;
|
||||
|
||||
final readonly class CreateImageSlotsTable implements Migration
|
||||
{
|
||||
|
||||
public function up(ConnectionInterface $connection): void
|
||||
{
|
||||
$sql = <<<SQL
|
||||
CREATE TABLE IF NOT EXISTS image_slots (
|
||||
id INT AUTO_INCREMENT,
|
||||
image_id VARCHAR(26) NOT NULL,
|
||||
slot_name VARCHAR(50) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT NULL,
|
||||
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
SQL;
|
||||
|
||||
$connection->query($sql);
|
||||
}
|
||||
|
||||
public function down(ConnectionInterface $connection): void
|
||||
{
|
||||
$connection->execute("DROP TABLE IF EXISTS image_slots");
|
||||
}
|
||||
|
||||
public function getVersion(): string
|
||||
{
|
||||
return "004";
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return "Create Image Slot Table";
|
||||
}
|
||||
}
|
||||
76
src/Domain/Media/Migrations/CreateImageVariantsTable.php
Normal file
76
src/Domain/Media/Migrations/CreateImageVariantsTable.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Media\Migrations;
|
||||
|
||||
use App\Framework\Database\ConnectionInterface;
|
||||
use App\Framework\Database\Migration\Migration;
|
||||
|
||||
final class CreateImageVariantsTable implements Migration
|
||||
{
|
||||
|
||||
public function up(ConnectionInterface $connection): void
|
||||
{
|
||||
$sql = <<<SQL
|
||||
CREATE TABLE IF NOT EXISTS image_variants (
|
||||
id INT AUTO_INCREMENT,
|
||||
image_id VARCHAR(26) NOT NULL,
|
||||
variant_type VARCHAR(50) NOT NULL,
|
||||
format VARCHAR(25) NOT NULL,
|
||||
mime_type VARCHAR(100) NOT NULL,
|
||||
width INT NOT NULL,
|
||||
height INT NOT NULL,
|
||||
file_size BIGINT NOT NULL,
|
||||
filename VARCHAR(500) NOT NULL,
|
||||
path VARCHAR(500) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT NULL,
|
||||
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_image_variants_combination (image_id, variant_type, format),
|
||||
CONSTRAINT fk_image_variants_image_id
|
||||
FOREIGN KEY (image_id) REFERENCES images(ulid) ON DELETE CASCADE,
|
||||
INDEX idx_image_variants_lookup (image_id, variant_type)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
SQL;
|
||||
|
||||
$connection->query($sql);
|
||||
}
|
||||
|
||||
public function down(ConnectionInterface $connection): void
|
||||
{
|
||||
$this->dropExistingConstraints($connection);
|
||||
|
||||
$connection->execute("DROP TABLE IF EXISTS image_variants");
|
||||
}
|
||||
|
||||
public function getVersion(): string
|
||||
{
|
||||
return "003";
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return "Create Image Variant Table";
|
||||
}
|
||||
|
||||
private function dropExistingConstraints(ConnectionInterface $connection): void
|
||||
{
|
||||
try {
|
||||
// Alle bestehenden Foreign Key Constraints für diese Tabelle finden und entfernen
|
||||
$constraints = $connection->query("
|
||||
SELECT CONSTRAINT_NAME
|
||||
FROM information_schema.KEY_COLUMN_USAGE
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'image_variants'
|
||||
AND REFERENCED_TABLE_NAME IS NOT NULL
|
||||
")->fetchAll();
|
||||
|
||||
foreach ($constraints as $constraint) {
|
||||
$connection->execute('ALTER TABLE image_variants DROP FOREIGN KEY ' . $constraint['CONSTRAINT_NAME']);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Ignorieren wenn Tabelle nicht existiert oder keine Constraints vorhanden sind
|
||||
}
|
||||
}
|
||||
}
|
||||
52
src/Domain/Media/Migrations/CreateImagesTable.php
Normal file
52
src/Domain/Media/Migrations/CreateImagesTable.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Media\Migrations;
|
||||
|
||||
use App\Framework\Database\ConnectionInterface;
|
||||
use App\Framework\Database\Migration\Migration;
|
||||
|
||||
final class CreateImagesTable implements Migration
|
||||
{
|
||||
|
||||
public function up(ConnectionInterface $connection): void
|
||||
{
|
||||
$sql = <<<SQL
|
||||
CREATE TABLE IF NOT EXISTS images (
|
||||
ulid VARCHAR(26) NOT NULL,
|
||||
filename VARCHAR(255) NOT NULL,
|
||||
original_filename VARCHAR(255) NOT NULL,
|
||||
mime_type VARCHAR(100) NOT NULL,
|
||||
file_size BIGINT NOT NULL,
|
||||
width INT UNSIGNED NOT NULL,
|
||||
height INT UNSIGNED NOT NULL,
|
||||
hash VARCHAR(255) NOT NULL,
|
||||
path VARCHAR(500) NOT NULL,
|
||||
alt_text TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT NULL,
|
||||
|
||||
PRIMARY KEY (ulid),
|
||||
UNIQUE KEY uk_images_hash (hash),
|
||||
UNIQUE KEY uk_images_ulid (ulid)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
SQL;
|
||||
|
||||
$connection->query($sql);
|
||||
}
|
||||
|
||||
public function down(ConnectionInterface $connection): void
|
||||
{
|
||||
$connection->execute("DROP TABLE IF EXISTS images");
|
||||
}
|
||||
|
||||
public function getVersion(): string
|
||||
{
|
||||
return "002";
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return "Create Image Table";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Media\Migrations;
|
||||
|
||||
use App\Framework\Database\ConnectionInterface;
|
||||
use App\Framework\Database\Migration\Migration;
|
||||
|
||||
final readonly class UpdateImageVariantsConstraint implements Migration
|
||||
{
|
||||
public function up(ConnectionInterface $connection): void
|
||||
{
|
||||
// Bestehenden Constraint entfernen
|
||||
$connection->execute("ALTER TABLE image_variants DROP INDEX uk_image_variants_combination");
|
||||
|
||||
// Neuen Constraint mit size-Spalte hinzufügen
|
||||
$connection->execute("ALTER TABLE image_variants ADD UNIQUE KEY uk_image_variants_combination (image_id, variant_type, size, format)");
|
||||
}
|
||||
|
||||
public function down(ConnectionInterface $connection): void
|
||||
{
|
||||
// Zurück zum ursprünglichen Constraint
|
||||
$connection->execute("ALTER TABLE image_variants DROP INDEX uk_image_variants_combination");
|
||||
$connection->execute("ALTER TABLE image_variants ADD UNIQUE KEY uk_image_variants_combination (image_id, variant_type, format)");
|
||||
}
|
||||
|
||||
public function getVersion(): string
|
||||
{
|
||||
return "006";
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return "Update unique constraint to include size column";
|
||||
}
|
||||
}
|
||||
92
src/Domain/Media/README.md
Normal file
92
src/Domain/Media/README.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# Media Modul
|
||||
|
||||
Dieses Modul verwaltet Bilder und erstellt optimierte Varianten für responsive Webseiten.
|
||||
|
||||
## Hauptfunktionen
|
||||
|
||||
- Automatische Erstellung von Bildvarianten in verschiedenen Größen und Formaten (AVIF, WebP, JPEG)
|
||||
- Hochwertige Bildoptimierung mit ImageMagick (oder GD als Fallback)
|
||||
- Responsive Bildausgabe mit HTML Picture-Element und Source-Sets
|
||||
|
||||
## Verwendung
|
||||
|
||||
### Erzeugen von Bildvarianten
|
||||
|
||||
```php
|
||||
// Direkter Zugriff (automatische Erkennung der besten Implementierung)
|
||||
$processor = new ImageProcessor();
|
||||
|
||||
// Alle Varianten
|
||||
$variants = $processor->createAllVariants($image);
|
||||
|
||||
// Nur bestimmte Typen
|
||||
$thumbnails = $processor->createVariantsForType($image, ImageVariantType::THUMBNAIL);
|
||||
|
||||
// Alternativ via DI Container
|
||||
$processor = $container->get(ImageProcessorInterface::class);
|
||||
```
|
||||
|
||||
### Spezifische Implementierungen
|
||||
|
||||
```php
|
||||
// ImageMagick direkt verwenden
|
||||
$imagickProcessor = new ImagickImageProcessor();
|
||||
$variants = $imagickProcessor->createAllVariants($image);
|
||||
|
||||
// Oder GD
|
||||
$gdProcessor = new GdImageProcessor();
|
||||
$variants = $gdProcessor->createAllVariants($image);
|
||||
|
||||
// Factory für automatische Auswahl
|
||||
$optimalProcessor = ImageProcessorFactory::create();
|
||||
```
|
||||
|
||||
### HTML für responsive Bilder generieren
|
||||
|
||||
```php
|
||||
$generator = new ImageSourceSetGenerator();
|
||||
|
||||
// Einfache Verwendung
|
||||
$html = $generator->generatePictureElement($image, 'gallery');
|
||||
|
||||
// Mit zusätzlichen Attributen
|
||||
$html = $generator->generatePictureElement($image, 'hero', [
|
||||
'alt' => 'Beschreibung des Bildes',
|
||||
'class' => 'hero-image',
|
||||
'loading' => 'lazy',
|
||||
]);
|
||||
```
|
||||
|
||||
## Konfiguration
|
||||
|
||||
Die Konfiguration der Bildgrößen und -formate erfolgt über Enums:
|
||||
|
||||
- `ImageVariantType`: Definiert die Verwendungszwecke (Thumbnail, Gallery, Hero)
|
||||
- `ImageSize`: Definiert die verfügbaren Größen und deren Breakpoints
|
||||
- `ImageFormat`: Definiert die unterstützten Formate und deren Qualitätseinstellungen
|
||||
|
||||
## Architektur
|
||||
|
||||
- `ImageProcessorInterface`: Gemeinsame Schnittstelle für alle Prozessoren
|
||||
- `ImagickImageProcessor`: Hochwertige Optimierung mit ImageMagick
|
||||
- `GdImageProcessor`: Fallback-Implementierung mit GD
|
||||
- `ImageProcessor`: Adapter für einfachen Zugriff und Abwärtskompatibilität
|
||||
- `ImageProcessorFactory`: Erstellt optimale Implementierung basierend auf verfügbaren Extensions
|
||||
|
||||
## Technische Details
|
||||
|
||||
- Automatische Auswahl zwischen ImageMagick und GD je nach Verfügbarkeit
|
||||
- Optimierte Kompression für jedes Format
|
||||
- Intelligente Schärfung bei Verkleinerung
|
||||
- Erhaltung von Transparenz bei unterstützten Formaten
|
||||
|
||||
## Beispiel: Vollständiges HTML-Ausgabe
|
||||
|
||||
```html
|
||||
<picture>
|
||||
<source type="image/avif" srcset="image_gallery_small.avif 400w, image_gallery_medium.avif 800w, image_gallery_large.avif 1200w" sizes="(max-width: 768px) 400px, (max-width: 1024px) 800px, 1200px">
|
||||
<source type="image/webp" srcset="image_gallery_small.webp 400w, image_gallery_medium.webp 800w, image_gallery_large.webp 1200w" sizes="(max-width: 768px) 400px, (max-width: 1024px) 800px, 1200px">
|
||||
<source type="image/jpeg" srcset="image_gallery_small.jpeg 400w, image_gallery_medium.jpeg 800w, image_gallery_large.jpeg 1200w" sizes="(max-width: 768px) 400px, (max-width: 1024px) 800px, 1200px">
|
||||
<img src="image_gallery_medium.jpeg" width="800" height="600" alt="Beschreibung" loading="lazy">
|
||||
</picture>
|
||||
```
|
||||
19
src/Domain/Media/SaveImageFile.php
Normal file
19
src/Domain/Media/SaveImageFile.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Media;
|
||||
|
||||
use App\Framework\Http\UploadedFile;
|
||||
use function move_uploaded_file;
|
||||
|
||||
final readonly class SaveImageFile
|
||||
{
|
||||
public function __invoke(Image $image, string $tempFileName): bool
|
||||
{
|
||||
$directory = $image->path;
|
||||
if (!is_dir($directory)) {
|
||||
mkdir($directory, 0755, true);
|
||||
}
|
||||
return move_uploaded_file($tempFileName, $image->path . $image->filename);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user