presseportale/app/Services/Image/ImageService.php
Kevin Adametz a000238ca8 User Panel: Phase-8-Abschluss, Titelbild/Lizenzen/Zeitzonen und KI-Pruef-Pipeline
Phase 8 (Rest) + Umbauten vom 10./11.06.:
- Ein Titelbild pro PM (Cover 1280x580), SVG-Platzhalter-Set + Picker,
  PressReleaseCoverImage-Resolver
- Lizenz-/Rechteformular nach "Lizenztyp Bildupload" (7 Lizenztypen,
  Personen-/Sachrechte-Status, bedingte Pflichtfelder, Risikohinweise)
- Veroeffentlichungs-Box vereinfacht (Embargo aus der Form-UI entfernt),
  geplante Termine in Europe/Berlin (Speicherung UTC, DISPLAY_TIMEZONE)
- Quota-Stub (users.press_release_quota) + monatlicher Reset-Command
- Einreichungs-Modal einheitlich in Show/Create/Edit; Ghost-Buttons auf
  filled; PM-Editor-Layout responsive entkoppelt (.pr-editor-layout)

KI-Pruef-Pipeline (Phasen 1-5 des Entwicklungsplans):
- API-Haertung: status nicht mehr per API setzbar, eigene Submit-Route
  durch denselben Funnel (Blacklist, Quota, Status-Log)
- Klassifikation Rot/Gelb/Gruen asynchron (Queue classification,
  OpenAI-Treiber + deterministischer Fallback), ki_audits-Audit-Log
- Routing: Rot -> rejected + Mail, Gelb -> Review-Queue, Gruen ->
  Auto-Publish; Scheduler publiziert nur gruene faellige PMs
- Content-Score 0-100 -> Stufe (Standard/Geprueft/Hochwertig) inkl.
  Editor-Panel und Badges; Re-Klassifikation/-Score bei Aenderung
- Admin: KI-Badge + Filter, On-Demand-Pruefung mit Anbieter-Override

Suite: 442 passed, 4 skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 08:30:13 +00:00

442 lines
15 KiB
PHP

