Add PHP Video Converter Suite with live stream pipelines and nuclear control panel UI

Full-featured video conversion platform with:
- FFmpeg-based pipeline system with composable stages (transcode, scale, filter, audio, bitrate, framerate, trim, deinterlace, denoise, stabilize)
- Live stream management with real-time format switching (RTMP/RTSP/HTTP)
- Industrial/nuclear power plant control room themed UI with gauges, switches, LED indicators
- Format switchboard for instant conversion between 16+ video/audio formats
- Pipeline designer with visual flow editor and drag-and-drop stage composition
- Job queue with priority scheduling and batch conversion
- WebSocket server for real-time progress broadcasting
- REST API for all operations (upload, convert, streams, pipelines, queue)
- System monitoring (CPU, memory, disk) with animated gauge displays
- Docker Compose setup with web, websocket, and worker services

https://claude.ai/code/session_01WxmHGnVFXGm2bwbFREHkHb
This commit is contained in:
Claude
2026-02-07 18:11:04 +00:00
parent 282d8b70fc
commit 6c56306873
27 changed files with 4746 additions and 0 deletions
@@ -0,0 +1,282 @@
<?php
namespace VideoConverter\Format;
use VideoConverter\Process\FFmpegProcess;
use VideoConverter\Process\MediaProbe;
use VideoConverter\Pipeline\Pipeline;
class FormatConverter
{
private array $jobs = [];
private string $stateFile;
private MediaProbe $probe;
public function __construct()
{
$this->stateFile = __DIR__ . '/../../storage/temp/jobs.json';
$this->probe = new MediaProbe();
$this->load();
}
private function load(): void
{
if (file_exists($this->stateFile)) {
$this->jobs = json_decode(file_get_contents($this->stateFile), true) ?: [];
}
}
private function save(): void
{
$dir = dirname($this->stateFile);
if (!is_dir($dir)) mkdir($dir, 0755, true);
file_put_contents($this->stateFile, json_encode($this->jobs, JSON_PRETTY_PRINT));
}
public function convert(array $params): array
{
$config = require __DIR__ . '/../../config/app.php';
$inputFile = $params['input_file'] ?? '';
$outputFormat = $params['output_format'] ?? 'mp4';
$preset = $params['preset'] ?? 'balanced';
$resolution = $params['resolution'] ?? null;
$customPipeline = $params['pipeline'] ?? null;
if (!file_exists($inputFile)) {
return ['error' => 'Input file not found'];
}
$id = bin2hex(random_bytes(8));
$formatConfig = $config['formats']['video'][$outputFormat]
?? $config['formats']['audio'][$outputFormat]
?? null;
if (!$formatConfig) {
return ['error' => "Unknown format: {$outputFormat}"];
}
$inputInfo = $this->probe->analyze($inputFile);
$baseName = pathinfo($inputFile, PATHINFO_FILENAME);
$outputFile = $config['storage']['outputs'] . "/{$baseName}_{$id}.{$formatConfig['ext']}";
// Build command
if ($customPipeline instanceof Pipeline) {
$cmd = $customPipeline->buildFFmpegCommand($inputFile, $outputFile);
} else {
$cmd = $this->buildCommand($inputFile, $outputFile, $outputFormat, $preset, $resolution, $params);
}
$process = new FFmpegProcess($cmd, $id);
if (isset($inputInfo['duration'])) {
$process->setDuration($inputInfo['duration']);
}
// Generate thumbnail
$thumbPath = $config['storage']['thumbnails'] . "/{$id}.jpg";
$this->probe->getThumbnail($inputFile, $thumbPath);
$job = [
'id' => $id,
'input_file' => $inputFile,
'input_info' => $inputInfo,
'output_file' => $outputFile,
'output_format' => $outputFormat,
'preset' => $preset,
'resolution' => $resolution,
'thumbnail' => file_exists($thumbPath) ? $thumbPath : null,
'status' => 'starting',
'pid' => null,
'command' => $cmd,
'created_at' => date('c'),
];
if ($process->start()) {
$job['status'] = 'running';
$job['pid'] = $process->getPid();
} else {
$job['status'] = 'error';
$job['error'] = 'Failed to start FFmpeg process';
}
$this->jobs[$id] = $job;
$this->save();
return $job;
}
public function batchConvert(string $inputFile, array $formats): array
{
$results = [];
foreach ($formats as $format => $settings) {
$params = array_merge(
['input_file' => $inputFile, 'output_format' => $format],
$settings
);
$results[$format] = $this->convert($params);
}
return $results;
}
public function getJob(string $id): ?array
{
$this->refreshJob($id);
return $this->jobs[$id] ?? null;
}
public function getAllJobs(): array
{
foreach (array_keys($this->jobs) as $id) {
$this->refreshJob($id);
}
return array_values($this->jobs);
}
public function cancelJob(string $id): bool
{
if (!isset($this->jobs[$id])) return false;
$job = $this->jobs[$id];
if ($job['pid'] && $job['status'] === 'running') {
posix_kill($job['pid'], SIGTERM);
$this->jobs[$id]['status'] = 'cancelled';
$this->save();
return true;
}
return false;
}
public function deleteJob(string $id): bool
{
if (isset($this->jobs[$id])) {
$this->cancelJob($id);
// Clean up output file
if (isset($this->jobs[$id]['output_file']) && file_exists($this->jobs[$id]['output_file'])) {
unlink($this->jobs[$id]['output_file']);
}
unset($this->jobs[$id]);
$this->save();
return true;
}
return false;
}
public function getProgress(string $id): array
{
if (!isset($this->jobs[$id])) {
return ['error' => 'Job not found'];
}
$config = require __DIR__ . '/../../config/app.php';
$progressFile = $config['storage']['logs'] . "/progress_{$id}.txt";
$progress = ['percent' => 0, 'fps' => 0, 'speed' => '0x', 'time' => '00:00:00'];
if (file_exists($progressFile)) {
$content = file_get_contents($progressFile);
foreach (explode("\n", $content) as $line) {
if (str_contains($line, '=')) {
[$key, $val] = explode('=', $line, 2);
$key = trim($key);
$val = trim($val);
if ($key === 'out_time') $progress['time'] = $val;
if ($key === 'fps') $progress['fps'] = (float)$val;
if ($key === 'speed') $progress['speed'] = $val;
if ($key === 'progress' && $val === 'end') $progress['percent'] = 100;
}
}
$duration = $this->jobs[$id]['input_info']['duration'] ?? 0;
if ($duration > 0 && $progress['percent'] < 100) {
$current = $this->timeToSeconds($progress['time']);
$progress['percent'] = min(99, round(($current / $duration) * 100, 1));
}
}
return $progress;
}
private function refreshJob(string $id): void
{
if (!isset($this->jobs[$id])) return;
$job = &$this->jobs[$id];
if ($job['status'] === 'running' && $job['pid']) {
if (!posix_kill($job['pid'], 0)) {
// Check if output file exists and has size
if (isset($job['output_file']) && file_exists($job['output_file']) && filesize($job['output_file']) > 0) {
$job['status'] = 'completed';
$job['completed_at'] = date('c');
$job['output_size'] = filesize($job['output_file']);
} else {
$job['status'] = 'error';
$job['error'] = 'Process ended without output';
}
$this->save();
}
}
}
private function buildCommand(string $input, string $output, string $format, string $preset, ?string $resolution, array $params): string
{
$config = require __DIR__ . '/../../config/app.php';
$ffmpeg = $config['ffmpeg']['binary'];
$formatConfig = $config['formats']['video'][$format] ?? $config['formats']['audio'][$format] ?? [];
$presetConfig = $config['presets'][$preset] ?? $config['presets']['balanced'];
$threads = $config['ffmpeg']['threads'];
$cmd = "{$ffmpeg} -y -i " . escapeshellarg($input);
$cmd .= " -threads {$threads}";
// Check if audio-only
$isAudio = isset($config['formats']['audio'][$format]);
if ($isAudio) {
$cmd .= " -vn";
$cmd .= " -c:a " . escapeshellarg($formatConfig['codec']);
if (isset($params['audio_bitrate'])) {
$cmd .= " -b:a " . escapeshellarg($params['audio_bitrate']);
}
} else {
$cmd .= " -c:v " . escapeshellarg($formatConfig['codec']);
$cmd .= " -preset " . escapeshellarg($presetConfig['preset']);
$cmd .= " -crf " . (int)$presetConfig['crf'];
if ($resolution && isset($config['resolutions'][$resolution])) {
$res = $config['resolutions'][$resolution];
$cmd .= " -vf scale={$res['width']}:{$res['height']}";
}
$cmd .= " -c:a aac -b:a 128k";
}
// HLS specific
if ($format === 'hls') {
$cmd .= " -hls_time 4 -hls_list_size 0 -hls_segment_filename "
. escapeshellarg(dirname($output) . "/segment_%03d.ts");
}
// DASH specific
if ($format === 'dash') {
$cmd .= " -use_timeline 1 -use_template 1 -adaptation_sets 'id=0,streams=v id=1,streams=a'";
}
// Extra params
if (isset($params['video_bitrate'])) {
$cmd .= " -b:v " . escapeshellarg($params['video_bitrate']);
}
if (isset($params['fps'])) {
$cmd .= " -r " . (int)$params['fps'];
}
$cmd .= " " . escapeshellarg($output);
return $cmd;
}
private function timeToSeconds(string $time): float
{
$parts = explode(':', $time);
if (count($parts) !== 3) return 0;
return (int)$parts[0] * 3600 + (int)$parts[1] * 60 + (float)$parts[2];
}
}
@@ -0,0 +1,127 @@
<?php
namespace VideoConverter\Pipeline;
class Pipeline
{
private string $id;
private string $name;
private array $stages = [];
private string $status = 'idle'; // idle, running, paused, error, completed
private ?string $inputSource = null;
private array $metadata = [];
private float $progress = 0;
private ?int $pid = null;
private string $createdAt;
public function __construct(string $name, ?string $id = null)
{
$this->id = $id ?? bin2hex(random_bytes(8));
$this->name = $name;
$this->createdAt = date('c');
}
public function getId(): string { return $this->id; }
public function getName(): string { return $this->name; }
public function getStatus(): string { return $this->status; }
public function getProgress(): float { return $this->progress; }
public function getPid(): ?int { return $this->pid; }
public function getStages(): array { return $this->stages; }
public function getInputSource(): ?string { return $this->inputSource; }
public function setStatus(string $status): void { $this->status = $status; }
public function setProgress(float $progress): void { $this->progress = min(100, max(0, $progress)); }
public function setPid(?int $pid): void { $this->pid = $pid; }
public function setInputSource(string $source): void { $this->inputSource = $source; }
public function addStage(PipelineStage $stage): self
{
$this->stages[] = $stage;
return $this;
}
public function removeStage(int $index): self
{
if (isset($this->stages[$index])) {
array_splice($this->stages, $index, 1);
}
return $this;
}
public function insertStage(int $index, PipelineStage $stage): self
{
array_splice($this->stages, $index, 0, [$stage]);
return $this;
}
public function setMetadata(string $key, mixed $value): void
{
$this->metadata[$key] = $value;
}
public function getMetadata(?string $key = null): mixed
{
if ($key === null) return $this->metadata;
return $this->metadata[$key] ?? null;
}
public function buildFFmpegCommand(string $inputPath, string $outputPath): string
{
$config = require __DIR__ . '/../../config/app.php';
$cmd = $config['ffmpeg']['binary'];
$parts = ["-y -i " . escapeshellarg($inputPath)];
foreach ($this->stages as $stage) {
$parts[] = $stage->toFFmpegArgs();
}
$parts[] = escapeshellarg($outputPath);
return $cmd . ' ' . implode(' ', $parts);
}
public function buildStreamCommand(string $inputUrl, string $outputUrl): string
{
$config = require __DIR__ . '/../../config/app.php';
$cmd = $config['ffmpeg']['binary'];
$parts = ["-re -i " . escapeshellarg($inputUrl)];
foreach ($this->stages as $stage) {
$parts[] = $stage->toFFmpegArgs();
}
$parts[] = "-f flv " . escapeshellarg($outputUrl);
return $cmd . ' ' . implode(' ', $parts);
}
public function toArray(): array
{
return [
'id' => $this->id,
'name' => $this->name,
'status' => $this->status,
'progress' => $this->progress,
'pid' => $this->pid,
'input_source' => $this->inputSource,
'stages' => array_map(fn(PipelineStage $s) => $s->toArray(), $this->stages),
'metadata' => $this->metadata,
'created_at' => $this->createdAt,
];
}
public static function fromArray(array $data): self
{
$pipeline = new self($data['name'], $data['id']);
$pipeline->status = $data['status'] ?? 'idle';
$pipeline->progress = $data['progress'] ?? 0;
$pipeline->pid = $data['pid'] ?? null;
$pipeline->inputSource = $data['input_source'] ?? null;
$pipeline->metadata = $data['metadata'] ?? [];
$pipeline->createdAt = $data['created_at'] ?? date('c');
foreach (($data['stages'] ?? []) as $stageData) {
$pipeline->addStage(PipelineStage::fromArray($stageData));
}
return $pipeline;
}
}
@@ -0,0 +1,89 @@
<?php
namespace VideoConverter\Pipeline;
class PipelineManager
{
private string $stateFile;
private array $pipelines = [];
public function __construct()
{
$this->stateFile = __DIR__ . '/../../storage/temp/pipelines.json';
$this->load();
}
private function load(): void
{
if (file_exists($this->stateFile)) {
$data = json_decode(file_get_contents($this->stateFile), true);
foreach (($data['pipelines'] ?? []) as $pData) {
$this->pipelines[$pData['id']] = Pipeline::fromArray($pData);
}
}
}
public function save(): void
{
$dir = dirname($this->stateFile);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
$data = ['pipelines' => []];
foreach ($this->pipelines as $pipeline) {
$data['pipelines'][] = $pipeline->toArray();
}
file_put_contents($this->stateFile, json_encode($data, JSON_PRETTY_PRINT));
}
public function create(string $name): Pipeline
{
$pipeline = new Pipeline($name);
$this->pipelines[$pipeline->getId()] = $pipeline;
$this->save();
return $pipeline;
}
public function get(string $id): ?Pipeline
{
return $this->pipelines[$id] ?? null;
}
public function getAll(): array
{
return $this->pipelines;
}
public function delete(string $id): bool
{
if (isset($this->pipelines[$id])) {
$pipeline = $this->pipelines[$id];
if ($pipeline->getStatus() === 'running' && $pipeline->getPid()) {
posix_kill($pipeline->getPid(), SIGTERM);
}
unset($this->pipelines[$id]);
$this->save();
return true;
}
return false;
}
public function getRunningCount(): int
{
$count = 0;
foreach ($this->pipelines as $p) {
if ($p->getStatus() === 'running') $count++;
}
return $count;
}
public function getByStatus(string $status): array
{
return array_filter($this->pipelines, fn(Pipeline $p) => $p->getStatus() === $status);
}
public function toArray(): array
{
return array_map(fn(Pipeline $p) => $p->toArray(), array_values($this->pipelines));
}
}
@@ -0,0 +1,197 @@
<?php
namespace VideoConverter\Pipeline;
class PipelineStage
{
private string $id;
private string $type; // transcode, scale, filter, audio, watermark, trim, split
private array $params;
private bool $enabled;
private string $label;
public function __construct(string $type, array $params = [], string $label = '', bool $enabled = true)
{
$this->id = bin2hex(random_bytes(4));
$this->type = $type;
$this->params = $params;
$this->label = $label ?: ucfirst($type);
$this->enabled = $enabled;
}
public function getId(): string { return $this->id; }
public function getType(): string { return $this->type; }
public function getParams(): array { return $this->params; }
public function isEnabled(): bool { return $this->enabled; }
public function getLabel(): string { return $this->label; }
public function setEnabled(bool $enabled): void { $this->enabled = $enabled; }
public function setParams(array $params): void { $this->params = $params; }
public function toFFmpegArgs(): string
{
if (!$this->enabled) return '';
return match ($this->type) {
'transcode' => $this->buildTranscodeArgs(),
'scale' => $this->buildScaleArgs(),
'filter' => $this->buildFilterArgs(),
'audio' => $this->buildAudioArgs(),
'watermark' => $this->buildWatermarkArgs(),
'trim' => $this->buildTrimArgs(),
'bitrate' => $this->buildBitrateArgs(),
'framerate' => $this->buildFramerateArgs(),
'deinterlace' => '-vf yadif',
'denoise' => '-vf hqdn3d',
'stabilize' => '-vf deshake',
default => '',
};
}
private function buildTranscodeArgs(): string
{
$args = [];
if (isset($this->params['video_codec'])) {
$args[] = "-c:v " . escapeshellarg($this->params['video_codec']);
}
if (isset($this->params['audio_codec'])) {
$args[] = "-c:a " . escapeshellarg($this->params['audio_codec']);
}
if (isset($this->params['preset'])) {
$args[] = "-preset " . escapeshellarg($this->params['preset']);
}
if (isset($this->params['crf'])) {
$args[] = "-crf " . (int)$this->params['crf'];
}
return implode(' ', $args);
}
private function buildScaleArgs(): string
{
$w = (int)($this->params['width'] ?? -1);
$h = (int)($this->params['height'] ?? -1);
$algo = $this->params['algorithm'] ?? 'lanczos';
return "-vf scale={$w}:{$h}:flags={$algo}";
}
private function buildFilterArgs(): string
{
$filters = [];
if (isset($this->params['brightness'])) {
$filters[] = "eq=brightness=" . (float)$this->params['brightness'];
}
if (isset($this->params['contrast'])) {
$filters[] = "eq=contrast=" . (float)$this->params['contrast'];
}
if (isset($this->params['saturation'])) {
$filters[] = "eq=saturation=" . (float)$this->params['saturation'];
}
if (isset($this->params['gamma'])) {
$filters[] = "eq=gamma=" . (float)$this->params['gamma'];
}
if (isset($this->params['custom'])) {
$filters[] = $this->params['custom'];
}
return $filters ? '-vf ' . escapeshellarg(implode(',', $filters)) : '';
}
private function buildAudioArgs(): string
{
$args = [];
if (isset($this->params['codec'])) {
$args[] = "-c:a " . escapeshellarg($this->params['codec']);
}
if (isset($this->params['bitrate'])) {
$args[] = "-b:a " . escapeshellarg($this->params['bitrate']);
}
if (isset($this->params['sample_rate'])) {
$args[] = "-ar " . (int)$this->params['sample_rate'];
}
if (isset($this->params['channels'])) {
$args[] = "-ac " . (int)$this->params['channels'];
}
if (isset($this->params['volume'])) {
$args[] = "-af volume=" . (float)$this->params['volume'];
}
return implode(' ', $args);
}
private function buildWatermarkArgs(): string
{
$image = $this->params['image'] ?? '';
$position = $this->params['position'] ?? 'topright';
$overlay = match ($position) {
'topleft' => 'overlay=10:10',
'topright' => 'overlay=W-w-10:10',
'bottomleft' => 'overlay=10:H-h-10',
'bottomright' => 'overlay=W-w-10:H-h-10',
'center' => 'overlay=(W-w)/2:(H-h)/2',
default => 'overlay=W-w-10:10',
};
return "-i " . escapeshellarg($image) . " -filter_complex \"{$overlay}\"";
}
private function buildTrimArgs(): string
{
$args = [];
if (isset($this->params['start'])) {
$args[] = "-ss " . escapeshellarg($this->params['start']);
}
if (isset($this->params['duration'])) {
$args[] = "-t " . escapeshellarg($this->params['duration']);
}
if (isset($this->params['end'])) {
$args[] = "-to " . escapeshellarg($this->params['end']);
}
return implode(' ', $args);
}
private function buildBitrateArgs(): string
{
$args = [];
if (isset($this->params['video'])) {
$args[] = "-b:v " . escapeshellarg($this->params['video']);
}
if (isset($this->params['audio'])) {
$args[] = "-b:a " . escapeshellarg($this->params['audio']);
}
if (isset($this->params['maxrate'])) {
$args[] = "-maxrate " . escapeshellarg($this->params['maxrate']);
$args[] = "-bufsize " . escapeshellarg($this->params['bufsize'] ?? $this->params['maxrate']);
}
return implode(' ', $args);
}
private function buildFramerateArgs(): string
{
$fps = (float)($this->params['fps'] ?? 30);
return "-r {$fps}";
}
public function toArray(): array
{
return [
'id' => $this->id,
'type' => $this->type,
'label' => $this->label,
'params' => $this->params,
'enabled' => $this->enabled,
];
}
public static function fromArray(array $data): self
{
$stage = new self(
$data['type'],
$data['params'] ?? [],
$data['label'] ?? '',
$data['enabled'] ?? true
);
if (isset($data['id'])) {
// Use reflection to set the id for deserialization
$ref = new \ReflectionProperty($stage, 'id');
$ref->setValue($stage, $data['id']);
}
return $stage;
}
}
@@ -0,0 +1,159 @@
<?php
namespace VideoConverter\Process;
class FFmpegProcess
{
private string $command;
private ?int $pid = null;
private string $logFile;
private string $progressFile;
private float $duration = 0;
private string $status = 'pending';
private array $outputLines = [];
public function __construct(string $command, string $jobId)
{
$config = require __DIR__ . '/../../config/app.php';
$this->command = $command;
$logDir = $config['storage']['logs'];
if (!is_dir($logDir)) mkdir($logDir, 0755, true);
$this->logFile = $logDir . "/ffmpeg_{$jobId}.log";
$this->progressFile = $logDir . "/progress_{$jobId}.txt";
}
public function start(): bool
{
$cmd = $this->command
. " -progress " . escapeshellarg($this->progressFile)
. " -stats_period 0.5"
. " 2>" . escapeshellarg($this->logFile)
. " & echo $!";
$output = [];
exec($cmd, $output);
$this->pid = (int)($output[0] ?? 0);
if ($this->pid > 0) {
$this->status = 'running';
return true;
}
$this->status = 'error';
return false;
}
public function stop(): void
{
if ($this->pid && $this->isRunning()) {
posix_kill($this->pid, SIGTERM);
usleep(500000);
if ($this->isRunning()) {
posix_kill($this->pid, SIGKILL);
}
}
$this->status = 'stopped';
}
public function pause(): void
{
if ($this->pid && $this->isRunning()) {
posix_kill($this->pid, SIGSTOP);
$this->status = 'paused';
}
}
public function resume(): void
{
if ($this->pid) {
posix_kill($this->pid, SIGCONT);
$this->status = 'running';
}
}
public function isRunning(): bool
{
if (!$this->pid) return false;
return posix_kill($this->pid, 0);
}
public function getProgress(): array
{
$progress = [
'percent' => 0,
'frame' => 0,
'fps' => 0,
'speed' => '0x',
'time' => '00:00:00.00',
'bitrate' => '0kbits/s',
'size' => '0kB',
];
if (!file_exists($this->progressFile)) return $progress;
$content = file_get_contents($this->progressFile);
$lines = explode("\n", $content);
foreach ($lines as $line) {
$line = trim($line);
if (str_contains($line, '=')) {
[$key, $value] = explode('=', $line, 2);
$key = trim($key);
$value = trim($value);
switch ($key) {
case 'frame': $progress['frame'] = (int)$value; break;
case 'fps': $progress['fps'] = (float)$value; break;
case 'speed': $progress['speed'] = $value; break;
case 'out_time': $progress['time'] = $value; break;
case 'total_size': $progress['size'] = $this->formatBytes((int)$value); break;
case 'bitrate': $progress['bitrate'] = $value; break;
case 'progress':
if ($value === 'end') $progress['percent'] = 100;
break;
}
}
}
if ($this->duration > 0 && $progress['percent'] < 100) {
$currentTime = $this->timeToSeconds($progress['time']);
$progress['percent'] = min(99, round(($currentTime / $this->duration) * 100, 1));
}
return $progress;
}
public function getLog(int $lines = 50): string
{
if (!file_exists($this->logFile)) return '';
$all = file($this->logFile);
return implode('', array_slice($all, -$lines));
}
public function setDuration(float $duration): void
{
$this->duration = $duration;
}
public function getPid(): ?int { return $this->pid; }
public function getStatus(): string { return $this->status; }
public function getCommand(): string { return $this->command; }
private function timeToSeconds(string $time): float
{
$parts = explode(':', $time);
if (count($parts) !== 3) return 0;
return (int)$parts[0] * 3600 + (int)$parts[1] * 60 + (float)$parts[2];
}
private function formatBytes(int $bytes): string
{
$units = ['B', 'KB', 'MB', 'GB'];
$i = 0;
$size = (float)$bytes;
while ($size >= 1024 && $i < count($units) - 1) {
$size /= 1024;
$i++;
}
return round($size, 1) . $units[$i];
}
}
@@ -0,0 +1,106 @@
<?php
namespace VideoConverter\Process;
class MediaProbe
{
private string $ffprobe;
public function __construct()
{
$config = require __DIR__ . '/../../config/app.php';
$this->ffprobe = $config['ffmpeg']['ffprobe'];
}
public function analyze(string $filePath): array
{
$cmd = sprintf(
'%s -v quiet -print_format json -show_format -show_streams %s',
$this->ffprobe,
escapeshellarg($filePath)
);
$output = shell_exec($cmd);
$data = json_decode($output ?: '{}', true);
if (!$data) {
return ['error' => 'Could not analyze file'];
}
return $this->parseProbeData($data);
}
public function getDuration(string $filePath): float
{
$info = $this->analyze($filePath);
return (float)($info['duration'] ?? 0);
}
public function getThumbnail(string $filePath, string $outputPath, string $time = '00:00:01'): bool
{
$config = require __DIR__ . '/../../config/app.php';
$cmd = sprintf(
'%s -y -i %s -ss %s -vframes 1 -vf scale=320:-1 %s 2>/dev/null',
$config['ffmpeg']['binary'],
escapeshellarg($filePath),
escapeshellarg($time),
escapeshellarg($outputPath)
);
exec($cmd, $output, $exitCode);
return $exitCode === 0;
}
private function parseProbeData(array $data): array
{
$result = [
'format' => $data['format']['format_long_name'] ?? 'Unknown',
'format_name' => $data['format']['format_name'] ?? '',
'duration' => (float)($data['format']['duration'] ?? 0),
'size' => (int)($data['format']['size'] ?? 0),
'bitrate' => (int)($data['format']['bit_rate'] ?? 0),
'streams' => [],
'video' => null,
'audio' => null,
];
foreach (($data['streams'] ?? []) as $stream) {
$type = $stream['codec_type'] ?? '';
$info = [
'index' => $stream['index'],
'type' => $type,
'codec' => $stream['codec_name'] ?? 'unknown',
'codec_long' => $stream['codec_long_name'] ?? '',
];
if ($type === 'video') {
$info['width'] = (int)($stream['width'] ?? 0);
$info['height'] = (int)($stream['height'] ?? 0);
$info['fps'] = $this->parseFps($stream['r_frame_rate'] ?? '0/1');
$info['pix_fmt'] = $stream['pix_fmt'] ?? '';
$info['bitrate'] = (int)($stream['bit_rate'] ?? 0);
$info['profile'] = $stream['profile'] ?? '';
$info['level'] = $stream['level'] ?? '';
if (!$result['video']) $result['video'] = $info;
} elseif ($type === 'audio') {
$info['sample_rate'] = (int)($stream['sample_rate'] ?? 0);
$info['channels'] = (int)($stream['channels'] ?? 0);
$info['channel_layout'] = $stream['channel_layout'] ?? '';
$info['bitrate'] = (int)($stream['bit_rate'] ?? 0);
if (!$result['audio']) $result['audio'] = $info;
}
$result['streams'][] = $info;
}
return $result;
}
private function parseFps(string $frac): float
{
$parts = explode('/', $frac);
if (count($parts) === 2 && (int)$parts[1] > 0) {
return round((int)$parts[0] / (int)$parts[1], 2);
}
return (float)$frac;
}
}
@@ -0,0 +1,115 @@
<?php
namespace VideoConverter\Queue;
class JobQueue
{
private string $queueFile;
private array $queue = [];
public function __construct()
{
$this->queueFile = __DIR__ . '/../../storage/temp/queue.json';
$this->load();
}
private function load(): void
{
if (file_exists($this->queueFile)) {
$this->queue = json_decode(file_get_contents($this->queueFile), true) ?: [];
}
}
private function save(): void
{
$dir = dirname($this->queueFile);
if (!is_dir($dir)) mkdir($dir, 0755, true);
file_put_contents($this->queueFile, json_encode($this->queue, JSON_PRETTY_PRINT));
}
public function enqueue(array $job): string
{
$id = bin2hex(random_bytes(8));
$job['queue_id'] = $id;
$job['queued_at'] = date('c');
$job['queue_status'] = 'waiting';
$job['priority'] = $job['priority'] ?? 5;
$this->queue[] = $job;
// Sort by priority (lower = higher priority)
usort($this->queue, fn($a, $b) => ($a['priority'] ?? 5) <=> ($b['priority'] ?? 5));
$this->save();
return $id;
}
public function dequeue(): ?array
{
foreach ($this->queue as &$job) {
if ($job['queue_status'] === 'waiting') {
$job['queue_status'] = 'processing';
$job['started_at'] = date('c');
$this->save();
return $job;
}
}
return null;
}
public function complete(string $queueId, array $result = []): void
{
foreach ($this->queue as &$job) {
if ($job['queue_id'] === $queueId) {
$job['queue_status'] = 'completed';
$job['completed_at'] = date('c');
$job['result'] = $result;
break;
}
}
$this->save();
}
public function fail(string $queueId, string $error): void
{
foreach ($this->queue as &$job) {
if ($job['queue_id'] === $queueId) {
$job['queue_status'] = 'failed';
$job['failed_at'] = date('c');
$job['error'] = $error;
break;
}
}
$this->save();
}
public function getQueue(): array { return $this->queue; }
public function getWaiting(): array
{
return array_values(array_filter($this->queue, fn($j) => $j['queue_status'] === 'waiting'));
}
public function getProcessing(): array
{
return array_values(array_filter($this->queue, fn($j) => $j['queue_status'] === 'processing'));
}
public function clear(string $status = 'completed'): int
{
$before = count($this->queue);
$this->queue = array_values(array_filter($this->queue, fn($j) => $j['queue_status'] !== $status));
$this->save();
return $before - count($this->queue);
}
public function getStats(): array
{
$stats = ['waiting' => 0, 'processing' => 0, 'completed' => 0, 'failed' => 0];
foreach ($this->queue as $job) {
$status = $job['queue_status'] ?? 'waiting';
$stats[$status] = ($stats[$status] ?? 0) + 1;
}
return $stats;
}
}
@@ -0,0 +1,197 @@
<?php
namespace VideoConverter\Stream;
use VideoConverter\Process\FFmpegProcess;
class StreamManager
{
private array $activeStreams = [];
private string $stateFile;
public function __construct()
{
$this->stateFile = __DIR__ . '/../../storage/temp/streams.json';
$this->load();
}
private function load(): void
{
if (file_exists($this->stateFile)) {
$this->activeStreams = json_decode(file_get_contents($this->stateFile), true) ?: [];
}
}
private function save(): void
{
$dir = dirname($this->stateFile);
if (!is_dir($dir)) mkdir($dir, 0755, true);
file_put_contents($this->stateFile, json_encode($this->activeStreams, JSON_PRETTY_PRINT));
}
public function startStream(array $params): array
{
$id = bin2hex(random_bytes(8));
$config = require __DIR__ . '/../../config/app.php';
$inputUrl = $params['input_url'] ?? '';
$outputFormat = $params['output_format'] ?? 'mp4';
$resolution = $params['resolution'] ?? null;
$preset = $params['preset'] ?? 'fast';
$formatConfig = $config['formats']['video'][$outputFormat] ?? $config['formats']['video']['mp4'];
$presetConfig = $config['presets'][$preset] ?? $config['presets']['fast'];
$outputDir = $config['storage']['outputs'];
$outputFile = "{$outputDir}/stream_{$id}.{$formatConfig['ext']}";
$cmd = $config['ffmpeg']['binary'] . " -y";
// Input
if (str_starts_with($inputUrl, 'rtmp://') || str_starts_with($inputUrl, 'rtsp://')) {
$cmd .= " -re";
}
$cmd .= " -i " . escapeshellarg($inputUrl);
// Video codec
$cmd .= " -c:v " . escapeshellarg($formatConfig['codec']);
$cmd .= " -preset " . escapeshellarg($presetConfig['preset']);
$cmd .= " -crf " . (int)$presetConfig['crf'];
// Resolution
if ($resolution && isset($config['resolutions'][$resolution])) {
$res = $config['resolutions'][$resolution];
$cmd .= " -vf scale={$res['width']}:{$res['height']}";
}
// Audio
$audioCodec = $params['audio_codec'] ?? 'aac';
$audioBitrate = $params['audio_bitrate'] ?? '128k';
$cmd .= " -c:a " . escapeshellarg($audioCodec);
$cmd .= " -b:a " . escapeshellarg($audioBitrate);
$cmd .= " " . escapeshellarg($outputFile);
$process = new FFmpegProcess($cmd, $id);
$stream = [
'id' => $id,
'input_url' => $inputUrl,
'output_file' => $outputFile,
'output_format' => $outputFormat,
'resolution' => $resolution,
'preset' => $preset,
'status' => 'starting',
'pid' => null,
'command' => $cmd,
'started_at' => date('c'),
];
if ($process->start()) {
$stream['status'] = 'running';
$stream['pid'] = $process->getPid();
} else {
$stream['status'] = 'error';
}
$this->activeStreams[$id] = $stream;
$this->save();
return $stream;
}
public function stopStream(string $id): bool
{
if (!isset($this->activeStreams[$id])) return false;
$stream = $this->activeStreams[$id];
if ($stream['pid']) {
posix_kill($stream['pid'], SIGTERM);
usleep(500000);
if (posix_kill($stream['pid'], 0)) {
posix_kill($stream['pid'], SIGKILL);
}
}
$this->activeStreams[$id]['status'] = 'stopped';
$this->activeStreams[$id]['stopped_at'] = date('c');
$this->save();
return true;
}
public function switchFormat(string $id, string $newFormat, ?string $resolution = null): array
{
if (!isset($this->activeStreams[$id])) {
return ['error' => 'Stream not found'];
}
$oldStream = $this->activeStreams[$id];
$this->stopStream($id);
// Start new stream with same input but different output format
return $this->startStream([
'input_url' => $oldStream['input_url'],
'output_format' => $newFormat,
'resolution' => $resolution ?? $oldStream['resolution'],
'preset' => $oldStream['preset'],
'audio_codec' => 'aac',
]);
}
public function getStream(string $id): ?array
{
$this->refreshStatus($id);
return $this->activeStreams[$id] ?? null;
}
public function getAllStreams(): array
{
foreach (array_keys($this->activeStreams) as $id) {
$this->refreshStatus($id);
}
return array_values($this->activeStreams);
}
public function getActiveStreams(): array
{
return array_values(array_filter($this->getAllStreams(), fn($s) => $s['status'] === 'running'));
}
private function refreshStatus(string $id): void
{
if (!isset($this->activeStreams[$id])) return;
$stream = &$this->activeStreams[$id];
if ($stream['status'] === 'running' && $stream['pid']) {
if (!posix_kill($stream['pid'], 0)) {
$stream['status'] = 'completed';
$stream['completed_at'] = date('c');
$this->save();
}
}
}
public function deleteStream(string $id): bool
{
if (isset($this->activeStreams[$id])) {
if ($this->activeStreams[$id]['status'] === 'running') {
$this->stopStream($id);
}
unset($this->activeStreams[$id]);
$this->save();
return true;
}
return false;
}
public function getStats(): array
{
$all = $this->getAllStreams();
return [
'total' => count($all),
'running' => count(array_filter($all, fn($s) => $s['status'] === 'running')),
'completed' => count(array_filter($all, fn($s) => $s['status'] === 'completed')),
'errors' => count(array_filter($all, fn($s) => $s['status'] === 'error')),
];
}
}
@@ -0,0 +1,182 @@
<?php
namespace VideoConverter\WebSocket;
use Ratchet\MessageComponentInterface;
use Ratchet\ConnectionInterface;
use VideoConverter\Format\FormatConverter;
use VideoConverter\Stream\StreamManager;
use VideoConverter\Pipeline\PipelineManager;
use VideoConverter\Queue\JobQueue;
class StatusServer implements MessageComponentInterface
{
protected \SplObjectStorage $clients;
private FormatConverter $converter;
private StreamManager $streamManager;
private PipelineManager $pipelineManager;
private JobQueue $queue;
public function __construct()
{
$this->clients = new \SplObjectStorage();
$this->converter = new FormatConverter();
$this->streamManager = new StreamManager();
$this->pipelineManager = new PipelineManager();
$this->queue = new JobQueue();
}
public function onOpen(ConnectionInterface $conn): void
{
$this->clients->attach($conn);
$conn->send(json_encode([
'type' => 'connected',
'message' => 'Connected to Video Converter Suite',
'client_id' => spl_object_id($conn),
]));
}
public function onMessage(ConnectionInterface $from, $msg): void
{
$data = json_decode($msg, true);
if (!$data || !isset($data['action'])) return;
$response = match ($data['action']) {
'get_status' => $this->getFullStatus(),
'get_jobs' => ['type' => 'jobs', 'data' => $this->converter->getAllJobs()],
'get_streams' => ['type' => 'streams', 'data' => $this->streamManager->getAllStreams()],
'get_pipelines' => ['type' => 'pipelines', 'data' => $this->pipelineManager->toArray()],
'get_queue' => ['type' => 'queue', 'data' => $this->queue->getQueue()],
'get_progress' => $this->getJobProgress($data['job_id'] ?? ''),
'start_stream' => $this->handleStartStream($data),
'stop_stream' => $this->handleStopStream($data['stream_id'] ?? ''),
'switch_format' => $this->handleSwitchFormat($data),
default => ['type' => 'error', 'message' => 'Unknown action'],
};
$from->send(json_encode($response));
}
public function onClose(ConnectionInterface $conn): void
{
$this->clients->detach($conn);
}
public function onError(ConnectionInterface $conn, \Exception $e): void
{
$conn->send(json_encode([
'type' => 'error',
'message' => $e->getMessage(),
]));
$conn->close();
}
public function broadcastStatus(): void
{
$status = $this->getFullStatus();
$json = json_encode($status);
foreach ($this->clients as $client) {
$client->send($json);
}
}
private function getFullStatus(): array
{
// Reload state
$this->converter = new FormatConverter();
$this->streamManager = new StreamManager();
$this->pipelineManager = new PipelineManager();
$this->queue = new JobQueue();
$jobs = $this->converter->getAllJobs();
$runningJobs = array_filter($jobs, fn($j) => $j['status'] === 'running');
$progressData = [];
foreach ($runningJobs as $job) {
$progressData[$job['id']] = $this->converter->getProgress($job['id']);
}
return [
'type' => 'status',
'timestamp' => date('c'),
'system' => $this->getSystemStats(),
'jobs' => $jobs,
'progress' => $progressData,
'streams' => $this->streamManager->getAllStreams(),
'pipelines' => $this->pipelineManager->toArray(),
'queue' => $this->queue->getStats(),
];
}
private function getJobProgress(string $jobId): array
{
$this->converter = new FormatConverter();
return [
'type' => 'progress',
'job_id' => $jobId,
'data' => $this->converter->getProgress($jobId),
];
}
private function handleStartStream(array $data): array
{
$this->streamManager = new StreamManager();
$result = $this->streamManager->startStream($data);
return ['type' => 'stream_started', 'data' => $result];
}
private function handleStopStream(string $streamId): array
{
$this->streamManager = new StreamManager();
$success = $this->streamManager->stopStream($streamId);
return ['type' => 'stream_stopped', 'success' => $success, 'stream_id' => $streamId];
}
private function handleSwitchFormat(array $data): array
{
$this->streamManager = new StreamManager();
$result = $this->streamManager->switchFormat(
$data['stream_id'] ?? '',
$data['format'] ?? 'mp4',
$data['resolution'] ?? null
);
return ['type' => 'format_switched', 'data' => $result];
}
private function getSystemStats(): array
{
$load = sys_getloadavg();
$memInfo = $this->getMemoryInfo();
return [
'cpu_load' => $load[0] ?? 0,
'memory_used' => $memInfo['used'] ?? 0,
'memory_total' => $memInfo['total'] ?? 0,
'memory_percent' => $memInfo['percent'] ?? 0,
'disk_free' => disk_free_space('/'),
'disk_total' => disk_total_space('/'),
'uptime' => (int)(file_exists('/proc/uptime')
? (float)explode(' ', file_get_contents('/proc/uptime'))[0]
: 0),
];
}
private function getMemoryInfo(): array
{
if (!file_exists('/proc/meminfo')) {
return ['total' => 0, 'used' => 0, 'percent' => 0];
}
$content = file_get_contents('/proc/meminfo');
preg_match('/MemTotal:\s+(\d+)/', $content, $total);
preg_match('/MemAvailable:\s+(\d+)/', $content, $available);
$t = (int)($total[1] ?? 0) * 1024;
$a = (int)($available[1] ?? 0) * 1024;
$u = $t - $a;
return [
'total' => $t,
'used' => $u,
'percent' => $t > 0 ? round(($u / $t) * 100, 1) : 0,
];
}
}