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,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Libraries\Pipeline;
|
||||
|
||||
/**
|
||||
* A processing driver turns a job row into an output file.
|
||||
* handle() receives the raw job, the registry tool and an update()
|
||||
* callback for progress reporting (0-99 + stage label), and must
|
||||
* return ['file' => basename, 'name' => download name, 'ext' => 'mp3'].
|
||||
*/
|
||||
interface DriverInterface
|
||||
{
|
||||
public function handle(array $job, array $tool, callable $update): array;
|
||||
}
|
||||
@@ -0,0 +1,573 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Libraries\Pipeline;
|
||||
|
||||
use App\Libraries\Process;
|
||||
|
||||
/**
|
||||
* FFmpeg / ffprobe driver for video and audio operations.
|
||||
*
|
||||
* Security model: every operation maps to a fixed argument template.
|
||||
* User input only fills validated placeholders (numbers, enums, paths
|
||||
* inside the storage sandbox) — never raw strings appended to a command.
|
||||
*/
|
||||
final class Ffmpeg implements DriverInterface
|
||||
{
|
||||
private const AUDIO_EXT = ['mp3', 'wav', 'm4a', 'aac', 'flac', 'ogg', 'opus', 'wma'];
|
||||
|
||||
/** Audio codec mapping: ext => [codec, extra args] */
|
||||
private const AUDIO_CODECS = [
|
||||
'mp3' => ['libmp3lame', ['-b:a']],
|
||||
'wav' => ['pcm_s16le', []],
|
||||
'm4a' => ['aac', ['-b:a']],
|
||||
'aac' => ['aac', ['-b:a']],
|
||||
'flac' => ['flac', []],
|
||||
'ogg' => ['libvorbis', ['-b:a']],
|
||||
'opus' => ['libopus', ['-b:a']],
|
||||
];
|
||||
|
||||
public function handle(array $job, array $tool, callable $update): array
|
||||
{
|
||||
$params = json_decode($job['params'] ?? '{}', true) ?: [];
|
||||
$input = self::resolveInput($job);
|
||||
$update(5, 'fetching');
|
||||
|
||||
return match ($job['operation']) {
|
||||
'convert' => isset($params['_mode']) && $params['_mode'] === 'audio' || in_array($this->targetExt($job, $tool), self::AUDIO_EXT, true)
|
||||
? $this->convertAudio($input, $job, $tool, $update, $params)
|
||||
: $this->convertVideo($input, $job, $tool, $update, $params),
|
||||
'compress' => $this->compress($input, $job, $tool, $update, $params),
|
||||
'resize' => $this->scale($input, $job, $tool, $update, $params),
|
||||
'crop' => $this->crop($input, $job, $tool, $update, $params),
|
||||
'trim' => $this->trim($input, $job, $tool, $update, $params),
|
||||
'cut' => $this->trim($input, $job, $tool, $update, $params),
|
||||
'merge' => $this->merge($input, $job, $tool, $update),
|
||||
'rotate' => $this->transpose($input, $job, $tool, $update, $params, false),
|
||||
'flip' => $this->transpose($input, $job, $tool, $update, $params, true),
|
||||
'speed' => $this->speed($input, $job, $tool, $update, $params),
|
||||
'fps' => $this->fps($input, $job, $tool, $update, $params),
|
||||
'extract_audio' => $this->extractAudio($input, $job, $tool, $update, $params),
|
||||
'remove_audio' => $this->mute($input, $job, $tool, $update, keepVideo: true, silent: true),
|
||||
'mute' => $this->mute($input, $job, $tool, $update, keepVideo: true, silent: true),
|
||||
'add_audio' => $this->addAudio($input, $job, $tool, $update, $params),
|
||||
'add_subtitles' => $this->addSubtitles($input, $job, $tool, $update, $params),
|
||||
'remove_metadata' => $this->stripMetadata($input, $job, $tool, $update),
|
||||
'extract_frames' => $this->framesZip($input, $job, $update, $params),
|
||||
'video_to_gif' => $this->toGif($input, $job, $tool, $update, $params),
|
||||
'gif_to_video' => $this->convertVideo($input, $job, $tool, $update, ['_force_ext' => 'mp4']),
|
||||
'normalize' => $this->normalizeAudio($input, $job, $tool, $update),
|
||||
'volume' => $this->volume($input, $job, $tool, $update, $params),
|
||||
default => throw new \RuntimeException('Unsupported operation.'),
|
||||
};
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// helpers shared by operations
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
public static function resolveInput(array $job): string
|
||||
{
|
||||
foreach ([\App\Libraries\Pipeline::incomingDir(), \App\Libraries\Pipeline::storageDir()] as $dir) {
|
||||
$file = self::storagePath((string) ($job['input_file'] ?? ''), $dir);
|
||||
if (is_file($file)) {
|
||||
return $file;
|
||||
}
|
||||
}
|
||||
throw new \RuntimeException('Input file missing.');
|
||||
}
|
||||
|
||||
public static function storagePath(string $basename, ?string $baseDir = null): string
|
||||
{
|
||||
if (preg_match('/^[A-Za-z0-9._-]+$/', $basename) !== 1 || str_contains($basename, '..')) {
|
||||
throw new \RuntimeException('Invalid file reference.');
|
||||
}
|
||||
$baseDir ??= \App\Libraries\Pipeline::storageDir();
|
||||
$path = rtrim($baseDir, '/') . '/' . $basename;
|
||||
if (! str_starts_with(realpath(dirname($path)) ?: '', realpath($baseDir) ?: '')) {
|
||||
throw new \RuntimeException('Invalid file path.');
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
private function probe(string $file): array
|
||||
{
|
||||
$proc = Process::binary('ffprobe', [
|
||||
'-v', 'quiet', '-print_format', 'json',
|
||||
'-show_format', '-show_streams', $file,
|
||||
], 30);
|
||||
if (! $proc->run()) {
|
||||
throw new \RuntimeException('Could not read the media file.');
|
||||
}
|
||||
$data = json_decode($proc->out(), true);
|
||||
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
|
||||
private function targetExt(array $job, array $tool): string
|
||||
{
|
||||
$params = json_decode($job['params'] ?? '{}', true) ?: [];
|
||||
$ext = strtolower((string) ($params['format'] ?? $tool['primary_output_format'] ?? ''));
|
||||
if (preg_match('/^[a-z0-9]{1,6}$/', $ext) !== 1) {
|
||||
throw new \RuntimeException('Unknown output format.');
|
||||
}
|
||||
|
||||
return $ext;
|
||||
}
|
||||
|
||||
private function output(array $job, string $ext): array
|
||||
{
|
||||
$file = \App\Libraries\Pipeline::safeName($job['id'], $ext);
|
||||
|
||||
return [$file, \App\Libraries\Pipeline::storageDir() . '/' . $file];
|
||||
}
|
||||
|
||||
private function num(mixed $value, float $min, float $max, float $default): float
|
||||
{
|
||||
if (! is_numeric($value)) {
|
||||
return $default;
|
||||
}
|
||||
$v = (float) $value;
|
||||
|
||||
return max($min, min($max, $v));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// operations
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
private function convertAudio(string $input, array $job, array $tool, callable $update, array $params): array
|
||||
{
|
||||
$ext = $this->targetExt($job, $tool);
|
||||
if (! isset(self::AUDIO_CODECS[$ext])) {
|
||||
throw new \RuntimeException("Format {$ext} is not supported here.");
|
||||
}
|
||||
[$codec, $extra] = self::AUDIO_CODECS[$ext];
|
||||
|
||||
$bitrate = (int) $this->num($params['bitrate'] ?? 192, 32, 320, 192);
|
||||
$args = ['-y', '-i', $input, '-vn', '-c:a', $codec];
|
||||
foreach ($extra as $flag) {
|
||||
$args[] = $flag;
|
||||
$args[] = ($bitrate) . 'k';
|
||||
}
|
||||
if ($ext === 'mp3' || $ext === 'flac') {
|
||||
$args[] = '-metadata';
|
||||
$args[] = 'comment=Processed with Toolvana';
|
||||
}
|
||||
[, $out] = $this->output($job, $ext);
|
||||
$args[] = $out;
|
||||
|
||||
$update(15, 'converting');
|
||||
$proc = Process::binary('ffmpeg', $args, config('Site')->maxProcessingSeconds);
|
||||
if (! $proc->run()) {
|
||||
throw new \RuntimeException('Conversion failed. The file may be corrupted or in an unsupported format.');
|
||||
}
|
||||
|
||||
return ['file' => basename($out), 'name' => $this->downloadName($job, $ext), 'ext' => $ext];
|
||||
}
|
||||
|
||||
private function convertVideo(string $input, array $job, array $tool, callable $update, array $params): array
|
||||
{
|
||||
$ext = strtolower((string) ($params['_force_ext'] ?? $this->targetExt($job, $tool)));
|
||||
// [video args, uses CRF?, audio args, mux args]
|
||||
$map = [
|
||||
'mp4' => [['-c:v', 'libx264', '-preset', 'medium'], true, ['-c:a', 'aac', '-b:a', '160k'], ['-movflags', '+faststart']],
|
||||
'webm' => [['-c:v', 'libvpx-vp9'], true, ['-c:a', 'libopus', '-b:a', '128k'], []],
|
||||
'mkv' => [['-c:v', 'libx264', '-preset', 'medium'], true, ['-c:a', 'copy'], []],
|
||||
'mov' => [['-c:v', 'libx264', '-preset', 'medium'], true, ['-c:a', 'aac', '-b:a', '160k'], []],
|
||||
'avi' => [['-c:v', 'mpeg4'], false, ['-c:a', 'libmp3lame', '-b:a', '192k'], []],
|
||||
];
|
||||
if (! isset($map[$ext])) {
|
||||
throw new \RuntimeException("Format {$ext} is not supported here.");
|
||||
}
|
||||
[$videoArgs, $usesCrf, $audioArgs, $muxArgs] = $map[$ext];
|
||||
|
||||
$args = ['-y', '-i', $input, ...$videoArgs];
|
||||
if ($usesCrf) {
|
||||
$args[] = '-crf';
|
||||
$args[] = (string) (int) $this->num($params['quality'] ?? 23, 18, 34, 23);
|
||||
} else {
|
||||
$args[] = '-qscale:v';
|
||||
$args[] = '3';
|
||||
}
|
||||
$args = [...$args, ...$audioArgs, ...$muxArgs];
|
||||
|
||||
[, $out] = $this->output($job, $ext);
|
||||
$args[] = $out;
|
||||
|
||||
$update(10, 'converting');
|
||||
$proc = Process::binary('ffmpeg', $args, config('Site')->maxProcessingSeconds);
|
||||
if (! $proc->run()) {
|
||||
throw new \RuntimeException('Conversion failed. The source video could not be processed.');
|
||||
}
|
||||
|
||||
return ['file' => basename($out), 'name' => $this->downloadName($job, $ext), 'ext' => $ext];
|
||||
}
|
||||
|
||||
private function compress(string $input, array $job, array $tool, callable $update, array $params): array
|
||||
{
|
||||
$level = (int) $this->num($params['level'] ?? 28, 20, 38, 28); // CRF-style
|
||||
$info = $this->probe($input);
|
||||
$hasV = false;
|
||||
foreach ($info['streams'] ?? [] as $s) {
|
||||
if (($s['codec_type'] ?? '') === 'video') {
|
||||
$hasV = true;
|
||||
}
|
||||
}
|
||||
|
||||
$ext = $hasV ? 'mp4' : 'm4a';
|
||||
[, $out] = $this->output($job, $ext);
|
||||
|
||||
$args = ['-y', '-i', $input];
|
||||
if ($hasV) {
|
||||
$args = [...$args, '-c:v', 'libx264', '-preset', 'slow', '-crf', (string) $level, '-pix_fmt', 'yuv420p', '-movflags', '+faststart'];
|
||||
} else {
|
||||
$args = [...$args, '-c:a', 'aac', '-b:a', '96k'];
|
||||
}
|
||||
$args[] = $out;
|
||||
|
||||
$update(10, 'compressing');
|
||||
$proc = Process::binary('ffmpeg', $args, config('Site')->maxProcessingSeconds);
|
||||
if (! $proc->run()) {
|
||||
throw new \RuntimeException('Compression failed.');
|
||||
}
|
||||
|
||||
return ['file' => basename($out), 'name' => $this->downloadName($job, $ext), 'ext' => $ext];
|
||||
}
|
||||
|
||||
private function scale(string $input, array $job, array $tool, callable $update, array $params): array
|
||||
{
|
||||
$w = (int) $this->num($params['width'] ?? 0, 16, 7680, 0);
|
||||
$h = (int) $this->num($params['height'] ?? 0, 16, 4320, 0);
|
||||
if ($w === 0 && $h === 0) {
|
||||
throw new \RuntimeException('Choose a target width or height.');
|
||||
}
|
||||
$filter = $h > 0 ? "scale=-2:{$h}" : "scale={$w}:-2";
|
||||
|
||||
return $this->simpleVideoTransform($input, $job, $update, $filter, 'resizing');
|
||||
}
|
||||
|
||||
private function crop(string $input, array $job, array $tool, callable $update, array $params): array
|
||||
{
|
||||
$w = (int) $this->num($params['width'] ?? 1080, 16, 7680, 1080);
|
||||
$h = (int) $this->num($params['height'] ?? 1080, 16, 4320, 1080);
|
||||
$x = (int) $this->num($params['x'] ?? 0, 0, 7680, 0);
|
||||
$y = (int) $this->num($params['y'] ?? 0, 0, 4320, 0);
|
||||
|
||||
return $this->simpleVideoTransform($input, $job, $update, "crop={$w}:{$h}:{$x}:{$y}", 'cropping');
|
||||
}
|
||||
|
||||
private function trim(string $input, array $job, array $tool, callable $update, array $params): array
|
||||
{
|
||||
$start = max(0.0, $this->num($params['start'] ?? 0, 0, 86400, 0));
|
||||
$end = $this->num($params['end'] ?? 0, 0, 86401, 0);
|
||||
if ($end <= $start) {
|
||||
throw new \RuntimeException('End time must be after start time.');
|
||||
}
|
||||
$dur = $end - $start;
|
||||
|
||||
$ext = $this->guessContainer($input);
|
||||
[, $out] = $this->output($job, $ext);
|
||||
$proc = Process::binary('ffmpeg', [
|
||||
'-y', '-i', $input, '-ss', (string) $start, '-t', number_format($dur, 3, '.', ''),
|
||||
'-c', 'copy', '-movflags', '+faststart', $out,
|
||||
], config('Site')->maxProcessingSeconds);
|
||||
$update(20, 'cutting');
|
||||
if (! $proc->run() || ! is_file($out) || filesize($out) < 64) {
|
||||
// stream copy can fail on some sources — re-encode fallback
|
||||
$proc2 = Process::binary('ffmpeg', [
|
||||
'-y', '-i', $input, '-ss', (string) $start, '-t', number_format($dur, 3, '.', ''), $out,
|
||||
], config('Site')->maxProcessingSeconds);
|
||||
if (! $proc2->run()) {
|
||||
throw new \RuntimeException('Trim failed.');
|
||||
}
|
||||
}
|
||||
|
||||
return ['file' => basename($out), 'name' => $this->downloadName($job, $ext), 'ext' => $ext];
|
||||
}
|
||||
|
||||
private function merge(string $input, array $job, array $tool, callable $update): array
|
||||
{
|
||||
$extraFiles = json_decode($job['params'] ?? '{}', true)['extra_files'] ?? [];
|
||||
$files = [$input];
|
||||
foreach ((array) $extraFiles as $f) {
|
||||
$files[] = self::storagePath((string) $f);
|
||||
}
|
||||
if (count($files) < 2) {
|
||||
throw new \RuntimeException('At least two files are required to merge.');
|
||||
}
|
||||
|
||||
// concat demuxer needs a list file
|
||||
$listPath = \App\Libraries\Pipeline::incomingDir() . '/' . $job['id'] . '.txt';
|
||||
$lines = '';
|
||||
foreach ($files as $f) {
|
||||
$lines .= "file '" . addcslashes($f, "'\\") . "'\n";
|
||||
}
|
||||
file_put_contents($listPath, $lines);
|
||||
|
||||
[, $out] = $this->output($job, 'mp4');
|
||||
$update(15, 'merging');
|
||||
$proc = Process::binary('ffmpeg', ['-y', '-f', 'concat', '-safe', '0', '-i', $listPath, '-c', 'copy', '-movflags', '+faststart', $out], config('Site')->maxProcessingSeconds);
|
||||
@unlink($listPath);
|
||||
if (! $proc->run() || ! is_file($out) || filesize($out) < 64) {
|
||||
throw new \RuntimeException('Merge failed — files must share the same resolution and codecs.');
|
||||
}
|
||||
|
||||
return ['file' => basename($out), 'name' => $this->downloadName($job, 'mp4'), 'ext' => 'mp4'];
|
||||
}
|
||||
|
||||
private function transpose(string $input, array $job, array $tool, callable $update, array $params, bool $flip): array
|
||||
{
|
||||
if ($flip) {
|
||||
$dir = ($params['direction'] ?? 'horizontal') === 'vertical' ? 'vflip' : 'hflip';
|
||||
} else {
|
||||
$deg = (int) $this->num($params['degrees'] ?? 90, 90, 270, 90);
|
||||
$map = [90 => 'transpose=clock', 180 => 'transpose=clock,transpose=clock', 270 => 'transpose=cclock'];
|
||||
$dir = $map[$deg] ?? 'transpose=clock';
|
||||
}
|
||||
|
||||
return $this->simpleVideoTransform($input, $job, $update, $dir, $flip ? 'flipping' : 'rotating');
|
||||
}
|
||||
|
||||
private function speed(string $input, array $job, array $tool, callable $update, array $params): array
|
||||
{
|
||||
$factor = $this->num($params['factor'] ?? 1.5, 0.25, 4, 1.5);
|
||||
if (abs($factor - 1.0) < 0.05) {
|
||||
throw new \RuntimeException('Speed factor must differ from 1.0.');
|
||||
}
|
||||
$atempo = $factor >= 0.5 && $factor <= 2.0
|
||||
? 'atempo=' . number_format($factor, 3, '.', '')
|
||||
: 'atempo=2.0,atempo=' . number_format($factor / 2, 3, '.', '');
|
||||
|
||||
return $this->simpleVideoTransform(
|
||||
$input, $job, $update,
|
||||
'setpts=' . number_format(1 / $factor, 4, '.', '') . '*PTS,' . $atempo,
|
||||
'changing speed'
|
||||
);
|
||||
}
|
||||
|
||||
private function fps(string $input, array $job, array $tool, callable $update, array $params): array
|
||||
{
|
||||
$fps = $this->num($params['fps'] ?? 24, 1, 120, 24);
|
||||
|
||||
return $this->simpleVideoTransform($input, $job, $update, "fps={$fps}", 'converting fps');
|
||||
}
|
||||
|
||||
private function extractAudio(string $input, array $job, array $tool, callable $update, array $params): array
|
||||
{
|
||||
$params['format'] = $params['format'] ?? 'mp3';
|
||||
|
||||
return $this->convertAudio($input, $job, $tool, $update, $params);
|
||||
}
|
||||
|
||||
private function mute(string $input, array $job, array $tool, callable $update, bool $keepVideo, bool $silent): array
|
||||
{
|
||||
$ext = $this->guessContainer($input);
|
||||
[, $out] = $this->output($job, $ext);
|
||||
$update(15, 'removing audio');
|
||||
$proc = Process::binary('ffmpeg', ['-y', '-i', $input, '-an', '-c:v', 'copy', '-movflags', '+faststart', $out], config('Site')->maxProcessingSeconds);
|
||||
if (! $proc->run()) {
|
||||
throw new \RuntimeException('Could not remove the audio track.');
|
||||
}
|
||||
|
||||
return ['file' => basename($out), 'name' => $this->downloadName($job, $ext), 'ext' => $ext];
|
||||
}
|
||||
|
||||
private function addAudio(string $input, array $job, array $tool, callable $update, array $params): array
|
||||
{
|
||||
$audioFile = self::storagePath((string) ($params['audio_file'] ?? ''));
|
||||
[, $out] = $this->output($job, 'mp4');
|
||||
$update(15, 'adding audio');
|
||||
|
||||
$shortest = ! empty($params['stop_at_shortest']) ? ['-shortest'] : [];
|
||||
$proc = Process::binary('ffmpeg', [
|
||||
'-y', '-i', $input, '-i', $audioFile, '-map', '0:v:0', '-map', '1:a:0',
|
||||
'-c:v', 'copy', '-c:a', 'aac', '-b:a', '192k', ...$shortest, '-movflags', '+faststart', $out,
|
||||
], config('Site')->maxProcessingSeconds);
|
||||
if (! $proc->run()) {
|
||||
throw new \RuntimeException('Could not combine video and audio.');
|
||||
}
|
||||
|
||||
return ['file' => basename($out), 'name' => $this->downloadName($job, 'mp4'), 'ext' => 'mp4'];
|
||||
}
|
||||
|
||||
private function addSubtitles(string $input, array $job, array $tool, callable $update, array $params): array
|
||||
{
|
||||
$subFile = self::storagePath((string) ($params['subtitle_file'] ?? ''));
|
||||
[, $out] = $this->output($job, 'mp4');
|
||||
$update(15, 'adding subtitles');
|
||||
|
||||
$proc = Process::binary('ffmpeg', [
|
||||
'-y', '-i', $input, '-i', $subFile,
|
||||
'-c:v', 'libx264', '-preset', 'fast', '-crf', '22',
|
||||
'-c:a', 'copy', '-c:s', 'mov_text', '-movflags', '+faststart', $out,
|
||||
], config('Site')->maxProcessingSeconds);
|
||||
if (! $proc->run()) {
|
||||
throw new \RuntimeException('Could not embed subtitles. Use an SRT or VTT file.');
|
||||
}
|
||||
|
||||
return ['file' => basename($out), 'name' => $this->downloadName($job, 'mp4'), 'ext' => 'mp4'];
|
||||
}
|
||||
|
||||
private function stripMetadata(string $input, array $job, array $tool, callable $update): array
|
||||
{
|
||||
$ext = $this->guessContainer($input);
|
||||
[, $out] = $this->output($job, $ext);
|
||||
$update(20, 'removing metadata');
|
||||
$proc = Process::binary('ffmpeg', [
|
||||
'-y', '-i', $input, '-map_metadata', '-1', '-fflags', '+bitexact',
|
||||
'-flags:v', '+bitexact', '-flags:a', '+bitexact', '-c', 'copy', $out,
|
||||
], config('Site')->maxProcessingSeconds);
|
||||
if (! $proc->run()) {
|
||||
throw new \RuntimeException('Could not strip metadata.');
|
||||
}
|
||||
|
||||
return ['file' => basename($out), 'name' => $this->downloadName($job, $ext), 'ext' => $ext];
|
||||
}
|
||||
|
||||
private function framesZip(string $input, array $job, callable $update, array $params): array
|
||||
{
|
||||
$fps = $this->num($params['fps'] ?? 1, 0.1, 30, 1);
|
||||
$tmpDir = sys_get_temp_dir() . '/tv_frames_' . $job['id'];
|
||||
mkdir($tmpDir, 0700, true);
|
||||
$update(15, 'extracting frames');
|
||||
|
||||
$proc = Process::binary('ffmpeg', [
|
||||
'-y', '-i', $input, '-vf', "fps={$fps}", '-q:v', '2',
|
||||
rtrim($tmpDir, '/') . '/frame_%05d.jpg',
|
||||
], config('Site')->maxProcessingSeconds);
|
||||
if (! $proc->run()) {
|
||||
throw new \RuntimeException('Frame extraction failed.');
|
||||
}
|
||||
|
||||
$zipPath = \App\Libraries\Pipeline::storageDir() . '/' . \App\Libraries\Pipeline::safeName($job['id'], 'zip');
|
||||
$zip = new \ZipArchive();
|
||||
$zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE);
|
||||
$count = 0;
|
||||
foreach (glob(rtrim($tmpDir, '/') . '/frame_*.jpg') ?: [] as $frame) {
|
||||
$zip->addFile($frame, basename($frame));
|
||||
++$count;
|
||||
}
|
||||
$zip->close();
|
||||
foreach (glob(rtrim($tmpDir, '/') . '/*') ?: [] as $f) {
|
||||
@unlink($f);
|
||||
}
|
||||
@rmdir($tmpDir);
|
||||
|
||||
if ($count === 0) {
|
||||
throw new \RuntimeException('No frames were extracted.');
|
||||
}
|
||||
|
||||
return ['file' => basename($zipPath), 'name' => $this->downloadName($job, 'zip'), 'ext' => 'zip'];
|
||||
}
|
||||
|
||||
private function toGif(string $input, array $job, array $tool, callable $update, array $params): array
|
||||
{
|
||||
$width = (int) $this->num($params['width'] ?? 480, 64, 1280, 480);
|
||||
$fps = $this->num($params['fps'] ?? 12, 4, 24, 12);
|
||||
[, $out] = $this->output($job, 'gif');
|
||||
$palette = sys_get_temp_dir() . '/tv_palette_' . $job['id'] . '.png';
|
||||
|
||||
$update(10, 'analyzing colors');
|
||||
$p1 = Process::binary('ffmpeg', [
|
||||
'-y', '-t', '60', '-i', $input, '-vf',
|
||||
"fps={$fps},scale={$width}:-1:flags=lanczos,palettegen", $palette,
|
||||
], 300);
|
||||
$p1->run();
|
||||
|
||||
$update(25, 'generating GIF');
|
||||
$vf = "fps={$fps},scale={$width}:-1:flags=lanczos";
|
||||
if (is_file($palette)) {
|
||||
$vf .= '[x];[x][1:v]paletteuse';
|
||||
$args = ['-y', '-i', $input, '-i', $palette, '-filter_complex', $vf, $out];
|
||||
} else {
|
||||
$args = ['-y', '-i', $input, '-vf', $vf, $out];
|
||||
}
|
||||
$p2 = Process::binary('ffmpeg', $args, config('Site')->maxProcessingSeconds);
|
||||
@unlink($palette);
|
||||
if (! $p2->run() || ! is_file($out) || filesize($out) === 0) {
|
||||
throw new \RuntimeException('GIF generation failed.');
|
||||
}
|
||||
|
||||
return ['file' => basename($out), 'name' => $this->downloadName($job, 'gif'), 'ext' => 'gif'];
|
||||
}
|
||||
|
||||
private function normalizeAudio(string $input, array $job, array $tool, callable $update): array
|
||||
{
|
||||
$params = json_decode($job['params'] ?? '{}', true) ?: [];
|
||||
$ext = $params['format'] ?? 'mp3';
|
||||
[$codec, $extra] = self::AUDIO_CODECS[strtolower((string) $ext)] ?? self::AUDIO_CODECS['mp3'];
|
||||
[, $out] = $this->output($job, $ext);
|
||||
|
||||
$update(10, 'normalizing');
|
||||
$args = ['-y', '-i', $input, '-af', 'loudnorm=I=-16:TP=-1.5:LRA=11', '-ar', '48000', '-c:a', $codec];
|
||||
foreach ($extra as $flag) {
|
||||
$args[] = $flag;
|
||||
$args[] = '192k';
|
||||
}
|
||||
$args[] = $out;
|
||||
$proc = Process::binary('ffmpeg', $args, config('Site')->maxProcessingSeconds);
|
||||
if (! $proc->run()) {
|
||||
throw new \RuntimeException('Normalization failed.');
|
||||
}
|
||||
|
||||
return ['file' => basename($out), 'name' => $this->downloadName($job, $ext), 'ext' => $ext];
|
||||
}
|
||||
|
||||
private function volume(string $input, array $job, array $tool, callable $update, array $params): array
|
||||
{
|
||||
$gain = $this->num($params['gain_db'] ?? 6, -30, 30, 6);
|
||||
$ext = strtolower((string) (json_decode($job['params'] ?? '{}', true)['format'] ?? 'mp3'));
|
||||
[$codec, $extra] = self::AUDIO_CODECS[$ext] ?? self::AUDIO_CODECS['mp3'];
|
||||
[, $out] = $this->output($job, $ext);
|
||||
|
||||
$update(15, 'boosting volume');
|
||||
$args = ['-y', '-i', $input, '-af', 'volume=' . number_format($gain, 1, '.', '') . 'dB', '-c:a', $codec];
|
||||
foreach ($extra as $flag) {
|
||||
$args[] = $flag;
|
||||
$args[] = '192k';
|
||||
}
|
||||
$args[] = $out;
|
||||
$proc = Process::binary('ffmpeg', $args, config('Site')->maxProcessingSeconds);
|
||||
if (! $proc->run()) {
|
||||
throw new \RuntimeException('Volume adjustment failed.');
|
||||
}
|
||||
|
||||
return ['file' => basename($out), 'name' => $this->downloadName($job, $ext), 'ext' => $ext];
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
private function simpleVideoTransform(string $input, array $job, callable $update, string $filter, string $stage): array
|
||||
{
|
||||
[, $out] = $this->output($job, 'mp4');
|
||||
$update(15, $stage);
|
||||
$proc = Process::binary('ffmpeg', [
|
||||
'-y', '-i', $input, '-vf', $filter,
|
||||
'-c:v', 'libx264', '-preset', 'fast', '-crf', '21',
|
||||
'-c:a', 'aac', '-b:a', '160k', '-movflags', '+faststart', $out,
|
||||
], config('Site')->maxProcessingSeconds);
|
||||
if (! $proc->run() || ! is_file($out) || filesize($out) === 0) {
|
||||
throw new \RuntimeException(ucfirst($stage) . ' failed.');
|
||||
}
|
||||
|
||||
return ['file' => basename($out), 'name' => $this->downloadName($job, 'mp4'), 'ext' => 'mp4'];
|
||||
}
|
||||
|
||||
private function guessContainer(string $input): string
|
||||
{
|
||||
$ext = strtolower(pathinfo($input, PATHINFO_EXTENSION));
|
||||
|
||||
return in_array($ext, ['mp4', 'webm', 'mkv', 'mov', 'avi'], true) ? $ext : 'mp4';
|
||||
}
|
||||
|
||||
private function downloadName(array $job, string $ext): string
|
||||
{
|
||||
$base = pathinfo((string) ($job['input_name'] ?? 'media'), PATHINFO_FILENAME);
|
||||
$base = preg_replace('/[^A-Za-z0-9 _.-]/u', '_', $base) ?: 'media';
|
||||
|
||||
return mb_substr($base, 0, 80) . '.' . $ext;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Libraries\Pipeline;
|
||||
|
||||
use App\Libraries\Process;
|
||||
|
||||
/**
|
||||
* PDF driver: qpdf (merge/split/pages/metadata), Ghostscript (compress),
|
||||
* Poppler (rasterize + text extraction). Images->PDF uses GD to build
|
||||
* pages and Ghostscript to assemble.
|
||||
*/
|
||||
final class Pdf implements DriverInterface
|
||||
{
|
||||
public function handle(array $job, array $tool, callable $update): array
|
||||
{
|
||||
return match ($job['operation']) {
|
||||
'pdf_merge' => $this->merge($job, $update),
|
||||
'pdf_split' => $this->split($job, $update, singlePage: false),
|
||||
'pdf_extract_pages' => $this->extractPages($job, $update),
|
||||
'pdf_compress' => $this->compress($job, $update),
|
||||
'pdf_remove_metadata'=> $this->removeMetadata($job, $update),
|
||||
'pdf_to_jpg' => $this->rasterize($job, $update, 'jpg'),
|
||||
'pdf_to_png' => $this->rasterize($job, $update, 'png'),
|
||||
'pdf_to_text' => $this->toText($job, $update),
|
||||
'images_to_pdf' => $this->imagesToPdf($job, $update),
|
||||
default => throw new \RuntimeException('Unsupported operation.'),
|
||||
};
|
||||
}
|
||||
|
||||
private function merge(array $job, callable $update): array
|
||||
{
|
||||
$extra = json_decode($job['params'] ?? '{}', true)['extra_files'] ?? [];
|
||||
$files = [Ffmpeg::resolveInput($job)];
|
||||
foreach ((array) $extra as $f) {
|
||||
$files[] = Ffmpeg::storagePath((string) $f, \App\Libraries\Pipeline::incomingDir());
|
||||
}
|
||||
if (count($files) < 2) {
|
||||
throw new \RuntimeException('Select at least two PDF files.');
|
||||
}
|
||||
|
||||
[, $out] = self::output($job, 'pdf');
|
||||
$update(20, 'merging');
|
||||
$proc = Process::binary('qpdf', ['--empty', '--pages', ...$files, '--', $out], config('Site')->maxProcessingSeconds);
|
||||
if (! $proc->run()) {
|
||||
throw new \RuntimeException('Merge failed — are all files valid PDFs?');
|
||||
}
|
||||
|
||||
return ['file' => basename($out), 'name' => 'merged.pdf', 'ext' => 'pdf'];
|
||||
}
|
||||
|
||||
private function split(array $job, callable $update, bool $singlePage): array
|
||||
{
|
||||
$input = Ffmpeg::resolveInput($job);
|
||||
[$zipFile, $zipPath] = self::output($job, 'zip');
|
||||
$tmpDir = sys_get_temp_dir() . '/tv_pdf_' . $job['id'];
|
||||
if (! is_dir($tmpDir)) { mkdir($tmpDir, 0700, true); }
|
||||
|
||||
// one PDF per page via qpdf page ranges
|
||||
$info = Process::binary('pdfinfo', [$input], 30);
|
||||
$pagesN = 0;
|
||||
if ($info->run() && preg_match('/Pages:\s+(\d+)/', $info->out(), $m)) {
|
||||
$pagesN = (int) $m[1];
|
||||
}
|
||||
if ($pagesN < 1) {
|
||||
throw new \RuntimeException('Could not read the PDF.');
|
||||
}
|
||||
$update(10, 'splitting');
|
||||
for ($p = 1; $p <= $pagesN; ++$p) {
|
||||
$proc = Process::binary('qpdf', [$input, '--pages', '.', (string) $p, '--', rtrim($tmpDir, '/') . "/page_{$p}.pdf"], 120);
|
||||
if (! $proc->run()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$this->zipDirectory($zipPath, $tmpDir);
|
||||
|
||||
return ['file' => basename($zipPath), 'name' => 'split_pages.zip', 'ext' => 'zip'];
|
||||
}
|
||||
|
||||
private function extractPages(array $job, callable $update): array
|
||||
{
|
||||
$input = Ffmpeg::resolveInput($job);
|
||||
$range = (string) (json_decode($job['params'] ?? '{}', true)['pages'] ?? '');
|
||||
if (! preg_match('/^[0-9,\- ]{1,60}$/', $range)) {
|
||||
throw new \RuntimeException('Enter pages like 1-3 or 2,5,7.');
|
||||
}
|
||||
$normalized = str_replace(' ', '', $range);
|
||||
[, $out] = self::output($job, 'pdf');
|
||||
$update(25, 'extracting');
|
||||
|
||||
// expand "1-3" into individual page numbers for qpdf
|
||||
$parts = [];
|
||||
foreach (explode(',', $normalized) as $chunk) {
|
||||
if (preg_match('/^(\d+)-(\d+)$/', $chunk, $m)) {
|
||||
foreach (range((int) $m[1], (int) $m[2]) as $p) {
|
||||
$parts[] = (string) $p;
|
||||
}
|
||||
} elseif (ctype_digit($chunk)) {
|
||||
$parts[] = $chunk;
|
||||
}
|
||||
}
|
||||
$proc = Process::binary('qpdf', ['--empty', '--pages', $input, ...$parts, '--', $out], 300);
|
||||
if (! $proc->run()) {
|
||||
throw new \RuntimeException('Extraction failed — check the page range.');
|
||||
}
|
||||
|
||||
return ['file' => basename($out), 'name' => 'extracted.pdf', 'ext' => 'pdf'];
|
||||
}
|
||||
|
||||
private function compress(array $job, callable $update): array
|
||||
{
|
||||
$input = Ffmpeg::resolveInput($job);
|
||||
[, $out] = self::output($job, 'pdf');
|
||||
$level = (string) (json_decode($job['params'] ?? '{}', true)['preset'] ?? 'ebook');
|
||||
$valid = ['screen', 'ebook', 'printer', 'prepress'];
|
||||
in_array($level, $valid, true) || $level = 'ebook';
|
||||
|
||||
$update(15, 'compressing');
|
||||
$proc = Process::binary('gs', [
|
||||
'-sDEVICE=pdfwrite', '-dCompatibilityLevel=1.5', '-dPDFSETTINGS=/' . $level,
|
||||
'-dNOPAUSE', '-dQUIET', '-dBATCH',
|
||||
"-sOutputFile={$out}", $input,
|
||||
], config('Site')->maxProcessingSeconds);
|
||||
if (! $proc->run() || ! is_file($out) || filesize($out) === 0) {
|
||||
throw new \RuntimeException('Compression failed.');
|
||||
}
|
||||
if (filesize($out) >= filesize($input)) {
|
||||
// already optimized — deliver a byte-identical copy rather than bigger file
|
||||
copy($input, $out);
|
||||
}
|
||||
|
||||
return ['file' => basename($out), 'name' => 'compressed.pdf', 'ext' => 'pdf'];
|
||||
}
|
||||
|
||||
private function removeMetadata(array $job, callable $update): array
|
||||
{
|
||||
$input = Ffmpeg::resolveInput($job);
|
||||
[, $out] = self::output($job, 'pdf');
|
||||
$update(30, 'removing metadata');
|
||||
$proc = Process::binary('qpdf', ['--empty', '--pages', $input, '1-z', '--', $out], 300);
|
||||
if (! $proc->run()) {
|
||||
throw new \RuntimeException('Metadata removal failed.');
|
||||
}
|
||||
|
||||
return ['file' => basename($out), 'name' => 'cleaned.pdf', 'ext' => 'pdf'];
|
||||
}
|
||||
|
||||
private function rasterize(array $job, callable $update, string $format): array
|
||||
{
|
||||
$input = Ffmpeg::resolveInput($job);
|
||||
$dpi = min(200, max(72, (int) (json_decode($job['params'] ?? '{}', true)['dpi'] ?? 150)));
|
||||
$tmpDir = sys_get_temp_dir() . '/tv_pdf_' . $job['id'];
|
||||
if (! is_dir($tmpDir)) { mkdir($tmpDir, 0700, true); }
|
||||
$update(10, 'rendering pages');
|
||||
|
||||
$proc = Process::binary('pdftoppm', [
|
||||
"-{$format}", '-r', (string) $dpi, $input, rtrim($tmpDir, '/') . '/page',
|
||||
], config('Site')->maxProcessingSeconds);
|
||||
if (! $proc->run()) {
|
||||
throw new \RuntimeException('Rendering failed — is this a valid PDF?');
|
||||
}
|
||||
|
||||
[$zipFile, $zipPath] = self::output($job, 'zip');
|
||||
$this->zipDirectory($zipPath, $tmpDir);
|
||||
$update(90, 'packaging');
|
||||
|
||||
return ['file' => basename($zipPath), 'name' => "pdf_as_{$format}.zip", 'ext' => 'zip'];
|
||||
}
|
||||
|
||||
private function toText(array $job, callable $update): array
|
||||
{
|
||||
$input = Ffmpeg::resolveInput($job);
|
||||
[, $txtPath] = self::output($job, 'txt');
|
||||
$update(30, 'extracting text');
|
||||
$proc = Process::binary('pdftotext', ['-layout', $input, $txtPath], 300);
|
||||
if (! $proc->run()) {
|
||||
throw new \RuntimeException('Text extraction failed. Scanned PDFs contain images, not text.');
|
||||
}
|
||||
|
||||
return ['file' => basename($txtPath), 'name' => 'document.txt', 'ext' => 'txt'];
|
||||
}
|
||||
|
||||
private function imagesToPdf(array $job, callable $update): array
|
||||
{
|
||||
$extra = json_decode($job['params'] ?? '{}', true)['extra_files'] ?? [];
|
||||
$files = [Ffmpeg::resolveInput($job)];
|
||||
foreach ((array) $extra as $f) {
|
||||
$files[] = Ffmpeg::storagePath((string) $f);
|
||||
}
|
||||
if (count(array_filter($files)) === 0) {
|
||||
throw new \RuntimeException('Add at least one image.');
|
||||
}
|
||||
|
||||
// normalize every image to an intermediate PDF page via ImageMagick
|
||||
$update(15, 'building pages');
|
||||
$pagePdfs = [];
|
||||
$i = 0;
|
||||
foreach ($files as $file) {
|
||||
if (! is_file($file)) {
|
||||
continue;
|
||||
}
|
||||
$pagePdf = sys_get_temp_dir() . "/tv_img2pdf_{$job['id']}_{$i}.pdf";
|
||||
$proc = Process::binary('convert', [
|
||||
$file, '-auto-orient', '-background', 'white', '-flatten', '-resize', '2480x3508>', $pagePdf,
|
||||
], 120);
|
||||
if ($proc->run()) {
|
||||
$pagePdfs[] = $pagePdf;
|
||||
}
|
||||
++$i;
|
||||
}
|
||||
if ($pagePdfs === []) {
|
||||
throw new \RuntimeException('The images could not be converted to PDF pages.');
|
||||
}
|
||||
|
||||
[, $out] = self::output($job, 'pdf');
|
||||
$proc = Process::binary('gs', [
|
||||
'-sDEVICE=pdfwrite', '-dCompatibilityLevel=1.5', '-dNOPAUSE', '-dQUIET', '-dBATCH',
|
||||
"-sOutputFile={$out}", ...$pagePdfs,
|
||||
], config('Site')->maxProcessingSeconds);
|
||||
array_map('unlink', $pagePdfs);
|
||||
if (! $proc->run()) {
|
||||
throw new \RuntimeException('PDF assembly failed.');
|
||||
}
|
||||
|
||||
return ['file' => basename($out), 'name' => 'images.pdf', 'ext' => 'pdf'];
|
||||
}
|
||||
|
||||
private static function output(array $job, string $ext): array
|
||||
{
|
||||
$file = \App\Libraries\Pipeline::safeName($job['id'], $ext);
|
||||
|
||||
return [$file, \App\Libraries\Pipeline::storageDir() . '/' . $file];
|
||||
}
|
||||
|
||||
private function zipDirectory(string $zipPath, string $dir): void
|
||||
{
|
||||
$zip = new \ZipArchive();
|
||||
$zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE);
|
||||
foreach (glob(rtrim($dir, '/') . '/*') ?: [] as $f) {
|
||||
if (is_file($f) && filesize($f) > 0) {
|
||||
$zip->addFile($f, basename($f));
|
||||
}
|
||||
}
|
||||
$zip->close();
|
||||
array_map('unlink', glob(rtrim($dir, '/') . '/*') ?: []);
|
||||
@rmdir($dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Libraries\Pipeline;
|
||||
|
||||
use chillerlan\QRCode\QRCode;
|
||||
use chillerlan\QRCode\QROptions;
|
||||
|
||||
/**
|
||||
* Renders QR codes server-side (PNG) via chillerlan/php-qrcode.
|
||||
* Text-kind tool: input is `content`, options: size, level, margin.
|
||||
*/
|
||||
final class Qr implements DriverInterface
|
||||
{
|
||||
public function handle(array $job, array $tool = [], ?callable $update = null): array
|
||||
{
|
||||
$update ??= static function (int $p, string $stage): void {
|
||||
};
|
||||
$params = json_decode($job['params'] ?? '{}', true) ?: [];
|
||||
|
||||
$content = trim((string) ($params['content'] ?? ''));
|
||||
if ($content === '') {
|
||||
throw new \RuntimeException('Enter the text or URL to encode.');
|
||||
}
|
||||
if (mb_strlen($content) > 1000) {
|
||||
throw new \RuntimeException('Content too long for a QR code (max 1000 characters).');
|
||||
}
|
||||
|
||||
// error-correction: L ~7%, M ~15%, Q ~25%, H ~30%
|
||||
$level = strtoupper((string) ($params['level'] ?? 'M'));
|
||||
if (! in_array($level, ['L', 'M', 'Q', 'H'], true)) {
|
||||
$level = 'M';
|
||||
}
|
||||
$size = (int) ($params['size'] ?? 300);
|
||||
$size = min(1000, max(120, $size));
|
||||
|
||||
$options = new QROptions([
|
||||
'outputType' => QRCode::OUTPUT_IMAGE_PNG,
|
||||
'eccLevel' => constant(QRCode::class . '::ECC_' . $level),
|
||||
'scale' => max(3, (int) round($size / 33)),
|
||||
'imageBase64' => false,
|
||||
'quality' => 90,
|
||||
]);
|
||||
|
||||
[, $out] = self::output($job, 'png');
|
||||
$png = (new QRCode($options))->render($content);
|
||||
if (! str_starts_with((string) $png, "\x89PNG")) {
|
||||
throw new \RuntimeException('QR rendering failed.');
|
||||
}
|
||||
file_put_contents($out, $png);
|
||||
|
||||
return ['file' => basename($out), 'name' => 'qr-code.png', 'ext' => 'png'];
|
||||
}
|
||||
|
||||
private static function output(array $job, string $ext): array
|
||||
{
|
||||
$dir = \App\Libraries\Pipeline::storageDir();
|
||||
$file = \App\Libraries\Pipeline::safeName($job['id'], $ext);
|
||||
|
||||
return [$file, $dir . '/' . $file];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Libraries\Pipeline;
|
||||
|
||||
use App\Libraries\Process;
|
||||
use App\Libraries\UrlGuard;
|
||||
|
||||
/**
|
||||
* YouTube driver.
|
||||
*
|
||||
* Always available (no external service): thumbnail download, oEmbed
|
||||
* metadata, URL analysis. Downloading video/audio requires yt-dlp AND
|
||||
* the operator feature flag — when disabled the driver throws a clear
|
||||
* user-facing message and tool pages show an honest notice up-front.
|
||||
*/
|
||||
final class Youtube implements DriverInterface
|
||||
{
|
||||
private const ID_PATTERN = '(?:[A-Za-z0-9_-]{11})';
|
||||
|
||||
/** @return string|null 11-char video id from any public URL form */
|
||||
public static function extractId(string $url): ?string
|
||||
{
|
||||
$url = trim($url);
|
||||
if (preg_match('/^' . self::ID_PATTERN . '$/', $url) === 1) {
|
||||
return $url; // bare id
|
||||
}
|
||||
|
||||
$host = strtolower(parse_url($url, PHP_URL_HOST) ?? '');
|
||||
if (! preg_match('/(^|\.)(youtube\.com|youtu\.be|youtube-nocookie\.com)$/', $host)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($host === 'youtu.be') {
|
||||
$id = trim(parse_url($url, PHP_URL_PATH) ?? '', '/');
|
||||
|
||||
return preg_match('/^' . self::ID_PATTERN . '$/', $id) === 1 ? $id : null;
|
||||
}
|
||||
|
||||
parse_str(parse_url($url, PHP_URL_QUERY) ?? '', $query);
|
||||
if (! empty($query['v']) && preg_match('/^' . self::ID_PATTERN . '$/', (string) $query['v'])) {
|
||||
return (string) $query['v'];
|
||||
}
|
||||
if (preg_match('#/(?:embed|shorts|live|v)/(' . self::ID_PATTERN . ')#', $url, $m)) {
|
||||
return $m[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function handle(array $job, array $tool, callable $update): array
|
||||
{
|
||||
$params = json_decode($job['params'] ?? '{}', true) ?: [];
|
||||
$videoId = self::extractId((string) ($params['url'] ?? ''));
|
||||
if ($videoId === null) {
|
||||
throw new \RuntimeException('Invalid YouTube URL.');
|
||||
}
|
||||
$update(10, 'fetching');
|
||||
|
||||
return match ($job['operation']) {
|
||||
'yt_thumbnails' => $this->thumbnails($job, $videoId),
|
||||
default => $this->withYtDlp($job, $params, $update),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Thumbnail pack: maxres/sd/hq/mq/default as a zip. Fetched only
|
||||
* from Google-owned image hosts through the allowlist.
|
||||
*/
|
||||
private function thumbnails(array $job, string $videoId): array
|
||||
{
|
||||
$guard = new UrlGuard();
|
||||
if (! $guard->isAllowedMediaHost('i.ytimg.com')) {
|
||||
throw new \RuntimeException('Thumbnails are not fetchable on this server.');
|
||||
}
|
||||
|
||||
$qualities = [
|
||||
'maxresdefault' => 'Max resolution (1280x720+)',
|
||||
'sddefault' => 'Standard definition (640x480)',
|
||||
'hqdefault' => 'High quality (480x360)',
|
||||
'mqdefault' => 'Medium quality (320x180)',
|
||||
'default' => 'Thumbnail (120x90)',
|
||||
];
|
||||
|
||||
$tmpDir = sys_get_temp_dir() . '/tv_thumb_' . $job['id'];
|
||||
if (! is_dir($tmpDir)) { mkdir($tmpDir, 0700, true); }
|
||||
$found = 0;
|
||||
|
||||
foreach ($qualities as $key => $_label) {
|
||||
$target = rtrim($tmpDir, '/') . "/{$key}.jpg";
|
||||
$data = $this->httpGet("https://i.ytimg.com/vi/{$videoId}/{$key}.jpg");
|
||||
if ($data !== null && strlen($data) > 1000) {
|
||||
file_put_contents($target, $data);
|
||||
++$found;
|
||||
}
|
||||
}
|
||||
|
||||
// webp variants too
|
||||
foreach (['maxresdefault', 'hq720', 'sddefault', 'hqdefault'] as $key) {
|
||||
$webpName = str_replace('.jpg', '.webp', $key) === $key ? $key . '.webp' : $key;
|
||||
$data = $this->httpGet("https://i.ytimg.com/vi_webp/{$videoId}/{$webpName}");
|
||||
if ($data !== null && strlen($data) > 1000) {
|
||||
file_put_contents(rtrim($tmpDir, '/') . '/' . basename($webpName), $data);
|
||||
++$found;
|
||||
}
|
||||
}
|
||||
|
||||
if ($found === 0) {
|
||||
throw new \RuntimeException('No thumbnails were found for this video.');
|
||||
}
|
||||
|
||||
[$zipFile, $zipPath] = [\App\Libraries\Pipeline::safeName($job['id'], 'zip'), \App\Libraries\Pipeline::storageDir() . '/' . \App\Libraries\Pipeline::safeName($job['id'], 'zip')];
|
||||
$zip = new \ZipArchive();
|
||||
$zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE);
|
||||
foreach (glob(rtrim($tmpDir, '/') . '/*') ?: [] as $f) {
|
||||
$zip->addFile($f, basename($f));
|
||||
}
|
||||
$zip->close();
|
||||
array_map('unlink', glob(rtrim($tmpDir, '/') . '/*') ?: []);
|
||||
@rmdir($tmpDir);
|
||||
|
||||
return ['file' => basename($zipPath), 'name' => "thumbnails_{$videoId}.zip", 'ext' => 'zip'];
|
||||
}
|
||||
|
||||
/** Gated yt-dlp execution (audio/video download + conversion). */
|
||||
private function withYtDlp(array $job, array $params, callable $update): array
|
||||
{
|
||||
$pipeline = service('pipeline');
|
||||
if (! $pipeline->youtubeEnabled()) {
|
||||
throw new \RuntimeException(
|
||||
'Downloading from YouTube is currently disabled on this server. Please review the platform terms of use before enabling this capability.'
|
||||
);
|
||||
}
|
||||
|
||||
$format = strtolower((string) ($params['format'] ?? 'mp3'));
|
||||
$url = 'https://www.youtube.com/watch?v=' . urlencode((string) self::extractId((string) ($params['url'] ?? '')));
|
||||
|
||||
$update(15, 'fetching');
|
||||
$outTemplate = rtrim(\App\Libraries\Pipeline::incomingDir(), '/') . '/' . $job['id'] . '.%(ext)s';
|
||||
|
||||
$args = ['-f', 'bestaudio/best', '-o', $outTemplate, '--no-playlist', '--no-warnings', '--socket-timeout', '30'];
|
||||
if (in_array($format, ['mp3', 'wav', 'm4a', 'aac', 'flac', 'ogg'], true)) {
|
||||
$codecMap = ['mp3' => 'mp3', 'wav' => 'wav', 'm4a' => 'aac', 'aac' => 'aac', 'flac' => 'flac', 'ogg' => 'vorbis'];
|
||||
$args[] = '-x';
|
||||
$args[] = '--audio-format';
|
||||
$args[] = $codecMap[$format];
|
||||
$ext = $format;
|
||||
} else {
|
||||
// best mp4-compatible stream
|
||||
$args[] = '-f';
|
||||
$args[] = 'bv*[ext=mp4]+ba[ext=m4a]/b[ext=mp4]/b';
|
||||
$args[] = '--merge-output-format';
|
||||
$args[] = 'mp4';
|
||||
$ext = 'mp4';
|
||||
}
|
||||
$args[] = $url;
|
||||
|
||||
$proc = Process::binary('yt-dlp', $args, config('Site')->maxProcessingSeconds);
|
||||
if (! $proc->run()) {
|
||||
throw new \RuntimeException('This video could not be downloaded. It may be private, region-locked or unavailable.');
|
||||
}
|
||||
$update(75, 'converting');
|
||||
|
||||
// find produced file
|
||||
$produced = glob(rtrim(\App\Libraries\Pipeline::incomingDir(), '/') . '/' . $job['id'] . '.*') ?: [];
|
||||
$produced = array_values(array_filter($produced, static fn ($f) => ! str_ends_with((string) $f, '.part')));
|
||||
if ($produced === []) {
|
||||
throw new \RuntimeException('The download did not produce a file.');
|
||||
}
|
||||
$source = $produced[0];
|
||||
|
||||
$outPath = \App\Libraries\Pipeline::storageDir() . '/' . \App\Libraries\Pipeline::safeName($job['id'], $ext);
|
||||
if (pathinfo($source, PATHINFO_EXTENSION) === $ext) {
|
||||
rename($source, $outPath);
|
||||
} else {
|
||||
$conv = Process::binary('ffmpeg', ['-y', '-i', $source, $outPath], config('Site')->maxProcessingSeconds);
|
||||
@unlink($source);
|
||||
if (! $conv->run()) {
|
||||
throw new \RuntimeException('Conversion failed after download.');
|
||||
}
|
||||
}
|
||||
|
||||
return ['file' => basename($outPath), 'name' => 'download.' . $ext, 'ext' => $ext];
|
||||
}
|
||||
|
||||
/**
|
||||
* oEmbed metadata lookup — no API key, official public endpoint,
|
||||
* SSRF-guarded host allowlist.
|
||||
*
|
||||
* @return array|null title/author/thumb or null
|
||||
*/
|
||||
public static function fetchInfo(string $videoId): ?array
|
||||
{
|
||||
$endpoint = 'https://www.youtube.com/oembed?url=' . rawurlencode('https://www.youtube.com/watch?v=' . $videoId) . '&format=json';
|
||||
|
||||
$ch = curl_init($endpoint);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 8,
|
||||
CURLOPT_CONNECTTIMEOUT => 4,
|
||||
CURLOPT_FOLLOWLOCATION => false,
|
||||
CURLOPT_USERAGENT => config('Site')->name . '/1.0',
|
||||
]);
|
||||
$body = curl_exec($ch);
|
||||
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($status !== 200 || ! is_string($body)) {
|
||||
return null;
|
||||
}
|
||||
$data = json_decode($body, true);
|
||||
if (! is_array($data) || empty($data['title'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $videoId,
|
||||
'title' => (string) $data['title'],
|
||||
'author' => (string) ($data['author_name'] ?? ''),
|
||||
'author_url' => (string) ($data['author_url'] ?? ''),
|
||||
'thumbnail' => (string) ($data['thumbnail_url'] ?? ('https://i.ytimg.com/vi/' . $videoId . '/hqdefault.jpg')),
|
||||
'duration' => null, // oEmbed doesn't expose duration
|
||||
'width' => (int) ($data['width'] ?? 0),
|
||||
'height' => (int) ($data['height'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
private function httpGet(string $url): ?string
|
||||
{
|
||||
try {
|
||||
(new UrlGuard())->check($url);
|
||||
} catch (\InvalidArgumentException) {
|
||||
return null;
|
||||
}
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
CURLOPT_FOLLOWLOCATION => false,
|
||||
CURLOPT_USERAGENT => config('Site')->name . '/1.0',
|
||||
]);
|
||||
$body = curl_exec($ch);
|
||||
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
return $status === 200 && is_string($body) ? $body : null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user