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,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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user