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