chore: complete update

This commit is contained in:
2025-07-17 16:24:20 +02:00
parent 899227b0a4
commit 64a7051137
1300 changed files with 85570 additions and 2756 deletions

View 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);
}
}
}

View 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,
){}
}

View 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,
};
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Domain\Media;
use App\Framework\Database\Attributes\Entity;
#[Entity(tableName: 'image_galleries')]
class ImageGallery
{
}

View 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);
}
}

View 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.'
);
}
}

View 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;
}

View 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);
}
}

View 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'),
};
}
}

View 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
};
}
}

View 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,
){}
}

View 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);
}
}

View 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,
);
}
}

View 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}";
}
}

View 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;
}
}

View 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]);
}
}

View 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,
],
};
}
}

View 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);
}
}
}

View 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";
}
}

View 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";
}
}

View 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
}
}
}

View 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";
}
}

View File

@@ -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";
}
}

View 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>
```

View 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);
}
}