Files
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

574 lines
24 KiB
PHP

<?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;
}
}