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 */ 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('/]*\swidth\s*=\s*["\']([\d.]+)/i', $contents, $m) ? (int) round((float) $m[1]) : null; $height = preg_match('/]*\sheight\s*=\s*["\']([\d.]+)/i', $contents, $m) ? (int) round((float) $m[1]) : null; return [$width, $height]; } }