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