Files
toolvana/app/Libraries/Pipeline/Images.php
T
deepseek beaf0e1f37 Toolvana: production-ready media tools platform (CodeIgniter 4)
- 134-tool registry with programmatic SEO (unique titles/H1/descriptions,
  JSON-LD graphs, sitemap index, canonical 301 enforcement via required filter)
- DB-backed job queue (SKIP LOCKED) with drivers: Ffmpeg, Images (GD),
  Pdf (qpdf/gs/poppler), Youtube (thumbnails), Qr (server-side PNG)
- Security: SSRF guard, MIME validation, rate limits, API-key auth,
  bcrypt admin login, security headers
- Admin panel: dashboard, tools/categories/guides CRUD, SEO audit,
  analytics, job inspector with retry, system health, feature flags
- Docker deployment (nginx + web/api FPM pools + scalable workers),
  PHPUnit suite (19 tests / 1139 assertions), PWA manifest + service worker
2026-08-23 07:10:30 +00:00

127 lines
5.0 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Libraries\Pipeline;
/**
* Image driver — GD-based (jpg/png/webp/avif where the build supports
* it), with ImageMagick as a fallback for exotic formats. Re-encoding
* doubles as metadata stripping.
*/
final class Images implements DriverInterface
{
public function handle(array $job, array $tool, callable $update): array
{
$input = Ffmpeg::resolveInput($job);
$params = json_decode($job['params'] ?? '{}', true) ?: [];
$update(10, 'processing');
$image = imagecreatefromstring((string) file_get_contents($input));
if ($image === false) {
throw new \RuntimeException('The file could not be read as an image.');
}
// preserve orientation of JPEGs
$exif = @exif_read_data($input);
if ($exif !== false && isset($exif['Orientation'])) {
$angle = match ((int) $exif['Orientation']) {
3 => 180, 6 => -90, 8 => 90, default => null,
};
if ($angle !== null) {
$image = imagerotate($image, $angle, 0);
}
}
if (function_exists('imagepalettetotruecolor')) {
imagepalettetotruecolor($image);
}
imagealphablending($image, true);
imagesavealpha($image, true);
// resize / crop before encoding
$w = imagesx($image);
$h = imagesy($image);
if (! empty($params['width']) || ! empty($params['height'])) {
[$nw, $nh] = $this->fitSize($w, $h, (int) ($params['width'] ?? 0), (int) ($params['height'] ?? 0));
$scaled = imagecreatetruecolor(max(1, $nw), max(1, $nh));
imagealphablending($scaled, false);
imagesavealpha($scaled, true);
imagecopyresampled($scaled, $image, 0, 0, 0, 0, $nw, $nh, $w, $h);
imagedestroy($image);
$image = $scaled;
$update(55, 'resizing');
}
if (! empty($params['crop_width']) && ! empty($params['crop_height'])) {
$cw = min(imagesx($image), (int) $params['crop_width']);
$ch = min(imagesy($image), (int) $params['crop_height']);
$cx = max(0, min(imagesx($image) - $cw, (int) ($params['crop_x'] ?? 0)));
$cy = max(0, min(imagesy($image) - $ch, (int) ($params['crop_y'] ?? 0)));
$cropped = imagecreatetruecolor($cw, $ch);
imagealphablending($cropped, false);
imagesavealpha($cropped, true);
imagecopy($cropped, $image, 0, 0, $cx, $cy, $cw, $ch);
imagedestroy($image);
$image = $cropped;
$update(70, 'cropping');
}
if (($params['rotate'] ?? 0) != 0) {
$deg = in_array(abs((int) $params['rotate']), [90, 180, 270], true) ? (int) $params['rotate'] : 90;
$image = imagerotate($image, -$deg, 0); // GD rotates counter-clockwise
$update(70, 'rotating');
}
if (! empty($params['flip'])) {
imageflip($image, $params['flip'] === 'vertical' ? IMG_FLIP_VERTICAL : IMG_FLIP_HORIZONTAL);
}
$ext = strtolower((string) ($params['format'] ?? $tool['primary_output_format'] ?? pathinfo($job['input_file'], PATHINFO_EXTENSION)));
if ($ext === 'jpeg') {
$ext = 'jpg';
}
$quality = (int) max(30, min(100, (int) ($params['quality'] ?? 85)));
[, $out] = $this->outputPath($job, $ext);
$ok = match ($ext) {
'jpg' => imagejpeg($image, $out, $quality),
'png' => imagepng($image, $out, (int) round(9 * (1 - $quality / 100))),
'webp' => function_exists('imagewebp') ? imagewebp($image, $out, $quality) : false,
'avif' => function_exists('imageavif') ? imageavif($image, $out, $quality) : false,
'gif' => imagegif($image, $out),
default => throw new \RuntimeException("Format {$ext} is not supported."),
};
imagedestroy($image);
if (! $ok) {
throw new \RuntimeException("Saving as {$ext} is not supported by this server.");
}
$update(95, 'finalizing');
return ['file' => basename($out), 'name' => $this->downloadName($job, $ext), 'ext' => $ext];
}
private function fitSize(int $w, int $h, int $targetW, int $targetH): array
{
if ($targetW > 0 && $targetH > 0) {
return [min($targetW, $w * 4), min($targetH, $h * 4)];
}
if ($targetW > 0) {
return [$targetW, max(1, (int) round($h * $targetW / $w))];
}
return [max(1, (int) round($w * $targetH / $h)), $targetH];
}
private function outputPath(array $job, string $ext): array
{
$file = \App\Libraries\Pipeline::safeName($job['id'], $ext);
return [$file, \App\Libraries\Pipeline::storageDir() . '/' . $file];
}
private function downloadName(array $job, string $ext): string
{
$base = preg_replace('/[^A-Za-z0-9 _.-]/u', '_', pathinfo((string) ($job['input_name'] ?? 'image'), PATHINFO_FILENAME)) ?: 'image';
return mb_substr($base, 0, 80) . '.' . $ext;
}
}