<?php
namespace App\Services\Image;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use RuntimeException;
/**
* Centralised image processing for company logos and press release images.
*
* Uses GD natively to keep the dependency footprint small. Should
* `intervention/image` be added later, only the private rendering
* helpers below need to be replaced.
*
* Generates the logo variant set defined by the CRM data model:
* - sq: 1:1 contained, 256x256
* - wide: 2:1 contained, 640x320
*
* Each variant is written next to the original and registered in
* `companies.logo_variants` (JSON).
*/
class ImageService
{
/**
* @var array<string, array{width: int, height: int}>
*/
public const LOGO_VARIANTS = [
'sq' => ['width' => 256, 'height' => 256],
'wide' => ['width' => 640, 'height' => 320],
];
/**
* Press release image variants. The `large` variant is the canonical
* full-size representation we serve in the new portal; `thumb` and
* `medium` are derived for listings and previews.
*
* @var array<string, array{width: int, height: int, fit?: string}>
*/
public const PRESS_RELEASE_IMAGE_VARIANTS = [
'thumb' => ['width' => 320, 'height' => 240],
'medium' => ['width' => 800, 'height' => 600],
'large' => ['width' => 1600, 'height' => 1200],
// Titelbild (Hero) der Detailansicht: harte Obergrenze 1280x580 px.
'cover' => ['width' => 1280, 'height' => 580],
];
public const ALLOWED_LOGO_MIME_TYPES = [
'image/jpeg',
'image/png',
'image/webp',
'image/gif',
];
public const ALLOWED_PRESS_RELEASE_IMAGE_MIME_TYPES = [
'image/jpeg',
'image/png',
'image/webp',
];
public const MAX_LOGO_BYTES = 4 * 1024 * 1024; // 4 MB
public const MAX_PRESS_RELEASE_IMAGE_BYTES = 16 * 1024 * 1024; // 16 MB
public function __construct(private readonly string $disk = 'public') {}
/**
* Persists a freshly uploaded company logo and generates all variants.
*
* @return array{path: string, variants: array<string, string>}
*/
public function storeCompanyLogo(UploadedFile $upload, string $portal, int $companyId): array
{
$this->assertValidLogoUpload($upload);
$directory = sprintf('company-logos/%s/%d', $portal, $companyId);
$extension = $this->normalizedExtension($upload->getMimeType());
$filename = Str::uuid()->toString().'.'.$extension;
$relativePath = $directory.'/'.$filename;
$disk = $this->disk();
$disk->put($relativePath, $upload->get(), 'public');
$variants = $this->generateLogoVariants($disk, $relativePath, $extension);
return [
'path' => $relativePath,
'variants' => $variants,
];
}
/**
* Deletes a logo and all variants below the given relative path.
*/
public function deleteCompanyLogo(?string $relativePath, ?array $variants): void
{
$this->deleteWithVariants($relativePath, $variants);
}
/**
* Persists a freshly uploaded press release image, generates all variants
* and discards the original upload. The canonical stored path points to
* the cover variant to keep storage usage predictable.
*
* @return array{
* path: string,
* variants: array<string, string>,
* width: int|null,
* height: int|null,
* mime: string|null,
* }
*/
public function storePressReleaseImage(UploadedFile $upload, int $pressReleaseId): array
{
$this->assertValidPressReleaseImageUpload($upload);
$directory = sprintf('press-releases/%d/images', $pressReleaseId);
$extension = $this->normalizedExtension($upload->getMimeType());
$filename = Str::uuid()->toString().'.'.$extension;
$relativePath = $directory.'/'.$filename;
$disk = $this->disk();
$disk->put($relativePath, $upload->get(), 'public');
$variants = $this->generateVariants(
$disk,
$relativePath,
$extension,
self::PRESS_RELEASE_IMAGE_VARIANTS,
cover: true,
);
$coverPath = $variants['cover'] ?? $relativePath;
$coverAbsolute = $disk->path($coverPath);
$coverSize = @getimagesize($coverAbsolute) ?: [null, null];
if ($coverPath !== $relativePath && $disk->exists($relativePath)) {
$disk->delete($relativePath);
}
return [
'path' => $coverPath,
'variants' => $variants,
'width' => is_int($coverSize[0] ?? null) ? $coverSize[0] : null,
'height' => is_int($coverSize[1] ?? null) ? $coverSize[1] : null,
'mime' => $upload->getMimeType(),
];
}
/**
* Deletes a press release image and all variants on the given disk.
*
* @param array<string, string>|null $variants
*/
public function deletePressReleaseImage(string $disk, ?string $relativePath, ?array $variants): void
{
$this->deleteWithVariants($relativePath, $variants, $disk);
}
/**
* Generates and persists missing variants for an existing image (for
* legacy images that already live on disk). Returns the variant map.
*
* @return array<string, string>
*/
public function generateMissingPressReleaseVariants(string $relativePath): array
{
$disk = $this->disk();
if (! $disk->exists($relativePath)) {
return [];
}
$extension = strtolower(pathinfo($relativePath, PATHINFO_EXTENSION) ?: 'jpg');
return $this->generateVariants(
$disk,
$relativePath,
$extension,
self::PRESS_RELEASE_IMAGE_VARIANTS,
cover: true,
);
}
/**
* Generates and persists missing variants for an existing company logo.
*
* @return array<string, string>
*/
public function generateMissingCompanyLogoVariants(string $relativePath): array
{
$disk = $this->disk();
if (! $disk->exists($relativePath)) {
return [];
}
$extension = strtolower(pathinfo($relativePath, PATHINFO_EXTENSION) ?: 'jpg');
return $this->generateLogoVariants($disk, $relativePath, $extension);
}
private function deleteWithVariants(?string $relativePath, ?array $variants, ?string $diskName = null): void
{
$disk = $diskName ? Storage::disk($diskName) : $this->disk();
if (filled($relativePath) && $disk->exists($relativePath)) {
$disk->delete($relativePath);
}
if (is_array($variants)) {
foreach ($variants as $variantPath) {
if (is_string($variantPath) && $disk->exists($variantPath)) {
$disk->delete($variantPath);
}
}
}
}
/**
* @return array<string, string>
*/
private function generateLogoVariants(Filesystem $disk, string $relativePath, string $extension): array
{
return $this->generateVariants($disk, $relativePath, $extension, self::LOGO_VARIANTS, cover: false);
}
/**
* Generic variant generator. `cover` switches between contained
* (transparent letterbox, used for logos) and cover (cropped to fill,
* used for press release imagery).
*
* @param array<string, array{width: int, height: int}> $variantSpecs
* @return array<string, string>
*/
private function generateVariants(Filesystem $disk, string $relativePath, string $extension, array $variantSpecs, bool $cover = false): array
{
$absolute = $disk->path($relativePath);
$sourceImage = $this->createImageResource($absolute, $extension);
if (! $sourceImage) {
return [];
}
try {
$variants = [];
$sourceWidth = imagesx($sourceImage);
$sourceHeight = imagesy($sourceImage);
foreach ($variantSpecs as $key => $size) {
$variantPath = $this->variantPath($relativePath, $key);
$variantAbsolute = $disk->path($variantPath);
$disk->makeDirectory(dirname($variantPath));
$targetWidth = $size['width'];
$targetHeight = $size['height'];
$upscale = $sourceWidth < $targetWidth && $sourceHeight < $targetHeight;
if ($upscale) {
$scale = min($sourceWidth / $targetWidth, $sourceHeight / $targetHeight);
$targetWidth = max(1, (int) round($targetWidth * $scale));
$targetHeight = max(1, (int) round($targetHeight * $scale));
}
$resized = $cover
? $this->resizeCover($sourceImage, $targetWidth, $targetHeight)
: $this->resizeContained($sourceImage, $targetWidth, $targetHeight);
$written = $this->writeImage($resized, $variantAbsolute, $extension);
imagedestroy($resized);
if ($written) {
$variants[$key] = $variantPath;
}
}
return $variants;
} finally {
imagedestroy($sourceImage);
}
}
/**
* @return \GdImage|false
*/
private function createImageResource(string $absolutePath, string $extension)
{
return match ($extension) {
'jpg' => @imagecreatefromjpeg($absolutePath),
'png' => @imagecreatefrompng($absolutePath),
'webp' => @imagecreatefromwebp($absolutePath),
'gif' => @imagecreatefromgif($absolutePath),
default => false,
};
}
private function writeImage(\GdImage $image, string $absolutePath, string $extension): bool
{
return match ($extension) {
'jpg' => imagejpeg($image, $absolutePath, 88),
'png' => imagepng($image, $absolutePath, 6),
'webp' => imagewebp($image, $absolutePath, 88),
'gif' => imagegif($image, $absolutePath),
default => false,
};
}
/**
* Resizes preserving aspect ratio with transparent letterbox/pillarbox
* so logos always render fully on a square or 2:1 canvas.
*/
private function resizeContained(\GdImage $source, int $targetWidth, int $targetHeight): \GdImage
{
$sourceWidth = imagesx($source);
$sourceHeight = imagesy($source);
$scale = min($targetWidth / $sourceWidth, $targetHeight / $sourceHeight);
$resizedWidth = max(1, (int) round($sourceWidth * $scale));
$resizedHeight = max(1, (int) round($sourceHeight * $scale));
$offsetX = (int) round(($targetWidth - $resizedWidth) / 2);
$offsetY = (int) round(($targetHeight - $resizedHeight) / 2);
$canvas = imagecreatetruecolor($targetWidth, $targetHeight);
imagealphablending($canvas, false);
imagesavealpha($canvas, true);
$transparent = imagecolorallocatealpha($canvas, 0, 0, 0, 127);
imagefilledrectangle($canvas, 0, 0, $targetWidth, $targetHeight, $transparent);
imagealphablending($canvas, true);
imagecopyresampled(
$canvas,
$source,
$offsetX,
$offsetY,
0,
0,
$resizedWidth,
$resizedHeight,
$sourceWidth,
$sourceHeight,
);
return $canvas;
}
/**
* Cover-resize: scale source so that the target box is fully filled, then
* crop the overflow centered. Used for press release imagery so listings
* always render rectangles without empty bars.
*/
private function resizeCover(\GdImage $source, int $targetWidth, int $targetHeight): \GdImage
{
$sourceWidth = imagesx($source);
$sourceHeight = imagesy($source);
$scale = max($targetWidth / $sourceWidth, $targetHeight / $sourceHeight);
$sampledWidth = max(1, (int) round($targetWidth / $scale));
$sampledHeight = max(1, (int) round($targetHeight / $scale));
$sourceX = (int) round(($sourceWidth - $sampledWidth) / 2);
$sourceY = (int) round(($sourceHeight - $sampledHeight) / 2);
$canvas = imagecreatetruecolor($targetWidth, $targetHeight);
imagealphablending($canvas, false);
imagesavealpha($canvas, true);
$transparent = imagecolorallocatealpha($canvas, 0, 0, 0, 127);
imagefilledrectangle($canvas, 0, 0, $targetWidth, $targetHeight, $transparent);
imagealphablending($canvas, true);
imagecopyresampled(
$canvas,
$source,
0,
0,
$sourceX,
$sourceY,
$targetWidth,
$targetHeight,
$sampledWidth,
$sampledHeight,
);
return $canvas;
}
private function variantPath(string $originalRelativePath, string $variantKey): string
{
$info = pathinfo($originalRelativePath);
$filename = $info['filename'] ?? Str::uuid()->toString();
$extension = $info['extension'] ?? 'jpg';
return ($info['dirname'] ?? '').'/variants/'.$filename.'-'.$variantKey.'.'.$extension;
}
private function normalizedExtension(?string $mimeType): string
{
return match ($mimeType) {
'image/png' => 'png',
'image/webp' => 'webp',
'image/gif' => 'gif',
'image/jpeg', 'image/jpg' => 'jpg',
default => 'jpg',
};
}
private function assertValidLogoUpload(UploadedFile $upload): void
{
if (! in_array($upload->getMimeType(), self::ALLOWED_LOGO_MIME_TYPES, true)) {
throw new RuntimeException('Unsupported logo mime type: '.($upload->getMimeType() ?? 'unknown'));
}
if ($upload->getSize() > self::MAX_LOGO_BYTES) {
throw new RuntimeException('Logo exceeds maximum allowed size of '.self::MAX_LOGO_BYTES.' bytes.');
}
}
private function assertValidPressReleaseImageUpload(UploadedFile $upload): void
{
if (! in_array($upload->getMimeType(), self::ALLOWED_PRESS_RELEASE_IMAGE_MIME_TYPES, true)) {
throw new RuntimeException('Unsupported press release image mime type: '.($upload->getMimeType() ?? 'unknown'));
}
if ($upload->getSize() > self::MAX_PRESS_RELEASE_IMAGE_BYTES) {
throw new RuntimeException('Press release image exceeds maximum allowed size of '.self::MAX_PRESS_RELEASE_IMAGE_BYTES.' bytes.');
}
}
private function disk(): Filesystem
{
return Storage::disk($this->disk);
}
}