Laravel 13 + Filament 5 admin panel and REST API for a non-profit developer collective, backed by the Pireon OAuth identity server. The public site is a separate, server-rendered application consuming /api/public/v1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
319 lines
9.8 KiB
PHP
319 lines
9.8 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\Media;
|
|
|
|
use App\Models\Media;
|
|
use GdImage;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use RuntimeException;
|
|
use Throwable;
|
|
|
|
/**
|
|
* Turns an uploaded file into everything the frontend needs to render it well:
|
|
* its real dimensions, a set of narrower versions in modern formats, and a
|
|
* placeholder to paint while the real thing loads.
|
|
*
|
|
* Built on GD, which ships with essentially every PHP, rather than on an image
|
|
* library the project would have to carry. Formats are asked for rather than
|
|
* assumed — a runtime without AVIF simply produces WebP and says so.
|
|
*/
|
|
class ImageProcessor
|
|
{
|
|
/**
|
|
* Fill in everything derivable about a stored file.
|
|
*
|
|
* Never throws: a failure is recorded on the row and the image keeps
|
|
* working as a plain one, because half an image is better than a page
|
|
* that will not render.
|
|
*/
|
|
public function process(Media $media): void
|
|
{
|
|
try {
|
|
$this->run($media);
|
|
} catch (Throwable $e) {
|
|
report($e);
|
|
|
|
$media->forceFill([
|
|
'error' => $e->getMessage(),
|
|
'processed_at' => now(),
|
|
])->save();
|
|
}
|
|
}
|
|
|
|
protected function run(Media $media): void
|
|
{
|
|
$disk = Storage::disk($media->disk);
|
|
|
|
if (! $disk->exists($media->path)) {
|
|
throw new RuntimeException("Media file is missing: {$media->disk}:{$media->path}");
|
|
}
|
|
|
|
$contents = (string) $disk->get($media->path);
|
|
$mime = $disk->mimeType($media->path) ?: null;
|
|
|
|
$attributes = [
|
|
'mime' => $mime,
|
|
'size' => strlen($contents),
|
|
'variants' => [],
|
|
'placeholder' => null,
|
|
'error' => null,
|
|
'processed_at' => now(),
|
|
];
|
|
|
|
// A vector or an animated GIF is copied through: resampling the first
|
|
// loses the point of it, and the second loses the animation.
|
|
if (in_array($mime, (array) config('media.passthrough_mimes'), true)) {
|
|
[$width, $height] = $this->passthroughDimensions($mime, $contents);
|
|
|
|
$media->forceFill([...$attributes, 'width' => $width, 'height' => $height])->save();
|
|
|
|
return;
|
|
}
|
|
|
|
$size = @getimagesizefromstring($contents);
|
|
|
|
if ($size === false) {
|
|
throw new RuntimeException("Not a readable image: {$media->path}");
|
|
}
|
|
|
|
[$width, $height] = [(int) $size[0], (int) $size[1]];
|
|
|
|
// Above the ceiling the dimensions are still recorded — they are what
|
|
// stops the page reflowing — but nothing is resampled. A 100-megapixel
|
|
// upload is nearly always a mistake, and resizing one costs more than
|
|
// a small panel is worth.
|
|
if ($width * $height > (int) config('media.max_pixels', 50_000_000)) {
|
|
$media->forceFill([...$attributes, 'width' => $width, 'height' => $height])->save();
|
|
|
|
return;
|
|
}
|
|
|
|
$image = @imagecreatefromstring($contents);
|
|
|
|
if (! $image instanceof GdImage) {
|
|
throw new RuntimeException("GD could not read: {$media->path}");
|
|
}
|
|
|
|
// A photo taken sideways carries its rotation in EXIF rather than in
|
|
// the pixels, and everything downstream would inherit the mistake.
|
|
[$image, $width, $height] = $this->applyOrientation($image, $contents, $width, $height);
|
|
|
|
$this->preserveTransparency($image);
|
|
|
|
try {
|
|
$media->deleteDerived();
|
|
|
|
$media->forceFill([
|
|
...$attributes,
|
|
'width' => $width,
|
|
'height' => $height,
|
|
'variants' => $this->variants($media, $image, $width),
|
|
'placeholder' => $this->placeholder($image, $width, $height),
|
|
])->save();
|
|
} finally {
|
|
imagedestroy($image);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* One file per width and format, never wider than the original.
|
|
*
|
|
* @return array<int, array{format: string, width: int, path: string, size: int}>
|
|
*/
|
|
protected function variants(Media $media, GdImage $image, int $width): array
|
|
{
|
|
$disk = Storage::disk($media->disk);
|
|
$formats = array_filter((array) config('media.formats'), $this->canEncode(...));
|
|
|
|
$variants = [];
|
|
|
|
foreach ((array) config('media.widths') as $target) {
|
|
$target = (int) $target;
|
|
|
|
// Never upscale: a 900px original must not be republished as a
|
|
// blurry "1600px" version.
|
|
if ($target >= $width) {
|
|
continue;
|
|
}
|
|
|
|
$scaled = imagescale($image, $target);
|
|
|
|
if (! $scaled instanceof GdImage) {
|
|
continue;
|
|
}
|
|
|
|
$this->preserveTransparency($scaled);
|
|
|
|
foreach ($formats as $format) {
|
|
$encoded = $this->encode($scaled, $format);
|
|
|
|
if ($encoded === null) {
|
|
continue;
|
|
}
|
|
|
|
$path = $this->variantPath($media->path, $target, $format);
|
|
$disk->put($path, $encoded);
|
|
|
|
$variants[] = [
|
|
'format' => $format,
|
|
'width' => $target,
|
|
'path' => $path,
|
|
'size' => strlen($encoded),
|
|
];
|
|
}
|
|
|
|
imagedestroy($scaled);
|
|
}
|
|
|
|
return $variants;
|
|
}
|
|
|
|
/**
|
|
* A tiny blurred version, inlined as a data URI.
|
|
*
|
|
* It travels inside every JSON response that mentions the image, so it is
|
|
* kept to a few hundred bytes on purpose.
|
|
*/
|
|
protected function placeholder(GdImage $image, int $width, int $height): ?string
|
|
{
|
|
if (! config('media.placeholder.enabled', true)) {
|
|
return null;
|
|
}
|
|
|
|
$target = min((int) config('media.placeholder.width', 20), $width);
|
|
$scaled = imagescale($image, $target);
|
|
|
|
if (! $scaled instanceof GdImage) {
|
|
return null;
|
|
}
|
|
|
|
$this->preserveTransparency($scaled);
|
|
|
|
$format = $this->canEncode('webp') ? 'webp' : 'jpeg';
|
|
$encoded = $this->encode($scaled, $format, quality: 40);
|
|
|
|
imagedestroy($scaled);
|
|
|
|
if ($encoded === null) {
|
|
return null;
|
|
}
|
|
|
|
return "data:image/{$format};base64,".base64_encode($encoded);
|
|
}
|
|
|
|
protected function encode(GdImage $image, string $format, ?int $quality = null): ?string
|
|
{
|
|
$quality ??= (int) config("media.quality.{$format}", 80);
|
|
|
|
ob_start();
|
|
|
|
$ok = match ($format) {
|
|
'webp' => imagewebp($image, null, $quality),
|
|
'avif' => imageavif($image, null, $quality),
|
|
'jpeg' => imagejpeg($image, null, $quality),
|
|
default => false,
|
|
};
|
|
|
|
$contents = (string) ob_get_clean();
|
|
|
|
return $ok && $contents !== '' ? $contents : null;
|
|
}
|
|
|
|
/**
|
|
* Whether this PHP can actually write the format, rather than whether the
|
|
* configuration would like it to.
|
|
*/
|
|
protected function canEncode(string $format): bool
|
|
{
|
|
return match ($format) {
|
|
'webp' => function_exists('imagewebp'),
|
|
'avif' => function_exists('imageavif'),
|
|
'jpeg' => function_exists('imagejpeg'),
|
|
default => false,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* `projects/cover.jpg` at 800px in WebP becomes `projects/cover-800.webp`,
|
|
* which keeps derived files next to their original and makes them easy to
|
|
* recognise and to clean up.
|
|
*/
|
|
protected function variantPath(string $path, int $width, string $format): string
|
|
{
|
|
$directory = trim(pathinfo($path, PATHINFO_DIRNAME), '.');
|
|
$name = pathinfo($path, PATHINFO_FILENAME);
|
|
|
|
return ltrim("{$directory}/{$name}-{$width}.{$format}", '/');
|
|
}
|
|
|
|
/**
|
|
* PNGs with transparency turn black when resampled without this.
|
|
*/
|
|
protected function preserveTransparency(GdImage $image): void
|
|
{
|
|
imagealphablending($image, false);
|
|
imagesavealpha($image, true);
|
|
}
|
|
|
|
/**
|
|
* @return array{0: GdImage, 1: int, 2: int}
|
|
*/
|
|
protected function applyOrientation(GdImage $image, string $contents, int $width, int $height): array
|
|
{
|
|
if (! function_exists('exif_read_data')) {
|
|
return [$image, $width, $height];
|
|
}
|
|
|
|
$exif = @exif_read_data('data://image/jpeg;base64,'.base64_encode($contents));
|
|
$orientation = (int) ($exif['Orientation'] ?? 0);
|
|
|
|
$degrees = match ($orientation) {
|
|
3 => 180,
|
|
6 => 270,
|
|
8 => 90,
|
|
default => 0,
|
|
};
|
|
|
|
if ($degrees === 0) {
|
|
return [$image, $width, $height];
|
|
}
|
|
|
|
$rotated = imagerotate($image, $degrees, 0);
|
|
|
|
if (! $rotated instanceof GdImage) {
|
|
return [$image, $width, $height];
|
|
}
|
|
|
|
imagedestroy($image);
|
|
|
|
return [$rotated, imagesx($rotated), imagesy($rotated)];
|
|
}
|
|
|
|
/**
|
|
* Dimensions for the formats that are copied through. An SVG carries them
|
|
* in its markup, which is worth reading: without a size the frontend
|
|
* cannot reserve the box either.
|
|
*
|
|
* @return array{0: ?int, 1: ?int}
|
|
*/
|
|
protected function passthroughDimensions(?string $mime, string $contents): array
|
|
{
|
|
if ($mime !== 'image/svg+xml') {
|
|
$size = @getimagesizefromstring($contents);
|
|
|
|
return $size === false ? [null, null] : [(int) $size[0], (int) $size[1]];
|
|
}
|
|
|
|
if (preg_match('/viewBox\s*=\s*["\']\s*[\d.+-]+[,\s]+[\d.+-]+[,\s]+([\d.]+)[,\s]+([\d.]+)/i', $contents, $m)) {
|
|
return [(int) round((float) $m[1]), (int) round((float) $m[2])];
|
|
}
|
|
|
|
$width = preg_match('/<svg[^>]*\swidth\s*=\s*["\']([\d.]+)/i', $contents, $m) ? (int) round((float) $m[1]) : null;
|
|
$height = preg_match('/<svg[^>]*\sheight\s*=\s*["\']([\d.]+)/i', $contents, $m) ? (int) round((float) $m[1]) : null;
|
|
|
|
return [$width, $height];
|
|
}
|
|
}
|