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
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user