diff --git a/video-converter-suite/.gitignore b/video-converter-suite/.gitignore
new file mode 100644
index 0000000..aee17c0
--- /dev/null
+++ b/video-converter-suite/.gitignore
@@ -0,0 +1,15 @@
+/vendor/
+/storage/uploads/*
+/storage/outputs/*
+/storage/thumbnails/*
+/storage/logs/*
+/storage/temp/*
+!storage/uploads/.gitkeep
+!storage/outputs/.gitkeep
+!storage/thumbnails/.gitkeep
+!storage/logs/.gitkeep
+!storage/temp/.gitkeep
+.env
+*.swp
+*.swo
+.DS_Store
diff --git a/video-converter-suite/Dockerfile b/video-converter-suite/Dockerfile
new file mode 100644
index 0000000..9e793ac
--- /dev/null
+++ b/video-converter-suite/Dockerfile
@@ -0,0 +1,40 @@
+FROM php:8.2-cli
+
+# Install FFmpeg and dependencies
+RUN apt-get update && apt-get install -y \
+ ffmpeg \
+ libzip-dev \
+ unzip \
+ git \
+ && docker-php-ext-install pcntl posix sockets \
+ && rm -rf /var/lib/apt/lists/*
+
+# Install Composer
+COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
+
+WORKDIR /app
+
+# Copy composer files first for caching
+COPY composer.json ./
+RUN composer install --no-dev --optimize-autoloader 2>/dev/null || true
+
+# Copy application
+COPY . .
+
+# Install dependencies
+RUN composer install --no-dev --optimize-autoloader
+
+# Create storage directories
+RUN mkdir -p storage/uploads storage/outputs storage/thumbnails storage/logs storage/temp \
+ && chmod -R 777 storage
+
+# Configure PHP
+RUN echo "upload_max_filesize = 5G\n\
+post_max_size = 5G\n\
+memory_limit = 512M\n\
+max_execution_time = 3600\n\
+max_input_time = 3600" > /usr/local/etc/php/conf.d/video-converter.ini
+
+EXPOSE 8080 8081
+
+CMD ["php", "-S", "0.0.0.0:8080", "-t", "public", "public/router.php"]
diff --git a/video-converter-suite/bin/queue-worker.php b/video-converter-suite/bin/queue-worker.php
new file mode 100644
index 0000000..3da4331
--- /dev/null
+++ b/video-converter-suite/bin/queue-worker.php
@@ -0,0 +1,68 @@
+#!/usr/bin/env php
+getAllJobs(), fn($j) => $j['status'] === 'running');
+ $running = count($activeJobs);
+
+ if ($running < $config['limits']['max_concurrent_jobs']) {
+ $nextJob = $queue->dequeue();
+ if ($nextJob) {
+ echo "[" . date('H:i:s') . "] Processing: {$nextJob['queue_id']}\n";
+
+ try {
+ $result = $converter->convert([
+ 'input_file' => $nextJob['input_file'] ?? '',
+ 'output_format' => $nextJob['output_format'] ?? 'mp4',
+ 'preset' => $nextJob['preset'] ?? 'balanced',
+ 'resolution' => $nextJob['resolution'] ?? null,
+ ]);
+
+ if (isset($result['error'])) {
+ $queue->fail($nextJob['queue_id'], $result['error']);
+ echo "[" . date('H:i:s') . "] Failed: {$result['error']}\n";
+ } else {
+ $queue->complete($nextJob['queue_id'], $result);
+ echo "[" . date('H:i:s') . "] Started job: {$result['id']}\n";
+ }
+ } catch (\Throwable $e) {
+ $queue->fail($nextJob['queue_id'], $e->getMessage());
+ echo "[" . date('H:i:s') . "] Error: {$e->getMessage()}\n";
+ }
+ }
+ }
+
+ sleep(2);
+}
diff --git a/video-converter-suite/bin/start.sh b/video-converter-suite/bin/start.sh
new file mode 100755
index 0000000..703616f
--- /dev/null
+++ b/video-converter-suite/bin/start.sh
@@ -0,0 +1,101 @@
+#!/bin/bash
+
+# Video Converter Suite - Startup Script
+# Starts all services: Web Server, WebSocket Server, Queue Worker
+
+echo "================================================"
+echo " VIDEO CONVERTER SUITE - Starting Services"
+echo "================================================"
+echo ""
+
+DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "$DIR"
+
+# Create storage directories
+mkdir -p storage/{uploads,outputs,thumbnails,logs,temp}
+
+# Check FFmpeg
+if command -v ffmpeg &> /dev/null; then
+ echo "[OK] FFmpeg: $(ffmpeg -version 2>&1 | head -1)"
+else
+ echo "[!!] FFmpeg not found. Install with: apt install ffmpeg"
+ echo " The application will work but conversions will fail."
+fi
+
+# Check PHP
+if command -v php &> /dev/null; then
+ echo "[OK] PHP: $(php -v 2>&1 | head -1)"
+else
+ echo "[!!] PHP not found."
+ exit 1
+fi
+
+# Install dependencies if needed
+if [ ! -d "vendor" ]; then
+ echo ""
+ echo "Installing dependencies..."
+ if command -v composer &> /dev/null; then
+ composer install
+ else
+ echo "[!!] Composer not found. WebSocket server won't work."
+ echo " The web interface will still work without it."
+ fi
+fi
+
+echo ""
+echo "Starting services..."
+echo ""
+
+# Start Web Server
+echo "[1/3] Web Server on http://localhost:8080"
+php -S 0.0.0.0:8080 -t public public/router.php \
+ -d upload_max_filesize=5G \
+ -d post_max_size=5G \
+ -d memory_limit=512M \
+ -d max_execution_time=3600 \
+ > storage/logs/web.log 2>&1 &
+WEB_PID=$!
+
+# Start WebSocket Server (optional, requires Ratchet)
+if [ -f "vendor/autoload.php" ]; then
+ echo "[2/3] WebSocket Server on ws://localhost:8081"
+ php bin/websocket-server.php > storage/logs/websocket.log 2>&1 &
+ WS_PID=$!
+else
+ echo "[2/3] WebSocket Server: SKIPPED (run composer install first)"
+ WS_PID=""
+fi
+
+# Start Queue Worker
+echo "[3/3] Queue Worker"
+php bin/queue-worker.php > storage/logs/worker.log 2>&1 &
+WORKER_PID=$!
+
+echo ""
+echo "================================================"
+echo " All services started!"
+echo ""
+echo " Web UI: http://localhost:8080"
+echo " WebSocket: ws://localhost:8081"
+echo ""
+echo " PIDs: Web=$WEB_PID WS=$WS_PID Worker=$WORKER_PID"
+echo " Logs: storage/logs/"
+echo ""
+echo " Press Ctrl+C to stop all services"
+echo "================================================"
+
+# Trap exit to kill all processes
+cleanup() {
+ echo ""
+ echo "Stopping all services..."
+ kill $WEB_PID 2>/dev/null
+ [ -n "$WS_PID" ] && kill $WS_PID 2>/dev/null
+ kill $WORKER_PID 2>/dev/null
+ echo "All services stopped."
+ exit 0
+}
+
+trap cleanup EXIT INT TERM
+
+# Wait for any process to exit
+wait
diff --git a/video-converter-suite/bin/websocket-server.php b/video-converter-suite/bin/websocket-server.php
new file mode 100644
index 0000000..4338450
--- /dev/null
+++ b/video-converter-suite/bin/websocket-server.php
@@ -0,0 +1,41 @@
+#!/usr/bin/env php
+loop->addPeriodicTimer(2, function () use ($statusServer) {
+ $statusServer->broadcastStatus();
+});
+
+echo "WebSocket server running. Press Ctrl+C to stop.\n";
+$server->run();
diff --git a/video-converter-suite/composer.json b/video-converter-suite/composer.json
new file mode 100644
index 0000000..e63738f
--- /dev/null
+++ b/video-converter-suite/composer.json
@@ -0,0 +1,21 @@
+{
+ "name": "videokonverter/suite",
+ "description": "Video Converter Suite - Live Stream Pipeline Control Panel",
+ "type": "project",
+ "require": {
+ "php": ">=8.1",
+ "cboden/ratchet": "^0.4",
+ "react/event-loop": "^1.4",
+ "react/child-process": "^0.6"
+ },
+ "autoload": {
+ "psr-4": {
+ "VideoConverter\\": "src/"
+ }
+ },
+ "scripts": {
+ "start": "php -S 0.0.0.0:8080 -t public public/router.php",
+ "websocket": "php bin/websocket-server.php",
+ "worker": "php bin/queue-worker.php"
+ }
+}
diff --git a/video-converter-suite/config/app.php b/video-converter-suite/config/app.php
new file mode 100644
index 0000000..29019ed
--- /dev/null
+++ b/video-converter-suite/config/app.php
@@ -0,0 +1,74 @@
+ 'Video Converter Suite',
+ 'version' => '1.0.0',
+ 'debug' => true,
+
+ 'ffmpeg' => [
+ 'binary' => getenv('FFMPEG_PATH') ?: '/usr/bin/ffmpeg',
+ 'ffprobe' => getenv('FFPROBE_PATH') ?: '/usr/bin/ffprobe',
+ 'threads' => (int)(getenv('FFMPEG_THREADS') ?: 4),
+ 'timeout' => 3600,
+ 'nice' => 10,
+ ],
+
+ 'storage' => [
+ 'uploads' => __DIR__ . '/../storage/uploads',
+ 'outputs' => __DIR__ . '/../storage/outputs',
+ 'thumbnails' => __DIR__ . '/../storage/thumbnails',
+ 'logs' => __DIR__ . '/../storage/logs',
+ 'temp' => __DIR__ . '/../storage/temp',
+ ],
+
+ 'limits' => [
+ 'max_upload_size' => 5 * 1024 * 1024 * 1024, // 5 GB
+ 'max_concurrent_jobs' => 3,
+ 'max_pipeline_depth' => 10,
+ ],
+
+ 'websocket' => [
+ 'host' => '0.0.0.0',
+ 'port' => 8081,
+ ],
+
+ 'formats' => [
+ 'video' => [
+ 'mp4' => ['codec' => 'libx264', 'ext' => 'mp4', 'mime' => 'video/mp4'],
+ 'webm' => ['codec' => 'libvpx-vp9', 'ext' => 'webm', 'mime' => 'video/webm'],
+ 'mkv' => ['codec' => 'libx264', 'ext' => 'mkv', 'mime' => 'video/x-matroska'],
+ 'avi' => ['codec' => 'mpeg4', 'ext' => 'avi', 'mime' => 'video/x-msvideo'],
+ 'mov' => ['codec' => 'libx264', 'ext' => 'mov', 'mime' => 'video/quicktime'],
+ 'flv' => ['codec' => 'flv1', 'ext' => 'flv', 'mime' => 'video/x-flv'],
+ 'wmv' => ['codec' => 'wmv2', 'ext' => 'wmv', 'mime' => 'video/x-ms-wmv'],
+ 'ts' => ['codec' => 'libx264', 'ext' => 'ts', 'mime' => 'video/mp2t'],
+ 'hls' => ['codec' => 'libx264', 'ext' => 'm3u8', 'mime' => 'application/x-mpegURL'],
+ 'dash' => ['codec' => 'libx264', 'ext' => 'mpd', 'mime' => 'application/dash+xml'],
+ ],
+ 'audio' => [
+ 'aac' => ['codec' => 'aac', 'ext' => 'aac', 'mime' => 'audio/aac'],
+ 'mp3' => ['codec' => 'libmp3lame', 'ext' => 'mp3', 'mime' => 'audio/mpeg'],
+ 'ogg' => ['codec' => 'libvorbis', 'ext' => 'ogg', 'mime' => 'audio/ogg'],
+ 'wav' => ['codec' => 'pcm_s16le', 'ext' => 'wav', 'mime' => 'audio/wav'],
+ 'flac' => ['codec' => 'flac', 'ext' => 'flac', 'mime' => 'audio/flac'],
+ 'opus' => ['codec' => 'libopus', 'ext' => 'opus', 'mime' => 'audio/opus'],
+ ],
+ ],
+
+ 'presets' => [
+ 'ultrafast' => ['preset' => 'ultrafast', 'crf' => 28],
+ 'fast' => ['preset' => 'fast', 'crf' => 23],
+ 'balanced' => ['preset' => 'medium', 'crf' => 20],
+ 'quality' => ['preset' => 'slow', 'crf' => 18],
+ 'lossless' => ['preset' => 'veryslow', 'crf' => 0],
+ ],
+
+ 'resolutions' => [
+ '4k' => ['width' => 3840, 'height' => 2160, 'label' => '4K UHD'],
+ '1440p' => ['width' => 2560, 'height' => 1440, 'label' => '2K QHD'],
+ '1080p' => ['width' => 1920, 'height' => 1080, 'label' => 'Full HD'],
+ '720p' => ['width' => 1280, 'height' => 720, 'label' => 'HD'],
+ '480p' => ['width' => 854, 'height' => 480, 'label' => 'SD'],
+ '360p' => ['width' => 640, 'height' => 360, 'label' => 'Low'],
+ ],
+];
diff --git a/video-converter-suite/docker-compose.yml b/video-converter-suite/docker-compose.yml
new file mode 100644
index 0000000..230d2a3
--- /dev/null
+++ b/video-converter-suite/docker-compose.yml
@@ -0,0 +1,54 @@
+version: '3.8'
+
+services:
+ # Main Web Application
+ web:
+ build: .
+ ports:
+ - "8080:8080"
+ volumes:
+ - ./storage:/app/storage
+ - ./src:/app/src
+ - ./public:/app/public
+ - ./templates:/app/templates
+ - ./config:/app/config
+ environment:
+ - FFMPEG_PATH=/usr/bin/ffmpeg
+ - FFPROBE_PATH=/usr/bin/ffprobe
+ - FFMPEG_THREADS=4
+ restart: unless-stopped
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:8080/api/system"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+
+ # WebSocket Server for real-time updates
+ websocket:
+ build: .
+ command: php bin/websocket-server.php
+ ports:
+ - "8081:8081"
+ volumes:
+ - ./storage:/app/storage
+ - ./src:/app/src
+ - ./config:/app/config
+ depends_on:
+ - web
+ restart: unless-stopped
+
+ # Queue Worker for batch processing
+ worker:
+ build: .
+ command: php bin/queue-worker.php
+ volumes:
+ - ./storage:/app/storage
+ - ./src:/app/src
+ - ./config:/app/config
+ environment:
+ - FFMPEG_PATH=/usr/bin/ffmpeg
+ - FFPROBE_PATH=/usr/bin/ffprobe
+ - FFMPEG_THREADS=2
+ depends_on:
+ - web
+ restart: unless-stopped
diff --git a/video-converter-suite/public/api.php b/video-converter-suite/public/api.php
new file mode 100644
index 0000000..c6cfb1c
--- /dev/null
+++ b/video-converter-suite/public/api.php
@@ -0,0 +1,409 @@
+ handleSystem(),
+
+ // Convert
+ $resource === 'convert' && $method === 'POST' => handleConvert($input),
+ $resource === 'convert' && $action === 'batch' && $method === 'POST' => handleBatchConvert($input),
+ $resource === 'upload' && $method === 'POST' => handleUpload(),
+
+ // Jobs
+ $resource === 'jobs' && $method === 'GET' && !$id => handleGetJobs(),
+ $resource === 'jobs' && $method === 'GET' && $id && $action === 'progress' => handleJobProgress($id),
+ $resource === 'jobs' && $method === 'GET' && $id => handleGetJob($id),
+ $resource === 'jobs' && $method === 'DELETE' && $id => handleDeleteJob($id),
+ $resource === 'jobs' && $action === 'cancel' && $method === 'POST' => handleCancelJob($id),
+
+ // Streams
+ $resource === 'streams' && $method === 'GET' && !$id => handleGetStreams(),
+ $resource === 'streams' && $method === 'POST' => handleStartStream($input),
+ $resource === 'streams' && $method === 'GET' && $id => handleGetStream($id),
+ $resource === 'streams' && $method === 'DELETE' && $id => handleStopStream($id),
+ $resource === 'streams' && $action === 'switch' && $method === 'POST' => handleSwitchFormat($id, $input),
+
+ // Pipelines
+ $resource === 'pipelines' && $method === 'GET' && !$id => handleGetPipelines(),
+ $resource === 'pipelines' && $method === 'POST' => handleCreatePipeline($input),
+ $resource === 'pipelines' && $method === 'GET' && $id => handleGetPipeline($id),
+ $resource === 'pipelines' && $method === 'PUT' && $id => handleUpdatePipeline($id, $input),
+ $resource === 'pipelines' && $method === 'DELETE' && $id => handleDeletePipeline($id),
+ $resource === 'pipelines' && $action === 'run' && $method === 'POST' => handleRunPipeline($id, $input),
+ $resource === 'pipelines' && $action === 'stage' && $method === 'POST' => handleAddStage($id, $input),
+
+ // Queue
+ $resource === 'queue' && $method === 'GET' => handleGetQueue(),
+ $resource === 'queue' && $method === 'POST' => handleEnqueue($input),
+ $resource === 'queue' && $method === 'DELETE' => handleClearQueue(),
+
+ // Formats info
+ $resource === 'formats' && $method === 'GET' => handleGetFormats(),
+ $resource === 'presets' && $method === 'GET' => handleGetPresets(),
+ $resource === 'resolutions' && $method === 'GET' => handleGetResolutions(),
+
+ // Probe
+ $resource === 'probe' && $method === 'POST' => handleProbe($input),
+
+ // Downloads
+ $resource === 'download' && $method === 'GET' && $id => handleDownload($id),
+
+ default => ['error' => 'Not found', 'status' => 404],
+ };
+
+ $status = $response['status'] ?? 200;
+ unset($response['status']);
+ http_response_code($status);
+ echo json_encode($response, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+
+} catch (\Throwable $e) {
+ http_response_code(500);
+ echo json_encode(['error' => $e->getMessage()]);
+}
+
+// ---- Handler Functions ----
+
+function handleSystem(): array
+{
+ $load = sys_getloadavg();
+ $config = require __DIR__ . '/../config/app.php';
+ return [
+ 'app' => $config['app_name'],
+ 'version' => $config['version'],
+ 'cpu_load' => $load,
+ 'memory' => [
+ 'used' => memory_get_usage(true),
+ 'peak' => memory_get_peak_usage(true),
+ ],
+ 'disk' => [
+ 'free' => disk_free_space('/'),
+ 'total' => disk_total_space('/'),
+ ],
+ 'php_version' => PHP_VERSION,
+ 'ffmpeg_available' => file_exists($config['ffmpeg']['binary']),
+ ];
+}
+
+function handleUpload(): array
+{
+ $config = require __DIR__ . '/../config/app.php';
+
+ if (empty($_FILES['file'])) {
+ return ['error' => 'No file uploaded', 'status' => 400];
+ }
+
+ $file = $_FILES['file'];
+ if ($file['error'] !== UPLOAD_ERR_OK) {
+ return ['error' => 'Upload error: ' . $file['error'], 'status' => 400];
+ }
+
+ if ($file['size'] > $config['limits']['max_upload_size']) {
+ return ['error' => 'File too large', 'status' => 400];
+ }
+
+ $uploadDir = $config['storage']['uploads'];
+ if (!is_dir($uploadDir)) mkdir($uploadDir, 0755, true);
+
+ $ext = pathinfo($file['name'], PATHINFO_EXTENSION);
+ $safeName = bin2hex(random_bytes(8)) . '.' . $ext;
+ $destination = $uploadDir . '/' . $safeName;
+
+ if (!move_uploaded_file($file['tmp_name'], $destination)) {
+ return ['error' => 'Failed to save file', 'status' => 500];
+ }
+
+ $probe = new \VideoConverter\Process\MediaProbe();
+ $info = $probe->analyze($destination);
+
+ // Generate thumbnail
+ $thumbDir = $config['storage']['thumbnails'];
+ if (!is_dir($thumbDir)) mkdir($thumbDir, 0755, true);
+ $thumbPath = $thumbDir . '/' . pathinfo($safeName, PATHINFO_FILENAME) . '.jpg';
+ $probe->getThumbnail($destination, $thumbPath);
+
+ return [
+ 'file' => $safeName,
+ 'path' => $destination,
+ 'original_name' => $file['name'],
+ 'size' => $file['size'],
+ 'info' => $info,
+ 'thumbnail' => file_exists($thumbPath) ? '/api/thumbnail/' . pathinfo($safeName, PATHINFO_FILENAME) : null,
+ ];
+}
+
+function handleConvert(array $input): array
+{
+ $converter = new \VideoConverter\Format\FormatConverter();
+ return $converter->convert($input);
+}
+
+function handleBatchConvert(array $input): array
+{
+ $converter = new \VideoConverter\Format\FormatConverter();
+ return $converter->batchConvert($input['input_file'] ?? '', $input['formats'] ?? []);
+}
+
+function handleGetJobs(): array
+{
+ $converter = new \VideoConverter\Format\FormatConverter();
+ return ['jobs' => $converter->getAllJobs()];
+}
+
+function handleGetJob(string $id): array
+{
+ $converter = new \VideoConverter\Format\FormatConverter();
+ $job = $converter->getJob($id);
+ return $job ? $job : ['error' => 'Job not found', 'status' => 404];
+}
+
+function handleJobProgress(string $id): array
+{
+ $converter = new \VideoConverter\Format\FormatConverter();
+ return $converter->getProgress($id);
+}
+
+function handleCancelJob(string $id): array
+{
+ $converter = new \VideoConverter\Format\FormatConverter();
+ return ['success' => $converter->cancelJob($id)];
+}
+
+function handleDeleteJob(string $id): array
+{
+ $converter = new \VideoConverter\Format\FormatConverter();
+ return ['success' => $converter->deleteJob($id)];
+}
+
+function handleGetStreams(): array
+{
+ $mgr = new \VideoConverter\Stream\StreamManager();
+ return ['streams' => $mgr->getAllStreams(), 'stats' => $mgr->getStats()];
+}
+
+function handleStartStream(array $input): array
+{
+ $mgr = new \VideoConverter\Stream\StreamManager();
+ return $mgr->startStream($input);
+}
+
+function handleGetStream(string $id): array
+{
+ $mgr = new \VideoConverter\Stream\StreamManager();
+ $stream = $mgr->getStream($id);
+ return $stream ?: ['error' => 'Stream not found', 'status' => 404];
+}
+
+function handleStopStream(string $id): array
+{
+ $mgr = new \VideoConverter\Stream\StreamManager();
+ return ['success' => $mgr->stopStream($id)];
+}
+
+function handleSwitchFormat(string $id, array $input): array
+{
+ $mgr = new \VideoConverter\Stream\StreamManager();
+ return $mgr->switchFormat($id, $input['format'] ?? 'mp4', $input['resolution'] ?? null);
+}
+
+function handleGetPipelines(): array
+{
+ $mgr = new \VideoConverter\Pipeline\PipelineManager();
+ return ['pipelines' => $mgr->toArray()];
+}
+
+function handleCreatePipeline(array $input): array
+{
+ $mgr = new \VideoConverter\Pipeline\PipelineManager();
+ $pipeline = $mgr->create($input['name'] ?? 'Unnamed Pipeline');
+
+ foreach (($input['stages'] ?? []) as $stageData) {
+ $stage = new \VideoConverter\Pipeline\PipelineStage(
+ $stageData['type'] ?? 'transcode',
+ $stageData['params'] ?? [],
+ $stageData['label'] ?? '',
+ $stageData['enabled'] ?? true
+ );
+ $pipeline->addStage($stage);
+ }
+
+ $mgr->save();
+ return $pipeline->toArray();
+}
+
+function handleGetPipeline(string $id): array
+{
+ $mgr = new \VideoConverter\Pipeline\PipelineManager();
+ $pipeline = $mgr->get($id);
+ return $pipeline ? $pipeline->toArray() : ['error' => 'Pipeline not found', 'status' => 404];
+}
+
+function handleUpdatePipeline(string $id, array $input): array
+{
+ $mgr = new \VideoConverter\Pipeline\PipelineManager();
+ $pipeline = $mgr->get($id);
+ if (!$pipeline) return ['error' => 'Pipeline not found', 'status' => 404];
+
+ if (isset($input['stages'])) {
+ // Rebuild stages
+ $ref = new \ReflectionProperty($pipeline, 'stages');
+ $ref->setAccessible(true);
+ $ref->setValue($pipeline, []);
+
+ foreach ($input['stages'] as $stageData) {
+ $stage = \VideoConverter\Pipeline\PipelineStage::fromArray($stageData);
+ $pipeline->addStage($stage);
+ }
+ }
+
+ $mgr->save();
+ return $pipeline->toArray();
+}
+
+function handleDeletePipeline(string $id): array
+{
+ $mgr = new \VideoConverter\Pipeline\PipelineManager();
+ return ['success' => $mgr->delete($id)];
+}
+
+function handleRunPipeline(string $id, array $input): array
+{
+ $mgr = new \VideoConverter\Pipeline\PipelineManager();
+ $pipeline = $mgr->get($id);
+ if (!$pipeline) return ['error' => 'Pipeline not found', 'status' => 404];
+
+ $converter = new \VideoConverter\Format\FormatConverter();
+ return $converter->convert([
+ 'input_file' => $input['input_file'] ?? '',
+ 'output_format' => $input['output_format'] ?? 'mp4',
+ 'pipeline' => $pipeline,
+ ]);
+}
+
+function handleAddStage(string $id, array $input): array
+{
+ $mgr = new \VideoConverter\Pipeline\PipelineManager();
+ $pipeline = $mgr->get($id);
+ if (!$pipeline) return ['error' => 'Pipeline not found', 'status' => 404];
+
+ $stage = new \VideoConverter\Pipeline\PipelineStage(
+ $input['type'] ?? 'transcode',
+ $input['params'] ?? [],
+ $input['label'] ?? '',
+ $input['enabled'] ?? true
+ );
+ $pipeline->addStage($stage);
+ $mgr->save();
+
+ return $pipeline->toArray();
+}
+
+function handleGetQueue(): array
+{
+ $queue = new \VideoConverter\Queue\JobQueue();
+ return ['queue' => $queue->getQueue(), 'stats' => $queue->getStats()];
+}
+
+function handleEnqueue(array $input): array
+{
+ $queue = new \VideoConverter\Queue\JobQueue();
+ $queueId = $queue->enqueue($input);
+ return ['queue_id' => $queueId, 'position' => count($queue->getWaiting())];
+}
+
+function handleClearQueue(): array
+{
+ $queue = new \VideoConverter\Queue\JobQueue();
+ $cleared = $queue->clear();
+ return ['cleared' => $cleared];
+}
+
+function handleGetFormats(): array
+{
+ $config = require __DIR__ . '/../config/app.php';
+ return $config['formats'];
+}
+
+function handleGetPresets(): array
+{
+ $config = require __DIR__ . '/../config/app.php';
+ return $config['presets'];
+}
+
+function handleGetResolutions(): array
+{
+ $config = require __DIR__ . '/../config/app.php';
+ return $config['resolutions'];
+}
+
+function handleProbe(array $input): array
+{
+ $probe = new \VideoConverter\Process\MediaProbe();
+ return $probe->analyze($input['file'] ?? '');
+}
+
+function handleDownload(string $id): array
+{
+ $converter = new \VideoConverter\Format\FormatConverter();
+ $job = $converter->getJob($id);
+ if (!$job || !isset($job['output_file']) || !file_exists($job['output_file'])) {
+ return ['error' => 'File not found', 'status' => 404];
+ }
+
+ header('Content-Type: application/octet-stream');
+ header('Content-Disposition: attachment; filename="' . basename($job['output_file']) . '"');
+ header('Content-Length: ' . filesize($job['output_file']));
+ readfile($job['output_file']);
+ exit;
+}
diff --git a/video-converter-suite/public/css/controlpanel.css b/video-converter-suite/public/css/controlpanel.css
new file mode 100644
index 0000000..8e9ccc4
--- /dev/null
+++ b/video-converter-suite/public/css/controlpanel.css
@@ -0,0 +1,1179 @@
+/* ============================================
+ VIDEO CONVERTER SUITE - NUCLEAR CONTROL PANEL
+ Industrial / Power Plant Control Room Theme
+ ============================================ */
+
+:root {
+ --bg-dark: #0a0e14;
+ --bg-panel: #111820;
+ --bg-module: #151c26;
+ --bg-inset: #0d1117;
+ --border-dark: #1e2a38;
+ --border-glow: #1a3a5c;
+ --text-primary: #c8d6e5;
+ --text-secondary: #6b7d8f;
+ --text-dim: #3d4f61;
+ --accent-blue: #0ea5e9;
+ --accent-cyan: #22d3ee;
+ --accent-green: #10b981;
+ --accent-yellow: #f59e0b;
+ --accent-orange: #f97316;
+ --accent-red: #ef4444;
+ --accent-purple: #8b5cf6;
+ --glow-blue: 0 0 10px rgba(14, 165, 233, 0.3);
+ --glow-green: 0 0 10px rgba(16, 185, 129, 0.3);
+ --glow-red: 0 0 10px rgba(239, 68, 68, 0.3);
+ --glow-yellow: 0 0 10px rgba(245, 158, 11, 0.3);
+ --font-mono: 'JetBrains Mono', 'Fira Code', 'SF Mono', 'Consolas', monospace;
+ --font-display: 'Orbitron', 'Rajdhani', sans-serif;
+}
+
+* { margin: 0; padding: 0; box-sizing: border-box; }
+
+body {
+ font-family: var(--font-mono);
+ background: var(--bg-dark);
+ color: var(--text-primary);
+ min-height: 100vh;
+ overflow-x: hidden;
+}
+
+/* Scanline overlay */
+body::after {
+ content: '';
+ position: fixed;
+ top: 0; left: 0; right: 0; bottom: 0;
+ pointer-events: none;
+ background: repeating-linear-gradient(
+ 0deg,
+ transparent,
+ transparent 2px,
+ rgba(0, 0, 0, 0.03) 2px,
+ rgba(0, 0, 0, 0.03) 4px
+ );
+ z-index: 9999;
+}
+
+/* ---- TOP BAR ---- */
+.topbar {
+ background: linear-gradient(180deg, #0f1520 0%, #0a0e14 100%);
+ border-bottom: 1px solid var(--border-dark);
+ padding: 0 24px;
+ height: 56px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ position: sticky;
+ top: 0;
+ z-index: 100;
+}
+
+.topbar-logo {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.topbar-logo .reactor-icon {
+ width: 32px;
+ height: 32px;
+ border: 2px solid var(--accent-cyan);
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 14px;
+ color: var(--accent-cyan);
+ box-shadow: var(--glow-blue);
+ animation: pulse-glow 2s ease-in-out infinite;
+}
+
+@keyframes pulse-glow {
+ 0%, 100% { box-shadow: 0 0 5px rgba(34, 211, 238, 0.3); }
+ 50% { box-shadow: 0 0 20px rgba(34, 211, 238, 0.6); }
+}
+
+.topbar-title {
+ font-family: var(--font-display);
+ font-size: 16px;
+ font-weight: 700;
+ letter-spacing: 2px;
+ text-transform: uppercase;
+ color: var(--text-primary);
+}
+
+.topbar-subtitle {
+ font-size: 10px;
+ color: var(--text-secondary);
+ letter-spacing: 1px;
+}
+
+.topbar-status {
+ display: flex;
+ align-items: center;
+ gap: 24px;
+}
+
+.status-indicator {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 11px;
+ color: var(--text-secondary);
+}
+
+.status-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--accent-green);
+ box-shadow: var(--glow-green);
+ animation: blink 2s ease-in-out infinite;
+}
+
+.status-dot.warning { background: var(--accent-yellow); box-shadow: var(--glow-yellow); }
+.status-dot.error { background: var(--accent-red); box-shadow: var(--glow-red); animation: blink 0.5s ease-in-out infinite; }
+
+@keyframes blink {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.4; }
+}
+
+.clock {
+ font-family: var(--font-display);
+ font-size: 14px;
+ color: var(--accent-cyan);
+ letter-spacing: 2px;
+}
+
+/* ---- NAVIGATION ---- */
+.nav-bar {
+ background: var(--bg-panel);
+ border-bottom: 1px solid var(--border-dark);
+ display: flex;
+ padding: 0 24px;
+ gap: 0;
+}
+
+.nav-tab {
+ padding: 12px 20px;
+ font-size: 11px;
+ font-family: var(--font-mono);
+ letter-spacing: 1px;
+ text-transform: uppercase;
+ color: var(--text-secondary);
+ cursor: pointer;
+ border: none;
+ background: none;
+ border-bottom: 2px solid transparent;
+ transition: all 0.2s;
+ position: relative;
+}
+
+.nav-tab:hover {
+ color: var(--text-primary);
+ background: rgba(14, 165, 233, 0.05);
+}
+
+.nav-tab.active {
+ color: var(--accent-cyan);
+ border-bottom-color: var(--accent-cyan);
+ background: rgba(14, 165, 233, 0.08);
+}
+
+.nav-tab .badge {
+ position: absolute;
+ top: 6px;
+ right: 6px;
+ background: var(--accent-red);
+ color: white;
+ font-size: 9px;
+ padding: 1px 5px;
+ border-radius: 8px;
+ min-width: 16px;
+ text-align: center;
+}
+
+/* ---- MAIN LAYOUT ---- */
+.main-container {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 16px;
+ padding: 16px 24px;
+ max-width: 1800px;
+ margin: 0 auto;
+}
+
+.panel-row {
+ display: grid;
+ gap: 16px;
+}
+
+.panel-row.cols-2 { grid-template-columns: 1fr 1fr; }
+.panel-row.cols-3 { grid-template-columns: 1fr 1fr 1fr; }
+.panel-row.cols-4 { grid-template-columns: 1fr 1fr 1fr 1fr; }
+.panel-row.cols-2-1 { grid-template-columns: 2fr 1fr; }
+.panel-row.cols-1-2 { grid-template-columns: 1fr 2fr; }
+.panel-row.cols-3-1 { grid-template-columns: 3fr 1fr; }
+
+/* ---- MODULE / PANEL ---- */
+.module {
+ background: var(--bg-module);
+ border: 1px solid var(--border-dark);
+ border-radius: 4px;
+ overflow: hidden;
+}
+
+.module-header {
+ background: linear-gradient(180deg, rgba(30, 42, 56, 0.5) 0%, transparent 100%);
+ padding: 10px 16px;
+ border-bottom: 1px solid var(--border-dark);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.module-title {
+ font-family: var(--font-display);
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 2px;
+ text-transform: uppercase;
+ color: var(--text-secondary);
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.module-title .icon {
+ color: var(--accent-cyan);
+ font-size: 14px;
+}
+
+.module-body {
+ padding: 16px;
+}
+
+.module-footer {
+ padding: 8px 16px;
+ border-top: 1px solid var(--border-dark);
+ font-size: 10px;
+ color: var(--text-dim);
+ display: flex;
+ justify-content: space-between;
+}
+
+/* ---- SYSTEM GAUGES ---- */
+.gauge-grid {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 12px;
+}
+
+.gauge {
+ background: var(--bg-inset);
+ border: 1px solid var(--border-dark);
+ border-radius: 4px;
+ padding: 12px;
+ text-align: center;
+}
+
+.gauge-label {
+ font-size: 9px;
+ letter-spacing: 2px;
+ text-transform: uppercase;
+ color: var(--text-dim);
+ margin-bottom: 8px;
+}
+
+.gauge-value {
+ font-family: var(--font-display);
+ font-size: 24px;
+ font-weight: 700;
+ color: var(--accent-cyan);
+ line-height: 1;
+}
+
+.gauge-unit {
+ font-size: 10px;
+ color: var(--text-secondary);
+ margin-top: 4px;
+}
+
+.gauge-bar {
+ height: 4px;
+ background: var(--bg-dark);
+ border-radius: 2px;
+ margin-top: 8px;
+ overflow: hidden;
+}
+
+.gauge-bar-fill {
+ height: 100%;
+ border-radius: 2px;
+ background: var(--accent-cyan);
+ transition: width 0.5s ease;
+ box-shadow: var(--glow-blue);
+}
+
+.gauge-bar-fill.warning { background: var(--accent-yellow); box-shadow: var(--glow-yellow); }
+.gauge-bar-fill.danger { background: var(--accent-red); box-shadow: var(--glow-red); }
+
+/* ---- INDUSTRIAL SWITCH / TOGGLE ---- */
+.switch-panel {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
+ gap: 8px;
+}
+
+.switch-unit {
+ background: var(--bg-inset);
+ border: 1px solid var(--border-dark);
+ border-radius: 4px;
+ padding: 10px 8px;
+ text-align: center;
+ cursor: pointer;
+ transition: all 0.2s;
+ position: relative;
+}
+
+.switch-unit:hover {
+ border-color: var(--border-glow);
+ background: rgba(14, 165, 233, 0.05);
+}
+
+.switch-unit.active {
+ border-color: var(--accent-cyan);
+ background: rgba(14, 165, 233, 0.1);
+ box-shadow: var(--glow-blue);
+}
+
+.switch-unit.active .switch-led {
+ background: var(--accent-green);
+ box-shadow: var(--glow-green);
+}
+
+.switch-led {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--text-dim);
+ margin: 0 auto 6px;
+ transition: all 0.2s;
+}
+
+.switch-label {
+ font-size: 10px;
+ font-weight: 600;
+ letter-spacing: 1px;
+ text-transform: uppercase;
+ color: var(--text-secondary);
+}
+
+.switch-unit.active .switch-label {
+ color: var(--accent-cyan);
+}
+
+/* ---- INDUSTRIAL TOGGLE SWITCH ---- */
+.toggle-switch {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ cursor: pointer;
+ user-select: none;
+}
+
+.toggle-track {
+ width: 40px;
+ height: 20px;
+ background: var(--bg-dark);
+ border: 2px solid var(--border-dark);
+ border-radius: 10px;
+ position: relative;
+ transition: all 0.3s;
+}
+
+.toggle-track::after {
+ content: '';
+ position: absolute;
+ width: 12px;
+ height: 12px;
+ background: var(--text-dim);
+ border-radius: 50%;
+ top: 2px;
+ left: 2px;
+ transition: all 0.3s;
+}
+
+.toggle-switch.active .toggle-track {
+ background: rgba(16, 185, 129, 0.2);
+ border-color: var(--accent-green);
+}
+
+.toggle-switch.active .toggle-track::after {
+ left: 22px;
+ background: var(--accent-green);
+ box-shadow: var(--glow-green);
+}
+
+/* ---- ROTARY SELECTOR ---- */
+.rotary-selector {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 8px;
+}
+
+.rotary-dial {
+ width: 80px;
+ height: 80px;
+ border-radius: 50%;
+ background: conic-gradient(from -90deg, var(--accent-cyan), var(--accent-blue), var(--accent-purple), var(--accent-cyan));
+ padding: 3px;
+ position: relative;
+}
+
+.rotary-inner {
+ width: 100%;
+ height: 100%;
+ border-radius: 50%;
+ background: var(--bg-inset);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-family: var(--font-display);
+ font-size: 14px;
+ font-weight: 700;
+ color: var(--accent-cyan);
+}
+
+.rotary-label {
+ font-size: 9px;
+ letter-spacing: 2px;
+ text-transform: uppercase;
+ color: var(--text-dim);
+}
+
+/* ---- PIPELINE VISUALIZATION ---- */
+.pipeline-canvas {
+ background: var(--bg-inset);
+ border: 1px solid var(--border-dark);
+ border-radius: 4px;
+ padding: 20px;
+ min-height: 120px;
+ position: relative;
+ overflow-x: auto;
+}
+
+.pipeline-flow {
+ display: flex;
+ align-items: center;
+ gap: 0;
+ min-width: max-content;
+}
+
+.pipeline-node {
+ background: var(--bg-module);
+ border: 1px solid var(--border-dark);
+ border-radius: 4px;
+ padding: 10px 14px;
+ min-width: 100px;
+ text-align: center;
+ position: relative;
+ transition: all 0.2s;
+ cursor: pointer;
+}
+
+.pipeline-node:hover {
+ border-color: var(--accent-cyan);
+ transform: translateY(-2px);
+}
+
+.pipeline-node.active {
+ border-color: var(--accent-green);
+ box-shadow: var(--glow-green);
+}
+
+.pipeline-node.disabled {
+ opacity: 0.4;
+ border-style: dashed;
+}
+
+.pipeline-node .node-type {
+ font-size: 9px;
+ letter-spacing: 1px;
+ text-transform: uppercase;
+ color: var(--text-dim);
+ margin-bottom: 4px;
+}
+
+.pipeline-node .node-name {
+ font-size: 11px;
+ color: var(--text-primary);
+ font-weight: 600;
+}
+
+.pipeline-node .node-status {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ position: absolute;
+ top: 6px;
+ right: 6px;
+ background: var(--accent-green);
+}
+
+.pipeline-node.disabled .node-status { background: var(--text-dim); }
+
+.pipeline-connector {
+ width: 40px;
+ height: 2px;
+ background: var(--border-dark);
+ position: relative;
+ flex-shrink: 0;
+}
+
+.pipeline-connector::after {
+ content: '';
+ position: absolute;
+ right: -4px;
+ top: -3px;
+ border: 4px solid transparent;
+ border-left-color: var(--border-dark);
+}
+
+.pipeline-connector.active {
+ background: var(--accent-cyan);
+ box-shadow: var(--glow-blue);
+}
+
+.pipeline-connector.active::after {
+ border-left-color: var(--accent-cyan);
+}
+
+/* ---- PROGRESS BAR ---- */
+.progress-bar {
+ height: 6px;
+ background: var(--bg-dark);
+ border-radius: 3px;
+ overflow: hidden;
+ position: relative;
+}
+
+.progress-fill {
+ height: 100%;
+ background: linear-gradient(90deg, var(--accent-blue), var(--accent-cyan));
+ border-radius: 3px;
+ transition: width 0.3s ease;
+ position: relative;
+}
+
+.progress-fill::after {
+ content: '';
+ position: absolute;
+ top: 0; right: 0;
+ width: 30px;
+ height: 100%;
+ background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2));
+ animation: shimmer 1.5s infinite;
+}
+
+@keyframes shimmer {
+ 0% { opacity: 0; }
+ 50% { opacity: 1; }
+ 100% { opacity: 0; }
+}
+
+.progress-label {
+ display: flex;
+ justify-content: space-between;
+ font-size: 10px;
+ color: var(--text-secondary);
+ margin-top: 4px;
+}
+
+/* ---- JOB LIST ---- */
+.job-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.job-item {
+ background: var(--bg-inset);
+ border: 1px solid var(--border-dark);
+ border-radius: 4px;
+ padding: 12px;
+ display: grid;
+ grid-template-columns: auto 1fr auto auto;
+ gap: 12px;
+ align-items: center;
+ transition: all 0.2s;
+}
+
+.job-item:hover {
+ border-color: var(--border-glow);
+}
+
+.job-thumb {
+ width: 48px;
+ height: 36px;
+ background: var(--bg-dark);
+ border-radius: 2px;
+ overflow: hidden;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 16px;
+ color: var(--text-dim);
+}
+
+.job-thumb img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.job-info .job-name {
+ font-size: 12px;
+ color: var(--text-primary);
+ margin-bottom: 4px;
+}
+
+.job-info .job-meta {
+ font-size: 10px;
+ color: var(--text-dim);
+}
+
+.job-status {
+ font-size: 10px;
+ letter-spacing: 1px;
+ text-transform: uppercase;
+ padding: 3px 8px;
+ border-radius: 3px;
+ font-weight: 600;
+}
+
+.job-status.running {
+ color: var(--accent-cyan);
+ background: rgba(14, 165, 233, 0.1);
+ border: 1px solid rgba(14, 165, 233, 0.3);
+}
+
+.job-status.completed {
+ color: var(--accent-green);
+ background: rgba(16, 185, 129, 0.1);
+ border: 1px solid rgba(16, 185, 129, 0.3);
+}
+
+.job-status.error {
+ color: var(--accent-red);
+ background: rgba(239, 68, 68, 0.1);
+ border: 1px solid rgba(239, 68, 68, 0.3);
+}
+
+.job-status.queued {
+ color: var(--accent-yellow);
+ background: rgba(245, 158, 11, 0.1);
+ border: 1px solid rgba(245, 158, 11, 0.3);
+}
+
+.job-actions {
+ display: flex;
+ gap: 6px;
+}
+
+/* ---- BUTTONS ---- */
+.btn {
+ font-family: var(--font-mono);
+ font-size: 11px;
+ letter-spacing: 1px;
+ text-transform: uppercase;
+ padding: 6px 14px;
+ border: 1px solid var(--border-dark);
+ border-radius: 3px;
+ background: var(--bg-inset);
+ color: var(--text-secondary);
+ cursor: pointer;
+ transition: all 0.15s;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.btn:hover {
+ border-color: var(--accent-cyan);
+ color: var(--accent-cyan);
+ background: rgba(14, 165, 233, 0.08);
+}
+
+.btn-primary {
+ background: rgba(14, 165, 233, 0.15);
+ border-color: var(--accent-cyan);
+ color: var(--accent-cyan);
+}
+
+.btn-primary:hover {
+ background: rgba(14, 165, 233, 0.25);
+ box-shadow: var(--glow-blue);
+}
+
+.btn-danger {
+ border-color: rgba(239, 68, 68, 0.4);
+ color: var(--accent-red);
+}
+
+.btn-danger:hover {
+ background: rgba(239, 68, 68, 0.1);
+ box-shadow: var(--glow-red);
+}
+
+.btn-success {
+ border-color: rgba(16, 185, 129, 0.4);
+ color: var(--accent-green);
+}
+
+.btn-success:hover {
+ background: rgba(16, 185, 129, 0.1);
+ box-shadow: var(--glow-green);
+}
+
+.btn-icon {
+ width: 28px;
+ height: 28px;
+ padding: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 14px;
+}
+
+.btn-large {
+ padding: 10px 24px;
+ font-size: 12px;
+}
+
+/* Emergency button */
+.btn-emergency {
+ background: var(--accent-red);
+ color: white;
+ border: 2px solid #dc2626;
+ border-radius: 6px;
+ font-weight: 700;
+ padding: 8px 20px;
+ box-shadow: 0 0 15px rgba(239, 68, 68, 0.4);
+ text-shadow: 0 1px 2px rgba(0,0,0,0.3);
+}
+
+.btn-emergency:hover {
+ background: #dc2626;
+ box-shadow: 0 0 25px rgba(239, 68, 68, 0.6);
+ color: white;
+}
+
+/* ---- FORM ELEMENTS ---- */
+.form-group {
+ margin-bottom: 12px;
+}
+
+.form-label {
+ font-size: 10px;
+ letter-spacing: 1px;
+ text-transform: uppercase;
+ color: var(--text-dim);
+ margin-bottom: 4px;
+ display: block;
+}
+
+.form-input {
+ width: 100%;
+ padding: 8px 12px;
+ font-family: var(--font-mono);
+ font-size: 12px;
+ background: var(--bg-dark);
+ border: 1px solid var(--border-dark);
+ border-radius: 3px;
+ color: var(--text-primary);
+ transition: all 0.2s;
+}
+
+.form-input:focus {
+ outline: none;
+ border-color: var(--accent-cyan);
+ box-shadow: var(--glow-blue);
+}
+
+.form-select {
+ width: 100%;
+ padding: 8px 12px;
+ font-family: var(--font-mono);
+ font-size: 12px;
+ background: var(--bg-dark);
+ border: 1px solid var(--border-dark);
+ border-radius: 3px;
+ color: var(--text-primary);
+ cursor: pointer;
+ appearance: none;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%236b7d8f'%3E%3Cpath d='M6 8L1 3h10z'/%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right 10px center;
+}
+
+.form-select:focus {
+ outline: none;
+ border-color: var(--accent-cyan);
+}
+
+/* ---- UPLOAD ZONE ---- */
+.upload-zone {
+ border: 2px dashed var(--border-dark);
+ border-radius: 8px;
+ padding: 40px 20px;
+ text-align: center;
+ cursor: pointer;
+ transition: all 0.3s;
+ background: var(--bg-inset);
+}
+
+.upload-zone:hover,
+.upload-zone.dragover {
+ border-color: var(--accent-cyan);
+ background: rgba(14, 165, 233, 0.05);
+}
+
+.upload-zone .upload-icon {
+ font-size: 48px;
+ color: var(--text-dim);
+ margin-bottom: 12px;
+}
+
+.upload-zone .upload-text {
+ font-size: 13px;
+ color: var(--text-secondary);
+ margin-bottom: 8px;
+}
+
+.upload-zone .upload-hint {
+ font-size: 10px;
+ color: var(--text-dim);
+}
+
+/* ---- LOG CONSOLE ---- */
+.log-console {
+ background: #000;
+ border: 1px solid var(--border-dark);
+ border-radius: 4px;
+ padding: 12px;
+ font-size: 11px;
+ line-height: 1.6;
+ max-height: 200px;
+ overflow-y: auto;
+ font-family: var(--font-mono);
+}
+
+.log-line { color: var(--text-dim); }
+.log-line.info { color: var(--accent-cyan); }
+.log-line.warn { color: var(--accent-yellow); }
+.log-line.error { color: var(--accent-red); }
+.log-line.success { color: var(--accent-green); }
+
+.log-time {
+ color: var(--text-dim);
+ margin-right: 8px;
+}
+
+/* ---- STREAM MATRIX ---- */
+.stream-matrix {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+ gap: 12px;
+}
+
+.stream-card {
+ background: var(--bg-inset);
+ border: 1px solid var(--border-dark);
+ border-radius: 4px;
+ overflow: hidden;
+}
+
+.stream-preview {
+ aspect-ratio: 16/9;
+ background: #000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: relative;
+}
+
+.stream-preview .no-signal {
+ font-family: var(--font-display);
+ font-size: 14px;
+ color: var(--text-dim);
+ letter-spacing: 3px;
+}
+
+.stream-preview .live-badge {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ background: var(--accent-red);
+ color: white;
+ font-size: 9px;
+ padding: 2px 6px;
+ border-radius: 2px;
+ font-weight: 700;
+ letter-spacing: 1px;
+ animation: blink 1s ease-in-out infinite;
+}
+
+.stream-info {
+ padding: 10px;
+}
+
+.stream-info .stream-name {
+ font-size: 12px;
+ margin-bottom: 6px;
+}
+
+.stream-controls {
+ display: flex;
+ gap: 6px;
+ padding: 8px 10px;
+ border-top: 1px solid var(--border-dark);
+}
+
+/* ---- FORMAT MATRIX (NUCLEAR SWITCHBOARD) ---- */
+.format-matrix {
+ display: grid;
+ grid-template-columns: repeat(5, 1fr);
+ gap: 6px;
+}
+
+.format-switch {
+ background: var(--bg-inset);
+ border: 1px solid var(--border-dark);
+ border-radius: 3px;
+ padding: 8px 6px;
+ text-align: center;
+ cursor: pointer;
+ transition: all 0.2s;
+ position: relative;
+ overflow: hidden;
+}
+
+.format-switch::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 2px;
+ background: transparent;
+ transition: background 0.2s;
+}
+
+.format-switch:hover {
+ border-color: var(--border-glow);
+}
+
+.format-switch.selected {
+ border-color: var(--accent-cyan);
+ background: rgba(14, 165, 233, 0.08);
+}
+
+.format-switch.selected::before {
+ background: var(--accent-cyan);
+ box-shadow: 0 0 8px rgba(14, 165, 233, 0.5);
+}
+
+.format-switch .format-name {
+ font-size: 12px;
+ font-weight: 700;
+ text-transform: uppercase;
+ color: var(--text-secondary);
+ letter-spacing: 1px;
+}
+
+.format-switch.selected .format-name {
+ color: var(--accent-cyan);
+}
+
+.format-switch .format-desc {
+ font-size: 8px;
+ color: var(--text-dim);
+ margin-top: 2px;
+}
+
+/* ---- SLIDER ---- */
+.slider-control {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.slider-control input[type="range"] {
+ flex: 1;
+ -webkit-appearance: none;
+ height: 4px;
+ background: var(--bg-dark);
+ border-radius: 2px;
+ outline: none;
+}
+
+.slider-control input[type="range"]::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ width: 16px;
+ height: 16px;
+ border-radius: 50%;
+ background: var(--accent-cyan);
+ border: 2px solid var(--bg-module);
+ cursor: pointer;
+ box-shadow: var(--glow-blue);
+}
+
+.slider-value {
+ font-family: var(--font-display);
+ font-size: 14px;
+ color: var(--accent-cyan);
+ min-width: 50px;
+ text-align: right;
+}
+
+/* ---- RESPONSIVE ---- */
+@media (max-width: 1200px) {
+ .panel-row.cols-3 { grid-template-columns: 1fr 1fr; }
+ .panel-row.cols-4 { grid-template-columns: 1fr 1fr; }
+ .gauge-grid { grid-template-columns: repeat(2, 1fr); }
+}
+
+@media (max-width: 768px) {
+ .panel-row.cols-2,
+ .panel-row.cols-3,
+ .panel-row.cols-4,
+ .panel-row.cols-2-1,
+ .panel-row.cols-1-2 { grid-template-columns: 1fr; }
+
+ .format-matrix { grid-template-columns: repeat(3, 1fr); }
+ .gauge-grid { grid-template-columns: 1fr 1fr; }
+ .main-container { padding: 8px; }
+
+ .topbar { padding: 0 12px; }
+ .topbar-subtitle { display: none; }
+}
+
+/* ---- MODAL ---- */
+.modal-overlay {
+ position: fixed;
+ top: 0; left: 0; right: 0; bottom: 0;
+ background: rgba(0, 0, 0, 0.7);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+ opacity: 0;
+ visibility: hidden;
+ transition: all 0.3s;
+}
+
+.modal-overlay.visible {
+ opacity: 1;
+ visibility: visible;
+}
+
+.modal {
+ background: var(--bg-module);
+ border: 1px solid var(--border-dark);
+ border-radius: 6px;
+ width: 90%;
+ max-width: 600px;
+ max-height: 80vh;
+ overflow-y: auto;
+ transform: translateY(20px);
+ transition: transform 0.3s;
+}
+
+.modal-overlay.visible .modal {
+ transform: translateY(0);
+}
+
+.modal-header {
+ padding: 16px;
+ border-bottom: 1px solid var(--border-dark);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.modal-header h3 {
+ font-family: var(--font-display);
+ font-size: 13px;
+ letter-spacing: 2px;
+ text-transform: uppercase;
+ color: var(--text-primary);
+}
+
+.modal-close {
+ background: none;
+ border: none;
+ color: var(--text-dim);
+ font-size: 18px;
+ cursor: pointer;
+ padding: 4px;
+}
+
+.modal-close:hover { color: var(--accent-red); }
+
+.modal-body { padding: 16px; }
+.modal-footer {
+ padding: 12px 16px;
+ border-top: 1px solid var(--border-dark);
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+}
+
+/* ---- TOOLTIP ---- */
+[data-tooltip] {
+ position: relative;
+}
+
+[data-tooltip]:hover::after {
+ content: attr(data-tooltip);
+ position: absolute;
+ bottom: calc(100% + 8px);
+ left: 50%;
+ transform: translateX(-50%);
+ background: var(--bg-dark);
+ border: 1px solid var(--border-dark);
+ padding: 4px 8px;
+ font-size: 10px;
+ color: var(--text-secondary);
+ white-space: nowrap;
+ border-radius: 3px;
+ z-index: 100;
+}
+
+/* ---- NOTIFICATION ---- */
+.notification {
+ position: fixed;
+ top: 70px;
+ right: 20px;
+ background: var(--bg-module);
+ border: 1px solid var(--border-dark);
+ border-radius: 4px;
+ padding: 12px 16px;
+ font-size: 12px;
+ z-index: 1000;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ transform: translateX(120%);
+ transition: transform 0.3s ease;
+ max-width: 350px;
+}
+
+.notification.show { transform: translateX(0); }
+.notification.success { border-left: 3px solid var(--accent-green); }
+.notification.error { border-left: 3px solid var(--accent-red); }
+.notification.warning { border-left: 3px solid var(--accent-yellow); }
+.notification.info { border-left: 3px solid var(--accent-cyan); }
diff --git a/video-converter-suite/public/js/controlpanel.js b/video-converter-suite/public/js/controlpanel.js
new file mode 100644
index 0000000..61ad2c6
--- /dev/null
+++ b/video-converter-suite/public/js/controlpanel.js
@@ -0,0 +1,828 @@
+/**
+ * Video Converter Suite - Control Panel JavaScript
+ * Nuclear Power Plant Style UI Controller
+ */
+
+// ============ STATE ============
+const state = {
+ currentPage: 'dashboard',
+ selectedFormat: 'mp4',
+ selectedPreset: 'balanced',
+ selectedResolution: 'original',
+ uploadedFile: null,
+ uploadedFilePath: null,
+ jobs: [],
+ streams: [],
+ pipelines: [],
+ activePipelineId: null,
+ pipelineStages: [],
+ activeStreamId: null,
+ wsConnected: false,
+ refreshInterval: null,
+};
+
+// ============ INIT ============
+document.addEventListener('DOMContentLoaded', () => {
+ initClock();
+ initNavigation();
+ initUploadZone();
+ startAutoRefresh();
+ refreshStatus();
+ addLog('System initialisiert', 'info');
+});
+
+// ============ CLOCK ============
+function initClock() {
+ const el = document.getElementById('systemClock');
+ function update() {
+ const now = new Date();
+ el.textContent = now.toTimeString().split(' ')[0];
+ }
+ update();
+ setInterval(update, 1000);
+}
+
+// ============ NAVIGATION ============
+function initNavigation() {
+ document.querySelectorAll('.nav-tab').forEach(tab => {
+ tab.addEventListener('click', () => {
+ const page = tab.dataset.page;
+ switchPage(page);
+ });
+ });
+}
+
+function switchPage(page) {
+ state.currentPage = page;
+ document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active'));
+ document.querySelector(`.nav-tab[data-page="${page}"]`)?.classList.add('active');
+ document.querySelectorAll('.page-content').forEach(p => p.style.display = 'none');
+ document.getElementById(`page-${page}`).style.display = '';
+
+ // Refresh page-specific data
+ if (page === 'dashboard') refreshStatus();
+ if (page === 'streams') refreshStreams();
+ if (page === 'pipelines') refreshPipelines();
+ if (page === 'queue') refreshQueue();
+}
+
+// ============ UPLOAD ============
+function initUploadZone() {
+ const zone = document.getElementById('uploadZone');
+ if (!zone) return;
+
+ zone.addEventListener('dragover', (e) => {
+ e.preventDefault();
+ zone.classList.add('dragover');
+ });
+
+ zone.addEventListener('dragleave', () => {
+ zone.classList.remove('dragover');
+ });
+
+ zone.addEventListener('drop', (e) => {
+ e.preventDefault();
+ zone.classList.remove('dragover');
+ if (e.dataTransfer.files.length > 0) {
+ uploadFile(e.dataTransfer.files[0]);
+ }
+ });
+}
+
+function handleFileSelect(event) {
+ if (event.target.files.length > 0) {
+ uploadFile(event.target.files[0]);
+ }
+}
+
+async function uploadFile(file) {
+ state.uploadedFile = file;
+ addLog(`Upload gestartet: ${file.name} (${formatBytes(file.size)})`, 'info');
+
+ const formData = new FormData();
+ formData.append('file', file);
+
+ try {
+ const resp = await fetch('/api/upload', {
+ method: 'POST',
+ body: formData,
+ });
+ const data = await resp.json();
+
+ if (data.error) {
+ addLog(`Upload-Fehler: ${data.error}`, 'error');
+ notify('Upload fehlgeschlagen: ' + data.error, 'error');
+ return;
+ }
+
+ state.uploadedFilePath = data.path;
+ displayUploadedFile(data);
+ document.getElementById('btnStartConvert').disabled = false;
+ addLog(`Upload abgeschlossen: ${file.name}`, 'success');
+ notify('Datei hochgeladen: ' + file.name, 'success');
+ } catch (err) {
+ addLog(`Upload-Fehler: ${err.message}`, 'error');
+ notify('Upload fehlgeschlagen', 'error');
+ }
+}
+
+function displayUploadedFile(data) {
+ const el = document.getElementById('uploadedFileInfo');
+ el.style.display = 'block';
+
+ const info = data.info || {};
+ const video = info.video || {};
+ const audio = info.audio || {};
+
+ el.innerHTML = `
+
+
+ ${data.original_name}
+ ${formatBytes(data.size)}
+
+
+
Format: ${info.format_name || 'N/A'}
+
Dauer: ${formatDuration(info.duration || 0)}
+ ${video ? `
+
Video: ${video.codec || 'N/A'} ${video.width || ''}x${video.height || ''}
+
FPS: ${video.fps || 'N/A'}
+ ` : ''}
+ ${audio ? `
+
Audio: ${audio.codec || 'N/A'}
+
Sample: ${audio.sample_rate || 'N/A'} Hz
+ ` : ''}
+
+
+ `;
+}
+
+// ============ FORMAT SELECTION ============
+function selectFormat(format) {
+ state.selectedFormat = format;
+ document.querySelectorAll('.format-switch').forEach(s => s.classList.remove('selected'));
+ document.querySelectorAll(`.format-switch[data-format="${format}"]`).forEach(s => s.classList.add('selected'));
+ addLog(`Format gewählt: ${format.toUpperCase()}`, 'info');
+}
+
+function selectPreset(preset) {
+ state.selectedPreset = preset;
+ document.querySelectorAll('#presetPanel .switch-unit').forEach(s => s.classList.remove('active'));
+ document.querySelector(`#presetPanel .switch-unit[data-preset="${preset}"]`)?.classList.add('active');
+}
+
+function selectResolution(res) {
+ state.selectedResolution = res;
+ document.querySelectorAll('#resolutionPanel .switch-unit').forEach(s => s.classList.remove('active'));
+ document.querySelector(`#resolutionPanel .switch-unit[data-resolution="${res}"]`)?.classList.add('active');
+}
+
+// ============ CONVERSION ============
+async function startConversion() {
+ if (!state.uploadedFilePath) {
+ notify('Keine Datei hochgeladen', 'warning');
+ return;
+ }
+
+ const params = {
+ input_file: state.uploadedFilePath,
+ output_format: state.selectedFormat,
+ preset: state.selectedPreset,
+ };
+
+ if (state.selectedResolution !== 'original') {
+ params.resolution = state.selectedResolution;
+ }
+
+ addLog(`Konvertierung gestartet: ${state.selectedFormat.toUpperCase()} / ${state.selectedPreset}`, 'info');
+ document.getElementById('conversionStatus').textContent = 'Konvertierung wird gestartet...';
+
+ try {
+ const resp = await fetch('/api/convert', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(params),
+ });
+ const data = await resp.json();
+
+ if (data.error) {
+ addLog(`Fehler: ${data.error}`, 'error');
+ notify(data.error, 'error');
+ return;
+ }
+
+ addLog(`Job erstellt: ${data.id}`, 'success');
+ notify('Konvertierung gestartet', 'success');
+ document.getElementById('btnStopAll').style.display = '';
+ document.getElementById('conversionStatus').textContent = `Job ${data.id} läuft...`;
+ startJobPolling(data.id);
+
+ } catch (err) {
+ addLog(`Fehler: ${err.message}`, 'error');
+ notify('Konvertierung fehlgeschlagen', 'error');
+ }
+}
+
+function startJobPolling(jobId) {
+ const poll = setInterval(async () => {
+ try {
+ const resp = await fetch(`/api/jobs/${jobId}/progress`);
+ const progress = await resp.json();
+
+ document.getElementById('conversionStatus').textContent =
+ `${progress.percent || 0}% | FPS: ${progress.fps || 0} | Speed: ${progress.speed || '0x'} | Zeit: ${progress.time || '00:00:00'}`;
+
+ updateJobInList(jobId, progress);
+
+ if (progress.percent >= 100) {
+ clearInterval(poll);
+ addLog(`Job ${jobId} abgeschlossen`, 'success');
+ notify('Konvertierung abgeschlossen!', 'success');
+ document.getElementById('conversionStatus').textContent = 'Konvertierung abgeschlossen!';
+ refreshJobs();
+ }
+ } catch (e) {
+ // Keep polling
+ }
+ }, 1000);
+}
+
+async function stopAllJobs() {
+ if (!confirm('Alle laufenden Jobs stoppen?')) return;
+
+ try {
+ const resp = await fetch('/api/jobs');
+ const data = await resp.json();
+ for (const job of (data.jobs || [])) {
+ if (job.status === 'running') {
+ await fetch(`/api/jobs/${job.id}/cancel`, { method: 'POST' });
+ addLog(`Job ${job.id} gestoppt`, 'warn');
+ }
+ }
+ notify('Alle Jobs gestoppt', 'warning');
+ document.getElementById('btnStopAll').style.display = 'none';
+ refreshJobs();
+ } catch (e) {
+ notify('Fehler beim Stoppen', 'error');
+ }
+}
+
+// ============ JOBS ============
+async function refreshJobs() {
+ try {
+ const resp = await fetch('/api/jobs');
+ const data = await resp.json();
+ state.jobs = data.jobs || [];
+ renderJobList();
+ } catch (e) {
+ // Silently fail
+ }
+}
+
+function renderJobList() {
+ const el = document.getElementById('jobList');
+ if (!state.jobs.length) {
+ el.innerHTML = 'Keine aktiven Jobs
';
+ return;
+ }
+
+ el.innerHTML = state.jobs.map(job => `
+
+
${job.thumbnail ? `

` : '🎦'}
+
+
${job.input_file ? job.input_file.split('/').pop() : 'Unknown'}
+
+ ${job.output_format?.toUpperCase() || ''} | ${job.preset || ''} | ${job.resolution || 'Original'}
+
+
+
+ ${job.status === 'completed' ? '100%' : '0%'}
+
+
+
+
${job.status}
+
+ ${job.status === 'running' ? `` : ''}
+ ${job.status === 'completed' ? `` : ''}
+
+
+
+ `).join('');
+
+ // Update active job count
+ const running = state.jobs.filter(j => j.status === 'running').length;
+ document.getElementById('activeJobCount').textContent = running;
+ document.getElementById('gaugeJobs').textContent = running;
+}
+
+function updateJobInList(jobId, progress) {
+ const bar = document.getElementById(`progress-${jobId}`);
+ const text = document.getElementById(`progress-text-${jobId}`);
+ const speed = document.getElementById(`progress-speed-${jobId}`);
+ if (bar) bar.style.width = `${progress.percent || 0}%`;
+ if (text) text.textContent = `${progress.percent || 0}%`;
+ if (speed) speed.textContent = `${progress.fps || 0} fps | ${progress.speed || ''}`;
+}
+
+async function cancelJob(id) {
+ await fetch(`/api/jobs/${id}/cancel`, { method: 'POST' });
+ addLog(`Job ${id} abgebrochen`, 'warn');
+ refreshJobs();
+}
+
+async function deleteJob(id) {
+ await fetch(`/api/jobs/${id}`, { method: 'DELETE' });
+ addLog(`Job ${id} gelöscht`, 'info');
+ refreshJobs();
+}
+
+function downloadJob(id) {
+ window.open(`/api/download/${id}`, '_blank');
+}
+
+// ============ STREAMS ============
+async function refreshStreams() {
+ try {
+ const resp = await fetch('/api/streams');
+ const data = await resp.json();
+ state.streams = data.streams || [];
+ renderStreamMatrix();
+ updateStreamSelect();
+ } catch (e) {}
+}
+
+function renderStreamMatrix() {
+ const el = document.getElementById('streamMatrix');
+ if (!state.streams.length) {
+ el.innerHTML = 'Keine aktiven Streams
';
+ return;
+ }
+
+ el.innerHTML = state.streams.map(s => `
+
+
+ ${s.status === 'running' ? '▶ LIVE' : 'NO SIGNAL'}
+ ${s.status === 'running' ? 'LIVE' : ''}
+
+
+
${s.input_url || 'Stream'}
+
+ ${s.output_format?.toUpperCase() || ''} | ${s.resolution || 'Original'} | ${s.preset || 'fast'}
+
+
${s.status}
+
+
+ ${s.status === 'running' ?
+ `` :
+ ``
+ }
+
+
+
+ `).join('');
+}
+
+function updateStreamSelect() {
+ const sel = document.getElementById('activeStreamSelect');
+ const runningStreams = state.streams.filter(s => s.status === 'running');
+ sel.innerHTML = '' +
+ runningStreams.map(s =>
+ ``
+ ).join('');
+}
+
+function openStreamModal() {
+ document.getElementById('streamModal').classList.add('visible');
+}
+
+function closeStreamModal() {
+ document.getElementById('streamModal').classList.remove('visible');
+}
+
+async function startNewStream() {
+ const inputUrl = document.getElementById('streamInputUrl').value;
+ if (!inputUrl) {
+ notify('Bitte Stream-URL eingeben', 'warning');
+ return;
+ }
+
+ const params = {
+ input_url: inputUrl,
+ output_format: document.getElementById('streamOutputFormat').value,
+ resolution: document.getElementById('streamResolution').value || null,
+ preset: document.getElementById('streamPreset').value,
+ };
+
+ try {
+ const resp = await fetch('/api/streams', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(params),
+ });
+ const data = await resp.json();
+
+ if (data.error) {
+ notify(data.error, 'error');
+ return;
+ }
+
+ addLog(`Stream gestartet: ${data.id}`, 'success');
+ notify('Stream gestartet', 'success');
+ closeStreamModal();
+ refreshStreams();
+ } catch (e) {
+ notify('Stream-Start fehlgeschlagen', 'error');
+ }
+}
+
+async function stopStream(id) {
+ await fetch(`/api/streams/${id}`, { method: 'DELETE' });
+ addLog(`Stream ${id} gestoppt`, 'warn');
+ refreshStreams();
+}
+
+async function deleteStream(id) {
+ await fetch(`/api/streams/${id}`, { method: 'DELETE' });
+ refreshStreams();
+}
+
+function selectActiveStream(id) {
+ state.activeStreamId = id;
+ // Highlight current format
+ const stream = state.streams.find(s => s.id === id);
+ document.querySelectorAll('[data-stream-format]').forEach(el => el.classList.remove('selected'));
+ if (stream) {
+ document.querySelector(`[data-stream-format="${stream.output_format}"]`)?.classList.add('selected');
+ }
+}
+
+async function switchStreamFormat(format) {
+ if (!state.activeStreamId) {
+ notify('Bitte zuerst einen Stream wählen', 'warning');
+ return;
+ }
+
+ addLog(`Format-Wechsel: ${format.toUpperCase()} für Stream ${state.activeStreamId}`, 'warn');
+
+ try {
+ const resp = await fetch(`/api/streams/${state.activeStreamId}/switch`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ format }),
+ });
+ const data = await resp.json();
+
+ if (data.error) {
+ notify(data.error, 'error');
+ return;
+ }
+
+ addLog(`Format gewechselt zu ${format.toUpperCase()}`, 'success');
+ notify(`Format umgeschaltet: ${format.toUpperCase()}`, 'success');
+
+ // Update active stream ID to new stream
+ state.activeStreamId = data.id;
+ refreshStreams();
+
+ // Highlight new format
+ document.querySelectorAll('[data-stream-format]').forEach(el => el.classList.remove('selected'));
+ document.querySelector(`[data-stream-format="${format}"]`)?.classList.add('selected');
+ } catch (e) {
+ notify('Format-Wechsel fehlgeschlagen', 'error');
+ }
+}
+
+// ============ PIPELINES ============
+async function refreshPipelines() {
+ try {
+ const resp = await fetch('/api/pipelines');
+ const data = await resp.json();
+ state.pipelines = data.pipelines || [];
+ renderPipelineList();
+ } catch (e) {}
+}
+
+function renderPipelineList() {
+ const el = document.getElementById('pipelineList');
+ if (!state.pipelines.length) {
+ el.innerHTML = 'Keine Pipelines vorhanden
';
+ return;
+ }
+
+ el.innerHTML = state.pipelines.map(p => `
+
+
☰
+
+
${p.name}
+
${(p.stages || []).length} Stufen | Status: ${p.status}
+
+
${p.status}
+
+
+
+
+
+ `).join('');
+}
+
+async function createPipeline() {
+ const name = prompt('Pipeline-Name:', 'Neue Pipeline');
+ if (!name) return;
+
+ try {
+ const resp = await fetch('/api/pipelines', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name }),
+ });
+ const data = await resp.json();
+ addLog(`Pipeline erstellt: ${data.name}`, 'success');
+ refreshPipelines();
+ editPipeline(data.id);
+ } catch (e) {
+ notify('Pipeline-Erstellung fehlgeschlagen', 'error');
+ }
+}
+
+function editPipeline(id) {
+ state.activePipelineId = id;
+ const pipeline = state.pipelines.find(p => p.id === id);
+ if (!pipeline) return;
+
+ state.pipelineStages = pipeline.stages || [];
+ document.getElementById('pipelineEditor').style.display = '';
+ renderPipelineFlow();
+}
+
+function renderPipelineFlow() {
+ const flow = document.getElementById('pipelineFlow');
+ let html = `
+
+ `;
+
+ state.pipelineStages.forEach((stage, i) => {
+ html += ``;
+ html += `
+
+
${stage.type}
+
${stage.label || stage.type}
+
+
+ `;
+ });
+
+ html += ``;
+ html += `
+
+ `;
+
+ flow.innerHTML = html;
+}
+
+function toggleStage(index) {
+ if (state.pipelineStages[index]) {
+ state.pipelineStages[index].enabled = !state.pipelineStages[index].enabled;
+ renderPipelineFlow();
+ savePipelineStages();
+ }
+}
+
+async function addPipelineStage(type) {
+ if (!state.activePipelineId) {
+ notify('Bitte zuerst eine Pipeline auswählen oder erstellen', 'warning');
+ return;
+ }
+
+ const stageDefaults = {
+ transcode: { params: { video_codec: 'libx264', preset: 'medium', crf: 23 } },
+ scale: { params: { width: 1920, height: 1080 } },
+ filter: { params: { brightness: 0, contrast: 1, saturation: 1 } },
+ audio: { params: { codec: 'aac', bitrate: '128k', sample_rate: 44100 } },
+ bitrate: { params: { video: '2M', audio: '128k' } },
+ framerate: { params: { fps: 30 } },
+ trim: { params: { start: '00:00:00', duration: '' } },
+ deinterlace: { params: {} },
+ denoise: { params: {} },
+ stabilize: { params: {} },
+ };
+
+ const defaults = stageDefaults[type] || { params: {} };
+ const stage = {
+ type,
+ label: type.charAt(0).toUpperCase() + type.slice(1),
+ params: defaults.params,
+ enabled: true,
+ };
+
+ state.pipelineStages.push(stage);
+ renderPipelineFlow();
+ await savePipelineStages();
+ addLog(`Stufe hinzugefügt: ${type}`, 'info');
+}
+
+async function savePipelineStages() {
+ if (!state.activePipelineId) return;
+
+ try {
+ await fetch(`/api/pipelines/${state.activePipelineId}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ stages: state.pipelineStages }),
+ });
+ } catch (e) {
+ console.error('Failed to save pipeline stages');
+ }
+}
+
+async function deletePipeline(id) {
+ if (!confirm('Pipeline löschen?')) return;
+ await fetch(`/api/pipelines/${id}`, { method: 'DELETE' });
+ if (state.activePipelineId === id) {
+ state.activePipelineId = null;
+ document.getElementById('pipelineEditor').style.display = 'none';
+ }
+ refreshPipelines();
+}
+
+async function runPipeline(id) {
+ if (!state.uploadedFilePath) {
+ notify('Bitte zuerst eine Datei hochladen (Konverter-Seite)', 'warning');
+ return;
+ }
+
+ try {
+ const resp = await fetch(`/api/pipelines/${id}/run`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ input_file: state.uploadedFilePath,
+ output_format: state.selectedFormat,
+ }),
+ });
+ const data = await resp.json();
+ if (data.error) {
+ notify(data.error, 'error');
+ return;
+ }
+ addLog(`Pipeline ${id} ausgeführt, Job: ${data.id}`, 'success');
+ notify('Pipeline gestartet', 'success');
+ startJobPolling(data.id);
+ } catch (e) {
+ notify('Pipeline-Start fehlgeschlagen', 'error');
+ }
+}
+
+// ============ QUEUE ============
+async function refreshQueue() {
+ try {
+ const resp = await fetch('/api/queue');
+ const data = await resp.json();
+ renderQueue(data.queue || [], data.stats || {});
+ } catch (e) {}
+}
+
+function renderQueue(queue, stats) {
+ const el = document.getElementById('queueList');
+ if (!queue.length) {
+ el.innerHTML = 'Warteschlange ist leer
';
+ } else {
+ el.innerHTML = queue.map(job => `
+
+
📄
+
+
${job.input_file || job.queue_id}
+
Priorität: ${job.priority || 5} | ${job.output_format || 'mp4'}
+
+
${job.queue_status}
+
+ `).join('');
+ }
+
+ document.getElementById('queueWaiting').textContent = stats.waiting || 0;
+ document.getElementById('queueProcessing').textContent = stats.processing || 0;
+ document.getElementById('queueCompleted').textContent = stats.completed || 0;
+ document.getElementById('queueFailed').textContent = stats.failed || 0;
+}
+
+async function clearQueue() {
+ await fetch('/api/queue', { method: 'DELETE' });
+ refreshQueue();
+ notify('Queue geleert', 'info');
+}
+
+// ============ STATUS / SYSTEM ============
+async function refreshStatus() {
+ try {
+ const resp = await fetch('/api/system');
+ const data = await resp.json();
+ updateGauges(data);
+ refreshJobs();
+ } catch (e) {
+ document.getElementById('systemStatusDot').className = 'status-dot error';
+ document.getElementById('systemStatusText').textContent = 'OFFLINE';
+ }
+}
+
+function updateGauges(data) {
+ // CPU
+ const cpuLoad = data.cpu_load?.[0] || 0;
+ const cpuPercent = Math.min(100, cpuLoad * 25); // Normalize to ~100% at load 4
+ document.getElementById('gaugeCpu').textContent = cpuLoad.toFixed(1);
+ const cpuBar = document.getElementById('gaugeCpuBar');
+ cpuBar.style.width = cpuPercent + '%';
+ cpuBar.className = 'gauge-bar-fill' + (cpuPercent > 80 ? ' danger' : cpuPercent > 50 ? ' warning' : '');
+
+ // Memory
+ const mem = data.memory || {};
+ const memPercent = mem.peak ? Math.round((mem.used / mem.peak) * 100) : 0;
+ document.getElementById('gaugeMem').textContent = memPercent;
+ const memBar = document.getElementById('gaugeMemBar');
+ memBar.style.width = memPercent + '%';
+ memBar.className = 'gauge-bar-fill' + (memPercent > 80 ? ' danger' : memPercent > 50 ? ' warning' : '');
+
+ // Disk
+ const diskFree = (data.disk?.free || 0) / (1024 * 1024 * 1024);
+ const diskTotal = (data.disk?.total || 1) / (1024 * 1024 * 1024);
+ const diskUsedPercent = Math.round(((diskTotal - diskFree) / diskTotal) * 100);
+ document.getElementById('gaugeDisk').textContent = diskFree.toFixed(1);
+ const diskBar = document.getElementById('gaugeDiskBar');
+ diskBar.style.width = diskUsedPercent + '%';
+ diskBar.className = 'gauge-bar-fill' + (diskUsedPercent > 90 ? ' danger' : diskUsedPercent > 70 ? ' warning' : '');
+
+ // Status
+ document.getElementById('systemStatusDot').className = 'status-dot';
+ document.getElementById('systemStatusText').textContent = data.ffmpeg_available ? 'SYSTEM ONLINE' : 'FFMPEG MISSING';
+ if (!data.ffmpeg_available) {
+ document.getElementById('systemStatusDot').className = 'status-dot warning';
+ }
+}
+
+function startAutoRefresh() {
+ if (state.refreshInterval) clearInterval(state.refreshInterval);
+ state.refreshInterval = setInterval(() => {
+ if (state.currentPage === 'dashboard') refreshStatus();
+ if (state.currentPage === 'streams') refreshStreams();
+ }, 5000);
+}
+
+// ============ LOGGING ============
+function addLog(message, level = 'info') {
+ const console = document.getElementById('logConsole');
+ const time = new Date().toLocaleTimeString();
+ const line = document.createElement('div');
+ line.className = `log-line ${level}`;
+ line.innerHTML = `[${time}] ${escapeHtml(message)}`;
+ console.appendChild(line);
+ console.scrollTop = console.scrollHeight;
+
+ // Keep max 100 lines
+ while (console.children.length > 100) {
+ console.removeChild(console.firstChild);
+ }
+}
+
+function clearLog() {
+ document.getElementById('logConsole').innerHTML =
+ '[CLEAR] Log bereinigt
';
+}
+
+// ============ NOTIFICATIONS ============
+function notify(message, type = 'info') {
+ const el = document.getElementById('notification');
+ el.className = `notification ${type} show`;
+ el.textContent = message;
+ setTimeout(() => { el.classList.remove('show'); }, 3000);
+}
+
+// ============ HELPERS ============
+function formatBytes(bytes) {
+ if (bytes === 0) return '0 B';
+ const units = ['B', 'KB', 'MB', 'GB'];
+ let i = 0, size = bytes;
+ while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; }
+ return size.toFixed(1) + ' ' + units[i];
+}
+
+function formatDuration(seconds) {
+ if (!seconds) return '0:00';
+ const h = Math.floor(seconds / 3600);
+ const m = Math.floor((seconds % 3600) / 60);
+ const s = Math.floor(seconds % 60);
+ if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
+ return `${m}:${String(s).padStart(2, '0')}`;
+}
+
+function escapeHtml(str) {
+ const div = document.createElement('div');
+ div.textContent = str;
+ return div.innerHTML;
+}
diff --git a/video-converter-suite/public/router.php b/video-converter-suite/public/router.php
new file mode 100644
index 0000000..61d508b
--- /dev/null
+++ b/video-converter-suite/public/router.php
@@ -0,0 +1,15 @@
+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];
+ }
+}
diff --git a/video-converter-suite/src/Pipeline/Pipeline.php b/video-converter-suite/src/Pipeline/Pipeline.php
new file mode 100644
index 0000000..0fcfe67
--- /dev/null
+++ b/video-converter-suite/src/Pipeline/Pipeline.php
@@ -0,0 +1,127 @@
+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;
+ }
+}
diff --git a/video-converter-suite/src/Pipeline/PipelineManager.php b/video-converter-suite/src/Pipeline/PipelineManager.php
new file mode 100644
index 0000000..af78cea
--- /dev/null
+++ b/video-converter-suite/src/Pipeline/PipelineManager.php
@@ -0,0 +1,89 @@
+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));
+ }
+}
diff --git a/video-converter-suite/src/Pipeline/PipelineStage.php b/video-converter-suite/src/Pipeline/PipelineStage.php
new file mode 100644
index 0000000..8b1a04f
--- /dev/null
+++ b/video-converter-suite/src/Pipeline/PipelineStage.php
@@ -0,0 +1,197 @@
+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;
+ }
+}
diff --git a/video-converter-suite/src/Process/FFmpegProcess.php b/video-converter-suite/src/Process/FFmpegProcess.php
new file mode 100644
index 0000000..56d4705
--- /dev/null
+++ b/video-converter-suite/src/Process/FFmpegProcess.php
@@ -0,0 +1,159 @@
+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];
+ }
+}
diff --git a/video-converter-suite/src/Process/MediaProbe.php b/video-converter-suite/src/Process/MediaProbe.php
new file mode 100644
index 0000000..32b60c1
--- /dev/null
+++ b/video-converter-suite/src/Process/MediaProbe.php
@@ -0,0 +1,106 @@
+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;
+ }
+}
diff --git a/video-converter-suite/src/Queue/JobQueue.php b/video-converter-suite/src/Queue/JobQueue.php
new file mode 100644
index 0000000..c95fb8c
--- /dev/null
+++ b/video-converter-suite/src/Queue/JobQueue.php
@@ -0,0 +1,115 @@
+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;
+ }
+}
diff --git a/video-converter-suite/src/Stream/StreamManager.php b/video-converter-suite/src/Stream/StreamManager.php
new file mode 100644
index 0000000..6140c96
--- /dev/null
+++ b/video-converter-suite/src/Stream/StreamManager.php
@@ -0,0 +1,197 @@
+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')),
+ ];
+ }
+}
diff --git a/video-converter-suite/src/WebSocket/StatusServer.php b/video-converter-suite/src/WebSocket/StatusServer.php
new file mode 100644
index 0000000..02e3095
--- /dev/null
+++ b/video-converter-suite/src/WebSocket/StatusServer.php
@@ -0,0 +1,182 @@
+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,
+ ];
+ }
+}
diff --git a/video-converter-suite/storage/logs/.gitkeep b/video-converter-suite/storage/logs/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/video-converter-suite/storage/outputs/.gitkeep b/video-converter-suite/storage/outputs/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/video-converter-suite/storage/temp/.gitkeep b/video-converter-suite/storage/temp/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/video-converter-suite/storage/thumbnails/.gitkeep b/video-converter-suite/storage/thumbnails/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/video-converter-suite/storage/uploads/.gitkeep b/video-converter-suite/storage/uploads/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/video-converter-suite/templates/dashboard.php b/video-converter-suite/templates/dashboard.php
new file mode 100644
index 0000000..8b50dbc
--- /dev/null
+++ b/video-converter-suite/templates/dashboard.php
@@ -0,0 +1,447 @@
+
+
+
+
+
+ = $config['app_name'] ?> - Control Panel
+
+
+
+
+
+
+
+
+
+
☢
+
+
Video Converter Suite
+
Pipeline Control System v= $config['version'] ?>
+
+
+
+
+
+ SYSTEM ONLINE
+
+
+ JOBS:
+ 0
+
+
00:00:00
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
CPU Load
+
0.0
+
Load Avg
+
+
+
+
Memory
+
0
+
% Used
+
+
+
+
+
Active Jobs
+
0
+
Running
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Keine aktiven Jobs
+
+
+
+
+
+
+
+
+
[INIT] Video Converter Suite gestartet
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
📁
+
Datei hierher ziehen oder klicken
+
Alle Video- und Audio-Formate / max. 5 GB
+
+
+
+
+
+
+
+
+
+
+
Video-Formate
+
+
Audio-Formate
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Keine aktiven Streams. Klicke "Neuer Stream" um zu beginnen.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Aktiver Stream
+
+
+
+
Zielformat wählen (klick = sofort umschalten)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
PIPELINE FLOW - Drag & Drop zum Umordnen
+
+
+
+
VERFUEGBARE STUFEN - Klick zum Hinzufügen
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Warteschlange ist leer
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+