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

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

https://claude.ai/code/session_01WxmHGnVFXGm2bwbFREHkHb
This commit is contained in:
Claude
2026-02-07 18:11:04 +00:00
parent 282d8b70fc
commit 6c56306873
27 changed files with 4746 additions and 0 deletions
+15
View File
@@ -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
+40
View File
@@ -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"]
@@ -0,0 +1,68 @@
#!/usr/bin/env php
<?php
/**
* Video Converter Suite - Queue Worker
*
* Processes jobs from the queue sequentially.
* Usage: php bin/queue-worker.php
*/
spl_autoload_register(function ($class) {
$prefix = 'VideoConverter\\';
$baseDir = __DIR__ . '/../src/';
$len = strlen($prefix);
if (strncmp($prefix, $class, $len) !== 0) return;
$relative = substr($class, $len);
$file = $baseDir . str_replace('\\', '/', $relative) . '.php';
if (file_exists($file)) require $file;
});
use VideoConverter\Queue\JobQueue;
use VideoConverter\Format\FormatConverter;
$config = require __DIR__ . '/../config/app.php';
echo "=== Video Converter Suite - Queue Worker ===\n";
echo "Max concurrent: {$config['limits']['max_concurrent_jobs']}\n\n";
$queue = new JobQueue();
$converter = new FormatConverter();
$running = 0;
while (true) {
$queue = new JobQueue(); // Reload state
$converter = new FormatConverter();
$activeJobs = array_filter($converter->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);
}
+101
View File
@@ -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
@@ -0,0 +1,41 @@
#!/usr/bin/env php
<?php
/**
* Video Converter Suite - WebSocket Server
*
* Provides real-time status updates to connected clients.
* Usage: php bin/websocket-server.php
*/
require_once __DIR__ . '/../vendor/autoload.php';
use Ratchet\Server\IoServer;
use Ratchet\Http\HttpServer;
use Ratchet\WebSocket\WsServer;
use VideoConverter\WebSocket\StatusServer;
$config = require __DIR__ . '/../config/app.php';
$host = $config['websocket']['host'];
$port = $config['websocket']['port'];
echo "=== Video Converter Suite - WebSocket Server ===\n";
echo "Starting on {$host}:{$port}\n\n";
$statusServer = new StatusServer();
$server = IoServer::factory(
new HttpServer(
new WsServer($statusServer)
),
$port,
$host
);
// Broadcast status every 2 seconds
$server->loop->addPeriodicTimer(2, function () use ($statusServer) {
$statusServer->broadcastStatus();
});
echo "WebSocket server running. Press Ctrl+C to stop.\n";
$server->run();
+21
View File
@@ -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"
}
}
+74
View File
@@ -0,0 +1,74 @@
<?php
return [
'app_name' => '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'],
],
];
+54
View File
@@ -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
+409
View File
@@ -0,0 +1,409 @@
<?php
/**
* Video Converter Suite - REST API
*/
spl_autoload_register(function ($class) {
$prefix = 'VideoConverter\\';
$baseDir = __DIR__ . '/../src/';
$len = strlen($prefix);
if (strncmp($prefix, $class, $len) !== 0) return;
$relative = substr($class, $len);
$file = $baseDir . str_replace('\\', '/', $relative) . '.php';
if (file_exists($file)) require $file;
});
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204);
exit;
}
$method = $_SERVER['REQUEST_METHOD'];
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$segments = array_values(array_filter(explode('/', $path)));
// Page routes (return HTML)
if (empty($segments) || ($segments[0] ?? '') !== 'api') {
header('Content-Type: text/html; charset=utf-8');
$page = $segments[0] ?? 'dashboard';
$templateFile = __DIR__ . '/../templates/' . basename($page) . '.php';
if (file_exists($templateFile)) {
$config = require __DIR__ . '/../config/app.php';
require $templateFile;
} else {
$config = require __DIR__ . '/../config/app.php';
require __DIR__ . '/../templates/dashboard.php';
}
exit;
}
// API routes
array_shift($segments); // remove 'api'
$resource = $segments[0] ?? '';
$id = $segments[1] ?? null;
$action = $segments[2] ?? null;
$input = json_decode(file_get_contents('php://input'), true) ?? [];
try {
$response = match (true) {
// System
$resource === 'system' && $method === 'GET' => 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;
}
File diff suppressed because it is too large Load Diff
@@ -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 = `
<div style="background:var(--bg-inset); border:1px solid var(--border-dark); border-radius:4px; padding:12px;">
<div style="display:flex; justify-content:space-between; margin-bottom:8px;">
<strong style="color:var(--accent-cyan);">${data.original_name}</strong>
<span style="color:var(--text-dim); font-size:11px;">${formatBytes(data.size)}</span>
</div>
<div style="display:grid; grid-template-columns: 1fr 1fr; gap:8px; font-size:11px;">
<div><span style="color:var(--text-dim)">Format:</span> ${info.format_name || 'N/A'}</div>
<div><span style="color:var(--text-dim)">Dauer:</span> ${formatDuration(info.duration || 0)}</div>
${video ? `
<div><span style="color:var(--text-dim)">Video:</span> ${video.codec || 'N/A'} ${video.width || ''}x${video.height || ''}</div>
<div><span style="color:var(--text-dim)">FPS:</span> ${video.fps || 'N/A'}</div>
` : ''}
${audio ? `
<div><span style="color:var(--text-dim)">Audio:</span> ${audio.codec || 'N/A'}</div>
<div><span style="color:var(--text-dim)">Sample:</span> ${audio.sample_rate || 'N/A'} Hz</div>
` : ''}
</div>
</div>
`;
}
// ============ 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 = '<div style="text-align:center; color:var(--text-dim); padding:20px;">Keine aktiven Jobs</div>';
return;
}
el.innerHTML = state.jobs.map(job => `
<div class="job-item" id="job-${job.id}">
<div class="job-thumb">${job.thumbnail ? `<img src="${job.thumbnail}">` : '&#127910;'}</div>
<div class="job-info">
<div class="job-name">${job.input_file ? job.input_file.split('/').pop() : 'Unknown'}</div>
<div class="job-meta">
${job.output_format?.toUpperCase() || ''} | ${job.preset || ''} | ${job.resolution || 'Original'}
</div>
<div class="progress-bar" style="margin-top:6px;">
<div class="progress-fill" id="progress-${job.id}" style="width:${job.status === 'completed' ? 100 : 0}%"></div>
</div>
<div class="progress-label">
<span id="progress-text-${job.id}">${job.status === 'completed' ? '100%' : '0%'}</span>
<span id="progress-speed-${job.id}"></span>
</div>
</div>
<span class="job-status ${job.status}">${job.status}</span>
<div class="job-actions">
${job.status === 'running' ? `<button class="btn btn-icon btn-danger" onclick="cancelJob('${job.id}')" data-tooltip="Stop">&#9632;</button>` : ''}
${job.status === 'completed' ? `<button class="btn btn-icon btn-success" onclick="downloadJob('${job.id}')" data-tooltip="Download">&#8681;</button>` : ''}
<button class="btn btn-icon btn-danger" onclick="deleteJob('${job.id}')" data-tooltip="Löschen">&#10005;</button>
</div>
</div>
`).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 = '<div style="text-align:center; color:var(--text-dim); padding:40px; grid-column:1/-1;">Keine aktiven Streams</div>';
return;
}
el.innerHTML = state.streams.map(s => `
<div class="stream-card">
<div class="stream-preview">
<span class="no-signal">${s.status === 'running' ? '&#9654; LIVE' : 'NO SIGNAL'}</span>
${s.status === 'running' ? '<span class="live-badge">LIVE</span>' : ''}
</div>
<div class="stream-info">
<div class="stream-name">${s.input_url || 'Stream'}</div>
<div style="font-size:10px; color:var(--text-dim);">
${s.output_format?.toUpperCase() || ''} | ${s.resolution || 'Original'} | ${s.preset || 'fast'}
</div>
<span class="job-status ${s.status}" style="margin-top:6px; display:inline-block;">${s.status}</span>
</div>
<div class="stream-controls">
${s.status === 'running' ?
`<button class="btn btn-danger" onclick="stopStream('${s.id}')">&#9632; Stop</button>` :
`<button class="btn btn-success" onclick="restartStream('${s.id}')">&#9654; Restart</button>`
}
<button class="btn" onclick="deleteStream('${s.id}')">&#10005;</button>
</div>
</div>
`).join('');
}
function updateStreamSelect() {
const sel = document.getElementById('activeStreamSelect');
const runningStreams = state.streams.filter(s => s.status === 'running');
sel.innerHTML = '<option value="">-- Stream wählen --</option>' +
runningStreams.map(s =>
`<option value="${s.id}">${s.input_url} (${s.output_format?.toUpperCase()})</option>`
).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 = '<div style="text-align:center; color:var(--text-dim); padding:16px;">Keine Pipelines vorhanden</div>';
return;
}
el.innerHTML = state.pipelines.map(p => `
<div class="job-item" style="cursor:pointer" onclick="editPipeline('${p.id}')">
<div class="job-thumb" style="font-size:24px">&#9776;</div>
<div class="job-info">
<div class="job-name">${p.name}</div>
<div class="job-meta">${(p.stages || []).length} Stufen | Status: ${p.status}</div>
</div>
<span class="job-status ${p.status}">${p.status}</span>
<div class="job-actions">
<button class="btn btn-icon btn-primary" onclick="event.stopPropagation(); runPipeline('${p.id}')" data-tooltip="Ausführen">&#9654;</button>
<button class="btn btn-icon btn-danger" onclick="event.stopPropagation(); deletePipeline('${p.id}')" data-tooltip="Löschen">&#10005;</button>
</div>
</div>
`).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 = `
<div class="pipeline-node active">
<div class="node-type">Input</div>
<div class="node-name">Source</div>
<div class="node-status"></div>
</div>
`;
state.pipelineStages.forEach((stage, i) => {
html += `<div class="pipeline-connector ${stage.enabled ? 'active' : ''}"></div>`;
html += `
<div class="pipeline-node ${stage.enabled ? 'active' : 'disabled'}" onclick="toggleStage(${i})">
<div class="node-type">${stage.type}</div>
<div class="node-name">${stage.label || stage.type}</div>
<div class="node-status"></div>
</div>
`;
});
html += `<div class="pipeline-connector active"></div>`;
html += `
<div class="pipeline-node active">
<div class="node-type">Output</div>
<div class="node-name">Target</div>
<div class="node-status"></div>
</div>
`;
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 = '<div style="text-align:center; color:var(--text-dim); padding:20px;">Warteschlange ist leer</div>';
} else {
el.innerHTML = queue.map(job => `
<div class="job-item">
<div class="job-thumb">&#128196;</div>
<div class="job-info">
<div class="job-name">${job.input_file || job.queue_id}</div>
<div class="job-meta">Priorität: ${job.priority || 5} | ${job.output_format || 'mp4'}</div>
</div>
<span class="job-status ${job.queue_status === 'waiting' ? 'queued' : job.queue_status}">${job.queue_status}</span>
</div>
`).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 = `<span class="log-time">[${time}]</span> ${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 =
'<div class="log-line info"><span class="log-time">[CLEAR]</span> Log bereinigt</div>';
}
// ============ 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;
}
+15
View File
@@ -0,0 +1,15 @@
<?php
/**
* Video Converter Suite - Front Controller / Router
*
* Usage: php -S 0.0.0.0:8080 -t public public/router.php
*/
// Serve static files directly
$uri = urldecode(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH));
if ($uri !== '/' && file_exists(__DIR__ . $uri)) {
return false;
}
require_once __DIR__ . '/api.php';
@@ -0,0 +1,282 @@
<?php
namespace VideoConverter\Format;
use VideoConverter\Process\FFmpegProcess;
use VideoConverter\Process\MediaProbe;
use VideoConverter\Pipeline\Pipeline;
class FormatConverter
{
private array $jobs = [];
private string $stateFile;
private MediaProbe $probe;
public function __construct()
{
$this->stateFile = __DIR__ . '/../../storage/temp/jobs.json';
$this->probe = new MediaProbe();
$this->load();
}
private function load(): void
{
if (file_exists($this->stateFile)) {
$this->jobs = json_decode(file_get_contents($this->stateFile), true) ?: [];
}
}
private function save(): void
{
$dir = dirname($this->stateFile);
if (!is_dir($dir)) mkdir($dir, 0755, true);
file_put_contents($this->stateFile, json_encode($this->jobs, JSON_PRETTY_PRINT));
}
public function convert(array $params): array
{
$config = require __DIR__ . '/../../config/app.php';
$inputFile = $params['input_file'] ?? '';
$outputFormat = $params['output_format'] ?? 'mp4';
$preset = $params['preset'] ?? 'balanced';
$resolution = $params['resolution'] ?? null;
$customPipeline = $params['pipeline'] ?? null;
if (!file_exists($inputFile)) {
return ['error' => 'Input file not found'];
}
$id = bin2hex(random_bytes(8));
$formatConfig = $config['formats']['video'][$outputFormat]
?? $config['formats']['audio'][$outputFormat]
?? null;
if (!$formatConfig) {
return ['error' => "Unknown format: {$outputFormat}"];
}
$inputInfo = $this->probe->analyze($inputFile);
$baseName = pathinfo($inputFile, PATHINFO_FILENAME);
$outputFile = $config['storage']['outputs'] . "/{$baseName}_{$id}.{$formatConfig['ext']}";
// Build command
if ($customPipeline instanceof Pipeline) {
$cmd = $customPipeline->buildFFmpegCommand($inputFile, $outputFile);
} else {
$cmd = $this->buildCommand($inputFile, $outputFile, $outputFormat, $preset, $resolution, $params);
}
$process = new FFmpegProcess($cmd, $id);
if (isset($inputInfo['duration'])) {
$process->setDuration($inputInfo['duration']);
}
// Generate thumbnail
$thumbPath = $config['storage']['thumbnails'] . "/{$id}.jpg";
$this->probe->getThumbnail($inputFile, $thumbPath);
$job = [
'id' => $id,
'input_file' => $inputFile,
'input_info' => $inputInfo,
'output_file' => $outputFile,
'output_format' => $outputFormat,
'preset' => $preset,
'resolution' => $resolution,
'thumbnail' => file_exists($thumbPath) ? $thumbPath : null,
'status' => 'starting',
'pid' => null,
'command' => $cmd,
'created_at' => date('c'),
];
if ($process->start()) {
$job['status'] = 'running';
$job['pid'] = $process->getPid();
} else {
$job['status'] = 'error';
$job['error'] = 'Failed to start FFmpeg process';
}
$this->jobs[$id] = $job;
$this->save();
return $job;
}
public function batchConvert(string $inputFile, array $formats): array
{
$results = [];
foreach ($formats as $format => $settings) {
$params = array_merge(
['input_file' => $inputFile, 'output_format' => $format],
$settings
);
$results[$format] = $this->convert($params);
}
return $results;
}
public function getJob(string $id): ?array
{
$this->refreshJob($id);
return $this->jobs[$id] ?? null;
}
public function getAllJobs(): array
{
foreach (array_keys($this->jobs) as $id) {
$this->refreshJob($id);
}
return array_values($this->jobs);
}
public function cancelJob(string $id): bool
{
if (!isset($this->jobs[$id])) return false;
$job = $this->jobs[$id];
if ($job['pid'] && $job['status'] === 'running') {
posix_kill($job['pid'], SIGTERM);
$this->jobs[$id]['status'] = 'cancelled';
$this->save();
return true;
}
return false;
}
public function deleteJob(string $id): bool
{
if (isset($this->jobs[$id])) {
$this->cancelJob($id);
// Clean up output file
if (isset($this->jobs[$id]['output_file']) && file_exists($this->jobs[$id]['output_file'])) {
unlink($this->jobs[$id]['output_file']);
}
unset($this->jobs[$id]);
$this->save();
return true;
}
return false;
}
public function getProgress(string $id): array
{
if (!isset($this->jobs[$id])) {
return ['error' => 'Job not found'];
}
$config = require __DIR__ . '/../../config/app.php';
$progressFile = $config['storage']['logs'] . "/progress_{$id}.txt";
$progress = ['percent' => 0, 'fps' => 0, 'speed' => '0x', 'time' => '00:00:00'];
if (file_exists($progressFile)) {
$content = file_get_contents($progressFile);
foreach (explode("\n", $content) as $line) {
if (str_contains($line, '=')) {
[$key, $val] = explode('=', $line, 2);
$key = trim($key);
$val = trim($val);
if ($key === 'out_time') $progress['time'] = $val;
if ($key === 'fps') $progress['fps'] = (float)$val;
if ($key === 'speed') $progress['speed'] = $val;
if ($key === 'progress' && $val === 'end') $progress['percent'] = 100;
}
}
$duration = $this->jobs[$id]['input_info']['duration'] ?? 0;
if ($duration > 0 && $progress['percent'] < 100) {
$current = $this->timeToSeconds($progress['time']);
$progress['percent'] = min(99, round(($current / $duration) * 100, 1));
}
}
return $progress;
}
private function refreshJob(string $id): void
{
if (!isset($this->jobs[$id])) return;
$job = &$this->jobs[$id];
if ($job['status'] === 'running' && $job['pid']) {
if (!posix_kill($job['pid'], 0)) {
// Check if output file exists and has size
if (isset($job['output_file']) && file_exists($job['output_file']) && filesize($job['output_file']) > 0) {
$job['status'] = 'completed';
$job['completed_at'] = date('c');
$job['output_size'] = filesize($job['output_file']);
} else {
$job['status'] = 'error';
$job['error'] = 'Process ended without output';
}
$this->save();
}
}
}
private function buildCommand(string $input, string $output, string $format, string $preset, ?string $resolution, array $params): string
{
$config = require __DIR__ . '/../../config/app.php';
$ffmpeg = $config['ffmpeg']['binary'];
$formatConfig = $config['formats']['video'][$format] ?? $config['formats']['audio'][$format] ?? [];
$presetConfig = $config['presets'][$preset] ?? $config['presets']['balanced'];
$threads = $config['ffmpeg']['threads'];
$cmd = "{$ffmpeg} -y -i " . escapeshellarg($input);
$cmd .= " -threads {$threads}";
// Check if audio-only
$isAudio = isset($config['formats']['audio'][$format]);
if ($isAudio) {
$cmd .= " -vn";
$cmd .= " -c:a " . escapeshellarg($formatConfig['codec']);
if (isset($params['audio_bitrate'])) {
$cmd .= " -b:a " . escapeshellarg($params['audio_bitrate']);
}
} else {
$cmd .= " -c:v " . escapeshellarg($formatConfig['codec']);
$cmd .= " -preset " . escapeshellarg($presetConfig['preset']);
$cmd .= " -crf " . (int)$presetConfig['crf'];
if ($resolution && isset($config['resolutions'][$resolution])) {
$res = $config['resolutions'][$resolution];
$cmd .= " -vf scale={$res['width']}:{$res['height']}";
}
$cmd .= " -c:a aac -b:a 128k";
}
// HLS specific
if ($format === 'hls') {
$cmd .= " -hls_time 4 -hls_list_size 0 -hls_segment_filename "
. escapeshellarg(dirname($output) . "/segment_%03d.ts");
}
// DASH specific
if ($format === 'dash') {
$cmd .= " -use_timeline 1 -use_template 1 -adaptation_sets 'id=0,streams=v id=1,streams=a'";
}
// Extra params
if (isset($params['video_bitrate'])) {
$cmd .= " -b:v " . escapeshellarg($params['video_bitrate']);
}
if (isset($params['fps'])) {
$cmd .= " -r " . (int)$params['fps'];
}
$cmd .= " " . escapeshellarg($output);
return $cmd;
}
private function timeToSeconds(string $time): float
{
$parts = explode(':', $time);
if (count($parts) !== 3) return 0;
return (int)$parts[0] * 3600 + (int)$parts[1] * 60 + (float)$parts[2];
}
}
@@ -0,0 +1,127 @@
<?php
namespace VideoConverter\Pipeline;
class Pipeline
{
private string $id;
private string $name;
private array $stages = [];
private string $status = 'idle'; // idle, running, paused, error, completed
private ?string $inputSource = null;
private array $metadata = [];
private float $progress = 0;
private ?int $pid = null;
private string $createdAt;
public function __construct(string $name, ?string $id = null)
{
$this->id = $id ?? bin2hex(random_bytes(8));
$this->name = $name;
$this->createdAt = date('c');
}
public function getId(): string { return $this->id; }
public function getName(): string { return $this->name; }
public function getStatus(): string { return $this->status; }
public function getProgress(): float { return $this->progress; }
public function getPid(): ?int { return $this->pid; }
public function getStages(): array { return $this->stages; }
public function getInputSource(): ?string { return $this->inputSource; }
public function setStatus(string $status): void { $this->status = $status; }
public function setProgress(float $progress): void { $this->progress = min(100, max(0, $progress)); }
public function setPid(?int $pid): void { $this->pid = $pid; }
public function setInputSource(string $source): void { $this->inputSource = $source; }
public function addStage(PipelineStage $stage): self
{
$this->stages[] = $stage;
return $this;
}
public function removeStage(int $index): self
{
if (isset($this->stages[$index])) {
array_splice($this->stages, $index, 1);
}
return $this;
}
public function insertStage(int $index, PipelineStage $stage): self
{
array_splice($this->stages, $index, 0, [$stage]);
return $this;
}
public function setMetadata(string $key, mixed $value): void
{
$this->metadata[$key] = $value;
}
public function getMetadata(?string $key = null): mixed
{
if ($key === null) return $this->metadata;
return $this->metadata[$key] ?? null;
}
public function buildFFmpegCommand(string $inputPath, string $outputPath): string
{
$config = require __DIR__ . '/../../config/app.php';
$cmd = $config['ffmpeg']['binary'];
$parts = ["-y -i " . escapeshellarg($inputPath)];
foreach ($this->stages as $stage) {
$parts[] = $stage->toFFmpegArgs();
}
$parts[] = escapeshellarg($outputPath);
return $cmd . ' ' . implode(' ', $parts);
}
public function buildStreamCommand(string $inputUrl, string $outputUrl): string
{
$config = require __DIR__ . '/../../config/app.php';
$cmd = $config['ffmpeg']['binary'];
$parts = ["-re -i " . escapeshellarg($inputUrl)];
foreach ($this->stages as $stage) {
$parts[] = $stage->toFFmpegArgs();
}
$parts[] = "-f flv " . escapeshellarg($outputUrl);
return $cmd . ' ' . implode(' ', $parts);
}
public function toArray(): array
{
return [
'id' => $this->id,
'name' => $this->name,
'status' => $this->status,
'progress' => $this->progress,
'pid' => $this->pid,
'input_source' => $this->inputSource,
'stages' => array_map(fn(PipelineStage $s) => $s->toArray(), $this->stages),
'metadata' => $this->metadata,
'created_at' => $this->createdAt,
];
}
public static function fromArray(array $data): self
{
$pipeline = new self($data['name'], $data['id']);
$pipeline->status = $data['status'] ?? 'idle';
$pipeline->progress = $data['progress'] ?? 0;
$pipeline->pid = $data['pid'] ?? null;
$pipeline->inputSource = $data['input_source'] ?? null;
$pipeline->metadata = $data['metadata'] ?? [];
$pipeline->createdAt = $data['created_at'] ?? date('c');
foreach (($data['stages'] ?? []) as $stageData) {
$pipeline->addStage(PipelineStage::fromArray($stageData));
}
return $pipeline;
}
}
@@ -0,0 +1,89 @@
<?php
namespace VideoConverter\Pipeline;
class PipelineManager
{
private string $stateFile;
private array $pipelines = [];
public function __construct()
{
$this->stateFile = __DIR__ . '/../../storage/temp/pipelines.json';
$this->load();
}
private function load(): void
{
if (file_exists($this->stateFile)) {
$data = json_decode(file_get_contents($this->stateFile), true);
foreach (($data['pipelines'] ?? []) as $pData) {
$this->pipelines[$pData['id']] = Pipeline::fromArray($pData);
}
}
}
public function save(): void
{
$dir = dirname($this->stateFile);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
$data = ['pipelines' => []];
foreach ($this->pipelines as $pipeline) {
$data['pipelines'][] = $pipeline->toArray();
}
file_put_contents($this->stateFile, json_encode($data, JSON_PRETTY_PRINT));
}
public function create(string $name): Pipeline
{
$pipeline = new Pipeline($name);
$this->pipelines[$pipeline->getId()] = $pipeline;
$this->save();
return $pipeline;
}
public function get(string $id): ?Pipeline
{
return $this->pipelines[$id] ?? null;
}
public function getAll(): array
{
return $this->pipelines;
}
public function delete(string $id): bool
{
if (isset($this->pipelines[$id])) {
$pipeline = $this->pipelines[$id];
if ($pipeline->getStatus() === 'running' && $pipeline->getPid()) {
posix_kill($pipeline->getPid(), SIGTERM);
}
unset($this->pipelines[$id]);
$this->save();
return true;
}
return false;
}
public function getRunningCount(): int
{
$count = 0;
foreach ($this->pipelines as $p) {
if ($p->getStatus() === 'running') $count++;
}
return $count;
}
public function getByStatus(string $status): array
{
return array_filter($this->pipelines, fn(Pipeline $p) => $p->getStatus() === $status);
}
public function toArray(): array
{
return array_map(fn(Pipeline $p) => $p->toArray(), array_values($this->pipelines));
}
}
@@ -0,0 +1,197 @@
<?php
namespace VideoConverter\Pipeline;
class PipelineStage
{
private string $id;
private string $type; // transcode, scale, filter, audio, watermark, trim, split
private array $params;
private bool $enabled;
private string $label;
public function __construct(string $type, array $params = [], string $label = '', bool $enabled = true)
{
$this->id = bin2hex(random_bytes(4));
$this->type = $type;
$this->params = $params;
$this->label = $label ?: ucfirst($type);
$this->enabled = $enabled;
}
public function getId(): string { return $this->id; }
public function getType(): string { return $this->type; }
public function getParams(): array { return $this->params; }
public function isEnabled(): bool { return $this->enabled; }
public function getLabel(): string { return $this->label; }
public function setEnabled(bool $enabled): void { $this->enabled = $enabled; }
public function setParams(array $params): void { $this->params = $params; }
public function toFFmpegArgs(): string
{
if (!$this->enabled) return '';
return match ($this->type) {
'transcode' => $this->buildTranscodeArgs(),
'scale' => $this->buildScaleArgs(),
'filter' => $this->buildFilterArgs(),
'audio' => $this->buildAudioArgs(),
'watermark' => $this->buildWatermarkArgs(),
'trim' => $this->buildTrimArgs(),
'bitrate' => $this->buildBitrateArgs(),
'framerate' => $this->buildFramerateArgs(),
'deinterlace' => '-vf yadif',
'denoise' => '-vf hqdn3d',
'stabilize' => '-vf deshake',
default => '',
};
}
private function buildTranscodeArgs(): string
{
$args = [];
if (isset($this->params['video_codec'])) {
$args[] = "-c:v " . escapeshellarg($this->params['video_codec']);
}
if (isset($this->params['audio_codec'])) {
$args[] = "-c:a " . escapeshellarg($this->params['audio_codec']);
}
if (isset($this->params['preset'])) {
$args[] = "-preset " . escapeshellarg($this->params['preset']);
}
if (isset($this->params['crf'])) {
$args[] = "-crf " . (int)$this->params['crf'];
}
return implode(' ', $args);
}
private function buildScaleArgs(): string
{
$w = (int)($this->params['width'] ?? -1);
$h = (int)($this->params['height'] ?? -1);
$algo = $this->params['algorithm'] ?? 'lanczos';
return "-vf scale={$w}:{$h}:flags={$algo}";
}
private function buildFilterArgs(): string
{
$filters = [];
if (isset($this->params['brightness'])) {
$filters[] = "eq=brightness=" . (float)$this->params['brightness'];
}
if (isset($this->params['contrast'])) {
$filters[] = "eq=contrast=" . (float)$this->params['contrast'];
}
if (isset($this->params['saturation'])) {
$filters[] = "eq=saturation=" . (float)$this->params['saturation'];
}
if (isset($this->params['gamma'])) {
$filters[] = "eq=gamma=" . (float)$this->params['gamma'];
}
if (isset($this->params['custom'])) {
$filters[] = $this->params['custom'];
}
return $filters ? '-vf ' . escapeshellarg(implode(',', $filters)) : '';
}
private function buildAudioArgs(): string
{
$args = [];
if (isset($this->params['codec'])) {
$args[] = "-c:a " . escapeshellarg($this->params['codec']);
}
if (isset($this->params['bitrate'])) {
$args[] = "-b:a " . escapeshellarg($this->params['bitrate']);
}
if (isset($this->params['sample_rate'])) {
$args[] = "-ar " . (int)$this->params['sample_rate'];
}
if (isset($this->params['channels'])) {
$args[] = "-ac " . (int)$this->params['channels'];
}
if (isset($this->params['volume'])) {
$args[] = "-af volume=" . (float)$this->params['volume'];
}
return implode(' ', $args);
}
private function buildWatermarkArgs(): string
{
$image = $this->params['image'] ?? '';
$position = $this->params['position'] ?? 'topright';
$overlay = match ($position) {
'topleft' => 'overlay=10:10',
'topright' => 'overlay=W-w-10:10',
'bottomleft' => 'overlay=10:H-h-10',
'bottomright' => 'overlay=W-w-10:H-h-10',
'center' => 'overlay=(W-w)/2:(H-h)/2',
default => 'overlay=W-w-10:10',
};
return "-i " . escapeshellarg($image) . " -filter_complex \"{$overlay}\"";
}
private function buildTrimArgs(): string
{
$args = [];
if (isset($this->params['start'])) {
$args[] = "-ss " . escapeshellarg($this->params['start']);
}
if (isset($this->params['duration'])) {
$args[] = "-t " . escapeshellarg($this->params['duration']);
}
if (isset($this->params['end'])) {
$args[] = "-to " . escapeshellarg($this->params['end']);
}
return implode(' ', $args);
}
private function buildBitrateArgs(): string
{
$args = [];
if (isset($this->params['video'])) {
$args[] = "-b:v " . escapeshellarg($this->params['video']);
}
if (isset($this->params['audio'])) {
$args[] = "-b:a " . escapeshellarg($this->params['audio']);
}
if (isset($this->params['maxrate'])) {
$args[] = "-maxrate " . escapeshellarg($this->params['maxrate']);
$args[] = "-bufsize " . escapeshellarg($this->params['bufsize'] ?? $this->params['maxrate']);
}
return implode(' ', $args);
}
private function buildFramerateArgs(): string
{
$fps = (float)($this->params['fps'] ?? 30);
return "-r {$fps}";
}
public function toArray(): array
{
return [
'id' => $this->id,
'type' => $this->type,
'label' => $this->label,
'params' => $this->params,
'enabled' => $this->enabled,
];
}
public static function fromArray(array $data): self
{
$stage = new self(
$data['type'],
$data['params'] ?? [],
$data['label'] ?? '',
$data['enabled'] ?? true
);
if (isset($data['id'])) {
// Use reflection to set the id for deserialization
$ref = new \ReflectionProperty($stage, 'id');
$ref->setValue($stage, $data['id']);
}
return $stage;
}
}
@@ -0,0 +1,159 @@
<?php
namespace VideoConverter\Process;
class FFmpegProcess
{
private string $command;
private ?int $pid = null;
private string $logFile;
private string $progressFile;
private float $duration = 0;
private string $status = 'pending';
private array $outputLines = [];
public function __construct(string $command, string $jobId)
{
$config = require __DIR__ . '/../../config/app.php';
$this->command = $command;
$logDir = $config['storage']['logs'];
if (!is_dir($logDir)) mkdir($logDir, 0755, true);
$this->logFile = $logDir . "/ffmpeg_{$jobId}.log";
$this->progressFile = $logDir . "/progress_{$jobId}.txt";
}
public function start(): bool
{
$cmd = $this->command
. " -progress " . escapeshellarg($this->progressFile)
. " -stats_period 0.5"
. " 2>" . escapeshellarg($this->logFile)
. " & echo $!";
$output = [];
exec($cmd, $output);
$this->pid = (int)($output[0] ?? 0);
if ($this->pid > 0) {
$this->status = 'running';
return true;
}
$this->status = 'error';
return false;
}
public function stop(): void
{
if ($this->pid && $this->isRunning()) {
posix_kill($this->pid, SIGTERM);
usleep(500000);
if ($this->isRunning()) {
posix_kill($this->pid, SIGKILL);
}
}
$this->status = 'stopped';
}
public function pause(): void
{
if ($this->pid && $this->isRunning()) {
posix_kill($this->pid, SIGSTOP);
$this->status = 'paused';
}
}
public function resume(): void
{
if ($this->pid) {
posix_kill($this->pid, SIGCONT);
$this->status = 'running';
}
}
public function isRunning(): bool
{
if (!$this->pid) return false;
return posix_kill($this->pid, 0);
}
public function getProgress(): array
{
$progress = [
'percent' => 0,
'frame' => 0,
'fps' => 0,
'speed' => '0x',
'time' => '00:00:00.00',
'bitrate' => '0kbits/s',
'size' => '0kB',
];
if (!file_exists($this->progressFile)) return $progress;
$content = file_get_contents($this->progressFile);
$lines = explode("\n", $content);
foreach ($lines as $line) {
$line = trim($line);
if (str_contains($line, '=')) {
[$key, $value] = explode('=', $line, 2);
$key = trim($key);
$value = trim($value);
switch ($key) {
case 'frame': $progress['frame'] = (int)$value; break;
case 'fps': $progress['fps'] = (float)$value; break;
case 'speed': $progress['speed'] = $value; break;
case 'out_time': $progress['time'] = $value; break;
case 'total_size': $progress['size'] = $this->formatBytes((int)$value); break;
case 'bitrate': $progress['bitrate'] = $value; break;
case 'progress':
if ($value === 'end') $progress['percent'] = 100;
break;
}
}
}
if ($this->duration > 0 && $progress['percent'] < 100) {
$currentTime = $this->timeToSeconds($progress['time']);
$progress['percent'] = min(99, round(($currentTime / $this->duration) * 100, 1));
}
return $progress;
}
public function getLog(int $lines = 50): string
{
if (!file_exists($this->logFile)) return '';
$all = file($this->logFile);
return implode('', array_slice($all, -$lines));
}
public function setDuration(float $duration): void
{
$this->duration = $duration;
}
public function getPid(): ?int { return $this->pid; }
public function getStatus(): string { return $this->status; }
public function getCommand(): string { return $this->command; }
private function timeToSeconds(string $time): float
{
$parts = explode(':', $time);
if (count($parts) !== 3) return 0;
return (int)$parts[0] * 3600 + (int)$parts[1] * 60 + (float)$parts[2];
}
private function formatBytes(int $bytes): string
{
$units = ['B', 'KB', 'MB', 'GB'];
$i = 0;
$size = (float)$bytes;
while ($size >= 1024 && $i < count($units) - 1) {
$size /= 1024;
$i++;
}
return round($size, 1) . $units[$i];
}
}
@@ -0,0 +1,106 @@
<?php
namespace VideoConverter\Process;
class MediaProbe
{
private string $ffprobe;
public function __construct()
{
$config = require __DIR__ . '/../../config/app.php';
$this->ffprobe = $config['ffmpeg']['ffprobe'];
}
public function analyze(string $filePath): array
{
$cmd = sprintf(
'%s -v quiet -print_format json -show_format -show_streams %s',
$this->ffprobe,
escapeshellarg($filePath)
);
$output = shell_exec($cmd);
$data = json_decode($output ?: '{}', true);
if (!$data) {
return ['error' => 'Could not analyze file'];
}
return $this->parseProbeData($data);
}
public function getDuration(string $filePath): float
{
$info = $this->analyze($filePath);
return (float)($info['duration'] ?? 0);
}
public function getThumbnail(string $filePath, string $outputPath, string $time = '00:00:01'): bool
{
$config = require __DIR__ . '/../../config/app.php';
$cmd = sprintf(
'%s -y -i %s -ss %s -vframes 1 -vf scale=320:-1 %s 2>/dev/null',
$config['ffmpeg']['binary'],
escapeshellarg($filePath),
escapeshellarg($time),
escapeshellarg($outputPath)
);
exec($cmd, $output, $exitCode);
return $exitCode === 0;
}
private function parseProbeData(array $data): array
{
$result = [
'format' => $data['format']['format_long_name'] ?? 'Unknown',
'format_name' => $data['format']['format_name'] ?? '',
'duration' => (float)($data['format']['duration'] ?? 0),
'size' => (int)($data['format']['size'] ?? 0),
'bitrate' => (int)($data['format']['bit_rate'] ?? 0),
'streams' => [],
'video' => null,
'audio' => null,
];
foreach (($data['streams'] ?? []) as $stream) {
$type = $stream['codec_type'] ?? '';
$info = [
'index' => $stream['index'],
'type' => $type,
'codec' => $stream['codec_name'] ?? 'unknown',
'codec_long' => $stream['codec_long_name'] ?? '',
];
if ($type === 'video') {
$info['width'] = (int)($stream['width'] ?? 0);
$info['height'] = (int)($stream['height'] ?? 0);
$info['fps'] = $this->parseFps($stream['r_frame_rate'] ?? '0/1');
$info['pix_fmt'] = $stream['pix_fmt'] ?? '';
$info['bitrate'] = (int)($stream['bit_rate'] ?? 0);
$info['profile'] = $stream['profile'] ?? '';
$info['level'] = $stream['level'] ?? '';
if (!$result['video']) $result['video'] = $info;
} elseif ($type === 'audio') {
$info['sample_rate'] = (int)($stream['sample_rate'] ?? 0);
$info['channels'] = (int)($stream['channels'] ?? 0);
$info['channel_layout'] = $stream['channel_layout'] ?? '';
$info['bitrate'] = (int)($stream['bit_rate'] ?? 0);
if (!$result['audio']) $result['audio'] = $info;
}
$result['streams'][] = $info;
}
return $result;
}
private function parseFps(string $frac): float
{
$parts = explode('/', $frac);
if (count($parts) === 2 && (int)$parts[1] > 0) {
return round((int)$parts[0] / (int)$parts[1], 2);
}
return (float)$frac;
}
}
@@ -0,0 +1,115 @@
<?php
namespace VideoConverter\Queue;
class JobQueue
{
private string $queueFile;
private array $queue = [];
public function __construct()
{
$this->queueFile = __DIR__ . '/../../storage/temp/queue.json';
$this->load();
}
private function load(): void
{
if (file_exists($this->queueFile)) {
$this->queue = json_decode(file_get_contents($this->queueFile), true) ?: [];
}
}
private function save(): void
{
$dir = dirname($this->queueFile);
if (!is_dir($dir)) mkdir($dir, 0755, true);
file_put_contents($this->queueFile, json_encode($this->queue, JSON_PRETTY_PRINT));
}
public function enqueue(array $job): string
{
$id = bin2hex(random_bytes(8));
$job['queue_id'] = $id;
$job['queued_at'] = date('c');
$job['queue_status'] = 'waiting';
$job['priority'] = $job['priority'] ?? 5;
$this->queue[] = $job;
// Sort by priority (lower = higher priority)
usort($this->queue, fn($a, $b) => ($a['priority'] ?? 5) <=> ($b['priority'] ?? 5));
$this->save();
return $id;
}
public function dequeue(): ?array
{
foreach ($this->queue as &$job) {
if ($job['queue_status'] === 'waiting') {
$job['queue_status'] = 'processing';
$job['started_at'] = date('c');
$this->save();
return $job;
}
}
return null;
}
public function complete(string $queueId, array $result = []): void
{
foreach ($this->queue as &$job) {
if ($job['queue_id'] === $queueId) {
$job['queue_status'] = 'completed';
$job['completed_at'] = date('c');
$job['result'] = $result;
break;
}
}
$this->save();
}
public function fail(string $queueId, string $error): void
{
foreach ($this->queue as &$job) {
if ($job['queue_id'] === $queueId) {
$job['queue_status'] = 'failed';
$job['failed_at'] = date('c');
$job['error'] = $error;
break;
}
}
$this->save();
}
public function getQueue(): array { return $this->queue; }
public function getWaiting(): array
{
return array_values(array_filter($this->queue, fn($j) => $j['queue_status'] === 'waiting'));
}
public function getProcessing(): array
{
return array_values(array_filter($this->queue, fn($j) => $j['queue_status'] === 'processing'));
}
public function clear(string $status = 'completed'): int
{
$before = count($this->queue);
$this->queue = array_values(array_filter($this->queue, fn($j) => $j['queue_status'] !== $status));
$this->save();
return $before - count($this->queue);
}
public function getStats(): array
{
$stats = ['waiting' => 0, 'processing' => 0, 'completed' => 0, 'failed' => 0];
foreach ($this->queue as $job) {
$status = $job['queue_status'] ?? 'waiting';
$stats[$status] = ($stats[$status] ?? 0) + 1;
}
return $stats;
}
}
@@ -0,0 +1,197 @@
<?php
namespace VideoConverter\Stream;
use VideoConverter\Process\FFmpegProcess;
class StreamManager
{
private array $activeStreams = [];
private string $stateFile;
public function __construct()
{
$this->stateFile = __DIR__ . '/../../storage/temp/streams.json';
$this->load();
}
private function load(): void
{
if (file_exists($this->stateFile)) {
$this->activeStreams = json_decode(file_get_contents($this->stateFile), true) ?: [];
}
}
private function save(): void
{
$dir = dirname($this->stateFile);
if (!is_dir($dir)) mkdir($dir, 0755, true);
file_put_contents($this->stateFile, json_encode($this->activeStreams, JSON_PRETTY_PRINT));
}
public function startStream(array $params): array
{
$id = bin2hex(random_bytes(8));
$config = require __DIR__ . '/../../config/app.php';
$inputUrl = $params['input_url'] ?? '';
$outputFormat = $params['output_format'] ?? 'mp4';
$resolution = $params['resolution'] ?? null;
$preset = $params['preset'] ?? 'fast';
$formatConfig = $config['formats']['video'][$outputFormat] ?? $config['formats']['video']['mp4'];
$presetConfig = $config['presets'][$preset] ?? $config['presets']['fast'];
$outputDir = $config['storage']['outputs'];
$outputFile = "{$outputDir}/stream_{$id}.{$formatConfig['ext']}";
$cmd = $config['ffmpeg']['binary'] . " -y";
// Input
if (str_starts_with($inputUrl, 'rtmp://') || str_starts_with($inputUrl, 'rtsp://')) {
$cmd .= " -re";
}
$cmd .= " -i " . escapeshellarg($inputUrl);
// Video codec
$cmd .= " -c:v " . escapeshellarg($formatConfig['codec']);
$cmd .= " -preset " . escapeshellarg($presetConfig['preset']);
$cmd .= " -crf " . (int)$presetConfig['crf'];
// Resolution
if ($resolution && isset($config['resolutions'][$resolution])) {
$res = $config['resolutions'][$resolution];
$cmd .= " -vf scale={$res['width']}:{$res['height']}";
}
// Audio
$audioCodec = $params['audio_codec'] ?? 'aac';
$audioBitrate = $params['audio_bitrate'] ?? '128k';
$cmd .= " -c:a " . escapeshellarg($audioCodec);
$cmd .= " -b:a " . escapeshellarg($audioBitrate);
$cmd .= " " . escapeshellarg($outputFile);
$process = new FFmpegProcess($cmd, $id);
$stream = [
'id' => $id,
'input_url' => $inputUrl,
'output_file' => $outputFile,
'output_format' => $outputFormat,
'resolution' => $resolution,
'preset' => $preset,
'status' => 'starting',
'pid' => null,
'command' => $cmd,
'started_at' => date('c'),
];
if ($process->start()) {
$stream['status'] = 'running';
$stream['pid'] = $process->getPid();
} else {
$stream['status'] = 'error';
}
$this->activeStreams[$id] = $stream;
$this->save();
return $stream;
}
public function stopStream(string $id): bool
{
if (!isset($this->activeStreams[$id])) return false;
$stream = $this->activeStreams[$id];
if ($stream['pid']) {
posix_kill($stream['pid'], SIGTERM);
usleep(500000);
if (posix_kill($stream['pid'], 0)) {
posix_kill($stream['pid'], SIGKILL);
}
}
$this->activeStreams[$id]['status'] = 'stopped';
$this->activeStreams[$id]['stopped_at'] = date('c');
$this->save();
return true;
}
public function switchFormat(string $id, string $newFormat, ?string $resolution = null): array
{
if (!isset($this->activeStreams[$id])) {
return ['error' => 'Stream not found'];
}
$oldStream = $this->activeStreams[$id];
$this->stopStream($id);
// Start new stream with same input but different output format
return $this->startStream([
'input_url' => $oldStream['input_url'],
'output_format' => $newFormat,
'resolution' => $resolution ?? $oldStream['resolution'],
'preset' => $oldStream['preset'],
'audio_codec' => 'aac',
]);
}
public function getStream(string $id): ?array
{
$this->refreshStatus($id);
return $this->activeStreams[$id] ?? null;
}
public function getAllStreams(): array
{
foreach (array_keys($this->activeStreams) as $id) {
$this->refreshStatus($id);
}
return array_values($this->activeStreams);
}
public function getActiveStreams(): array
{
return array_values(array_filter($this->getAllStreams(), fn($s) => $s['status'] === 'running'));
}
private function refreshStatus(string $id): void
{
if (!isset($this->activeStreams[$id])) return;
$stream = &$this->activeStreams[$id];
if ($stream['status'] === 'running' && $stream['pid']) {
if (!posix_kill($stream['pid'], 0)) {
$stream['status'] = 'completed';
$stream['completed_at'] = date('c');
$this->save();
}
}
}
public function deleteStream(string $id): bool
{
if (isset($this->activeStreams[$id])) {
if ($this->activeStreams[$id]['status'] === 'running') {
$this->stopStream($id);
}
unset($this->activeStreams[$id]);
$this->save();
return true;
}
return false;
}
public function getStats(): array
{
$all = $this->getAllStreams();
return [
'total' => count($all),
'running' => count(array_filter($all, fn($s) => $s['status'] === 'running')),
'completed' => count(array_filter($all, fn($s) => $s['status'] === 'completed')),
'errors' => count(array_filter($all, fn($s) => $s['status'] === 'error')),
];
}
}
@@ -0,0 +1,182 @@
<?php
namespace VideoConverter\WebSocket;
use Ratchet\MessageComponentInterface;
use Ratchet\ConnectionInterface;
use VideoConverter\Format\FormatConverter;
use VideoConverter\Stream\StreamManager;
use VideoConverter\Pipeline\PipelineManager;
use VideoConverter\Queue\JobQueue;
class StatusServer implements MessageComponentInterface
{
protected \SplObjectStorage $clients;
private FormatConverter $converter;
private StreamManager $streamManager;
private PipelineManager $pipelineManager;
private JobQueue $queue;
public function __construct()
{
$this->clients = new \SplObjectStorage();
$this->converter = new FormatConverter();
$this->streamManager = new StreamManager();
$this->pipelineManager = new PipelineManager();
$this->queue = new JobQueue();
}
public function onOpen(ConnectionInterface $conn): void
{
$this->clients->attach($conn);
$conn->send(json_encode([
'type' => 'connected',
'message' => 'Connected to Video Converter Suite',
'client_id' => spl_object_id($conn),
]));
}
public function onMessage(ConnectionInterface $from, $msg): void
{
$data = json_decode($msg, true);
if (!$data || !isset($data['action'])) return;
$response = match ($data['action']) {
'get_status' => $this->getFullStatus(),
'get_jobs' => ['type' => 'jobs', 'data' => $this->converter->getAllJobs()],
'get_streams' => ['type' => 'streams', 'data' => $this->streamManager->getAllStreams()],
'get_pipelines' => ['type' => 'pipelines', 'data' => $this->pipelineManager->toArray()],
'get_queue' => ['type' => 'queue', 'data' => $this->queue->getQueue()],
'get_progress' => $this->getJobProgress($data['job_id'] ?? ''),
'start_stream' => $this->handleStartStream($data),
'stop_stream' => $this->handleStopStream($data['stream_id'] ?? ''),
'switch_format' => $this->handleSwitchFormat($data),
default => ['type' => 'error', 'message' => 'Unknown action'],
};
$from->send(json_encode($response));
}
public function onClose(ConnectionInterface $conn): void
{
$this->clients->detach($conn);
}
public function onError(ConnectionInterface $conn, \Exception $e): void
{
$conn->send(json_encode([
'type' => 'error',
'message' => $e->getMessage(),
]));
$conn->close();
}
public function broadcastStatus(): void
{
$status = $this->getFullStatus();
$json = json_encode($status);
foreach ($this->clients as $client) {
$client->send($json);
}
}
private function getFullStatus(): array
{
// Reload state
$this->converter = new FormatConverter();
$this->streamManager = new StreamManager();
$this->pipelineManager = new PipelineManager();
$this->queue = new JobQueue();
$jobs = $this->converter->getAllJobs();
$runningJobs = array_filter($jobs, fn($j) => $j['status'] === 'running');
$progressData = [];
foreach ($runningJobs as $job) {
$progressData[$job['id']] = $this->converter->getProgress($job['id']);
}
return [
'type' => 'status',
'timestamp' => date('c'),
'system' => $this->getSystemStats(),
'jobs' => $jobs,
'progress' => $progressData,
'streams' => $this->streamManager->getAllStreams(),
'pipelines' => $this->pipelineManager->toArray(),
'queue' => $this->queue->getStats(),
];
}
private function getJobProgress(string $jobId): array
{
$this->converter = new FormatConverter();
return [
'type' => 'progress',
'job_id' => $jobId,
'data' => $this->converter->getProgress($jobId),
];
}
private function handleStartStream(array $data): array
{
$this->streamManager = new StreamManager();
$result = $this->streamManager->startStream($data);
return ['type' => 'stream_started', 'data' => $result];
}
private function handleStopStream(string $streamId): array
{
$this->streamManager = new StreamManager();
$success = $this->streamManager->stopStream($streamId);
return ['type' => 'stream_stopped', 'success' => $success, 'stream_id' => $streamId];
}
private function handleSwitchFormat(array $data): array
{
$this->streamManager = new StreamManager();
$result = $this->streamManager->switchFormat(
$data['stream_id'] ?? '',
$data['format'] ?? 'mp4',
$data['resolution'] ?? null
);
return ['type' => 'format_switched', 'data' => $result];
}
private function getSystemStats(): array
{
$load = sys_getloadavg();
$memInfo = $this->getMemoryInfo();
return [
'cpu_load' => $load[0] ?? 0,
'memory_used' => $memInfo['used'] ?? 0,
'memory_total' => $memInfo['total'] ?? 0,
'memory_percent' => $memInfo['percent'] ?? 0,
'disk_free' => disk_free_space('/'),
'disk_total' => disk_total_space('/'),
'uptime' => (int)(file_exists('/proc/uptime')
? (float)explode(' ', file_get_contents('/proc/uptime'))[0]
: 0),
];
}
private function getMemoryInfo(): array
{
if (!file_exists('/proc/meminfo')) {
return ['total' => 0, 'used' => 0, 'percent' => 0];
}
$content = file_get_contents('/proc/meminfo');
preg_match('/MemTotal:\s+(\d+)/', $content, $total);
preg_match('/MemAvailable:\s+(\d+)/', $content, $available);
$t = (int)($total[1] ?? 0) * 1024;
$a = (int)($available[1] ?? 0) * 1024;
$u = $t - $a;
return [
'total' => $t,
'used' => $u,
'percent' => $t > 0 ? round(($u / $t) * 100, 1) : 0,
];
}
}
@@ -0,0 +1,447 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= $config['app_name'] ?> - Control Panel</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;600;700;900&family=JetBrains+Mono:wght@300;400;500;600;700&family=Rajdhani:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/controlpanel.css">
</head>
<body>
<!-- ====== TOP BAR ====== -->
<div class="topbar">
<div class="topbar-logo">
<div class="reactor-icon">&#9762;</div>
<div>
<div class="topbar-title">Video Converter Suite</div>
<div class="topbar-subtitle">Pipeline Control System v<?= $config['version'] ?></div>
</div>
</div>
<div class="topbar-status">
<div class="status-indicator">
<span class="status-dot" id="systemStatusDot"></span>
<span id="systemStatusText">SYSTEM ONLINE</span>
</div>
<div class="status-indicator">
<span>JOBS:</span>
<span id="activeJobCount" style="color: var(--accent-cyan)">0</span>
</div>
<div class="clock" id="systemClock">00:00:00</div>
</div>
</div>
<!-- ====== NAVIGATION ====== -->
<div class="nav-bar">
<button class="nav-tab active" data-page="dashboard">Dashboard</button>
<button class="nav-tab" data-page="converter">Konverter</button>
<button class="nav-tab" data-page="streams">Live Streams</button>
<button class="nav-tab" data-page="pipelines">Pipelines</button>
<button class="nav-tab" data-page="queue">Warteschlange</button>
</div>
<!-- ====== PAGES ====== -->
<div class="main-container">
<!-- ==================== DASHBOARD PAGE ==================== -->
<div id="page-dashboard" class="page-content">
<!-- System Gauges -->
<div class="panel-row cols-1">
<div class="module">
<div class="module-header">
<div class="module-title"><span class="icon">&#9881;</span> SYSTEM MONITOR</div>
<button class="btn btn-icon" onclick="refreshStatus()" data-tooltip="Refresh">&#8635;</button>
</div>
<div class="module-body">
<div class="gauge-grid">
<div class="gauge">
<div class="gauge-label">CPU Load</div>
<div class="gauge-value" id="gaugeCpu">0.0</div>
<div class="gauge-unit">Load Avg</div>
<div class="gauge-bar"><div class="gauge-bar-fill" id="gaugeCpuBar" style="width:0%"></div></div>
</div>
<div class="gauge">
<div class="gauge-label">Memory</div>
<div class="gauge-value" id="gaugeMem">0</div>
<div class="gauge-unit">% Used</div>
<div class="gauge-bar"><div class="gauge-bar-fill" id="gaugeMemBar" style="width:0%"></div></div>
</div>
<div class="gauge">
<div class="gauge-label">Disk</div>
<div class="gauge-value" id="gaugeDisk">0</div>
<div class="gauge-unit">GB Free</div>
<div class="gauge-bar"><div class="gauge-bar-fill" id="gaugeDiskBar" style="width:0%"></div></div>
</div>
<div class="gauge">
<div class="gauge-label">Active Jobs</div>
<div class="gauge-value" id="gaugeJobs">0</div>
<div class="gauge-unit">Running</div>
<div class="gauge-bar"><div class="gauge-bar-fill" id="gaugeJobsBar" style="width:0%"></div></div>
</div>
</div>
</div>
</div>
</div>
<!-- Active Jobs & Log -->
<div class="panel-row cols-2-1">
<div class="module">
<div class="module-header">
<div class="module-title"><span class="icon">&#9654;</span> AKTIVE JOBS</div>
<button class="btn" onclick="refreshJobs()">Aktualisieren</button>
</div>
<div class="module-body">
<div class="job-list" id="jobList">
<div style="text-align:center; color: var(--text-dim); padding: 20px;">
Keine aktiven Jobs
</div>
</div>
</div>
</div>
<div class="module">
<div class="module-header">
<div class="module-title"><span class="icon">&#9783;</span> SYSTEM LOG</div>
<button class="btn btn-icon" onclick="clearLog()">&#10005;</button>
</div>
<div class="module-body">
<div class="log-console" id="logConsole">
<div class="log-line info"><span class="log-time">[INIT]</span> Video Converter Suite gestartet</div>
</div>
</div>
</div>
</div>
</div>
<!-- ==================== CONVERTER PAGE ==================== -->
<div id="page-converter" class="page-content" style="display:none">
<div class="panel-row cols-2">
<!-- Upload & Input -->
<div class="module">
<div class="module-header">
<div class="module-title"><span class="icon">&#8682;</span> EINGANG / UPLOAD</div>
</div>
<div class="module-body">
<div class="upload-zone" id="uploadZone" onclick="document.getElementById('fileInput').click()">
<div class="upload-icon">&#128193;</div>
<div class="upload-text">Datei hierher ziehen oder klicken</div>
<div class="upload-hint">Alle Video- und Audio-Formate / max. 5 GB</div>
<input type="file" id="fileInput" accept="video/*,audio/*" style="display:none" onchange="handleFileSelect(event)">
</div>
<div id="uploadedFileInfo" style="display:none; margin-top:12px;"></div>
</div>
</div>
<!-- Format Switchboard -->
<div class="module">
<div class="module-header">
<div class="module-title"><span class="icon">&#9881;</span> AUSGANGSFORMAT</div>
</div>
<div class="module-body">
<div class="form-label" style="margin-bottom:8px">Video-Formate</div>
<div class="format-matrix" id="videoFormatMatrix">
<?php foreach ($config['formats']['video'] as $key => $fmt): ?>
<div class="format-switch <?= $key === 'mp4' ? 'selected' : '' ?>" data-format="<?= $key ?>" onclick="selectFormat('<?= $key ?>')">
<div class="format-name"><?= strtoupper($key) ?></div>
<div class="format-desc"><?= $fmt['codec'] ?></div>
</div>
<?php endforeach; ?>
</div>
<div class="form-label" style="margin:12px 0 8px">Audio-Formate</div>
<div class="format-matrix" id="audioFormatMatrix">
<?php foreach ($config['formats']['audio'] as $key => $fmt): ?>
<div class="format-switch" data-format="<?= $key ?>" onclick="selectFormat('<?= $key ?>')">
<div class="format-name"><?= strtoupper($key) ?></div>
<div class="format-desc"><?= $fmt['codec'] ?></div>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
</div>
<!-- Settings Row -->
<div class="panel-row cols-3">
<!-- Preset -->
<div class="module">
<div class="module-header">
<div class="module-title"><span class="icon">&#9733;</span> PRESET</div>
</div>
<div class="module-body">
<div class="switch-panel" id="presetPanel">
<?php foreach ($config['presets'] as $key => $p): ?>
<div class="switch-unit <?= $key === 'balanced' ? 'active' : '' ?>" data-preset="<?= $key ?>" onclick="selectPreset('<?= $key ?>')">
<div class="switch-led"></div>
<div class="switch-label"><?= ucfirst($key) ?></div>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<!-- Resolution -->
<div class="module">
<div class="module-header">
<div class="module-title"><span class="icon">&#9634;</span> AUFLOESUNG</div>
</div>
<div class="module-body">
<div class="switch-panel" id="resolutionPanel">
<div class="switch-unit active" data-resolution="original" onclick="selectResolution('original')">
<div class="switch-led"></div>
<div class="switch-label">Original</div>
</div>
<?php foreach ($config['resolutions'] as $key => $res): ?>
<div class="switch-unit" data-resolution="<?= $key ?>" onclick="selectResolution('<?= $key ?>')">
<div class="switch-led"></div>
<div class="switch-label"><?= $key ?></div>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<!-- Controls -->
<div class="module">
<div class="module-header">
<div class="module-title"><span class="icon">&#9655;</span> STEUERUNG</div>
</div>
<div class="module-body" style="display:flex; flex-direction:column; gap:12px; align-items:center; justify-content:center; min-height:140px;">
<button class="btn btn-primary btn-large" id="btnStartConvert" onclick="startConversion()" disabled>
&#9654; KONVERTIERUNG STARTEN
</button>
<button class="btn btn-emergency" id="btnStopAll" onclick="stopAllJobs()" style="display:none">
&#9632; NOTAUS - ALLE STOPPEN
</button>
<div id="conversionStatus" style="font-size:11px; color:var(--text-dim); text-align:center;"></div>
</div>
</div>
</div>
</div>
<!-- ==================== STREAMS PAGE ==================== -->
<div id="page-streams" class="page-content" style="display:none">
<div class="panel-row cols-1">
<div class="module">
<div class="module-header">
<div class="module-title"><span class="icon">&#128225;</span> LIVE STREAM STEUERUNG</div>
<button class="btn btn-primary" onclick="openStreamModal()">+ Neuer Stream</button>
</div>
<div class="module-body">
<div class="stream-matrix" id="streamMatrix">
<div style="text-align:center; color:var(--text-dim); padding:40px; grid-column: 1/-1;">
Keine aktiven Streams. Klicke "Neuer Stream" um zu beginnen.
</div>
</div>
</div>
</div>
</div>
<!-- Stream Format Switchboard -->
<div class="panel-row cols-1">
<div class="module">
<div class="module-header">
<div class="module-title"><span class="icon">&#9762;</span> FORMAT-UMSCHALTER (LIVE)</div>
<div style="font-size:10px; color:var(--accent-yellow)">&#9888; Format-Wechsel unterbricht den Stream kurz</div>
</div>
<div class="module-body">
<div style="display:grid; grid-template-columns: 200px 1fr; gap: 16px; align-items:start;">
<div>
<div class="form-label">Aktiver Stream</div>
<select class="form-select" id="activeStreamSelect" onchange="selectActiveStream(this.value)">
<option value="">-- Stream wählen --</option>
</select>
</div>
<div>
<div class="form-label">Zielformat wählen (klick = sofort umschalten)</div>
<div class="format-matrix" id="streamFormatSwitchboard">
<?php foreach ($config['formats']['video'] as $key => $fmt): ?>
<div class="format-switch" data-stream-format="<?= $key ?>" onclick="switchStreamFormat('<?= $key ?>')">
<div class="format-name"><?= strtoupper($key) ?></div>
<div class="format-desc"><?= $fmt['codec'] ?></div>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ==================== PIPELINES PAGE ==================== -->
<div id="page-pipelines" class="page-content" style="display:none">
<div class="panel-row cols-1">
<div class="module">
<div class="module-header">
<div class="module-title"><span class="icon">&#9776;</span> PIPELINE DESIGNER</div>
<div style="display:flex; gap:8px;">
<button class="btn btn-primary" onclick="createPipeline()">+ Neue Pipeline</button>
</div>
</div>
<div class="module-body">
<!-- Pipeline List -->
<div id="pipelineList" style="margin-bottom:16px;"></div>
<!-- Pipeline Editor -->
<div id="pipelineEditor" style="display:none;">
<div class="form-label" style="margin-bottom:8px;">PIPELINE FLOW - Drag & Drop zum Umordnen</div>
<div class="pipeline-canvas">
<div class="pipeline-flow" id="pipelineFlow">
<!-- Input node (always present) -->
<div class="pipeline-node active">
<div class="node-type">Input</div>
<div class="node-name">Source</div>
<div class="node-status"></div>
</div>
</div>
</div>
<!-- Stage Palette -->
<div class="form-label" style="margin:12px 0 8px;">VERFUEGBARE STUFEN - Klick zum Hinzufügen</div>
<div class="switch-panel" id="stagePalette">
<div class="switch-unit" onclick="addPipelineStage('transcode')">
<div class="switch-led"></div>
<div class="switch-label">Transcode</div>
</div>
<div class="switch-unit" onclick="addPipelineStage('scale')">
<div class="switch-led"></div>
<div class="switch-label">Scale</div>
</div>
<div class="switch-unit" onclick="addPipelineStage('filter')">
<div class="switch-led"></div>
<div class="switch-label">Filter</div>
</div>
<div class="switch-unit" onclick="addPipelineStage('audio')">
<div class="switch-led"></div>
<div class="switch-label">Audio</div>
</div>
<div class="switch-unit" onclick="addPipelineStage('bitrate')">
<div class="switch-led"></div>
<div class="switch-label">Bitrate</div>
</div>
<div class="switch-unit" onclick="addPipelineStage('framerate')">
<div class="switch-led"></div>
<div class="switch-label">FPS</div>
</div>
<div class="switch-unit" onclick="addPipelineStage('trim')">
<div class="switch-led"></div>
<div class="switch-label">Trim</div>
</div>
<div class="switch-unit" onclick="addPipelineStage('deinterlace')">
<div class="switch-led"></div>
<div class="switch-label">Deinterlace</div>
</div>
<div class="switch-unit" onclick="addPipelineStage('denoise')">
<div class="switch-led"></div>
<div class="switch-label">Denoise</div>
</div>
<div class="switch-unit" onclick="addPipelineStage('stabilize')">
<div class="switch-led"></div>
<div class="switch-label">Stabilize</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ==================== QUEUE PAGE ==================== -->
<div id="page-queue" class="page-content" style="display:none">
<div class="panel-row cols-3-1">
<div class="module">
<div class="module-header">
<div class="module-title"><span class="icon">&#9776;</span> WARTESCHLANGE</div>
<div style="display:flex; gap:8px;">
<button class="btn" onclick="refreshQueue()">Aktualisieren</button>
<button class="btn btn-danger" onclick="clearQueue()">Queue leeren</button>
</div>
</div>
<div class="module-body">
<div class="job-list" id="queueList">
<div style="text-align:center; color: var(--text-dim); padding: 20px;">
Warteschlange ist leer
</div>
</div>
</div>
</div>
<div class="module">
<div class="module-header">
<div class="module-title">STATISTIK</div>
</div>
<div class="module-body">
<div style="display:flex; flex-direction:column; gap:12px;">
<div class="gauge">
<div class="gauge-label">Wartend</div>
<div class="gauge-value" id="queueWaiting">0</div>
</div>
<div class="gauge">
<div class="gauge-label">Verarbeitet</div>
<div class="gauge-value" id="queueProcessing">0</div>
</div>
<div class="gauge">
<div class="gauge-label">Abgeschlossen</div>
<div class="gauge-value" id="queueCompleted" style="color:var(--accent-green)">0</div>
</div>
<div class="gauge">
<div class="gauge-label">Fehlgeschlagen</div>
<div class="gauge-value" id="queueFailed" style="color:var(--accent-red)">0</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ====== STREAM MODAL ====== -->
<div class="modal-overlay" id="streamModal">
<div class="modal">
<div class="modal-header">
<h3>Neuen Stream starten</h3>
<button class="modal-close" onclick="closeStreamModal()">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label">Stream-URL (RTMP, RTSP, HTTP, Datei)</label>
<input class="form-input" type="text" id="streamInputUrl" placeholder="rtmp://server/live/stream oder /pfad/zur/datei.mp4">
</div>
<div class="form-group">
<label class="form-label">Ausgangsformat</label>
<select class="form-select" id="streamOutputFormat">
<?php foreach ($config['formats']['video'] as $key => $fmt): ?>
<option value="<?= $key ?>"><?= strtoupper($key) ?> (<?= $fmt['codec'] ?>)</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">Aufloesung</label>
<select class="form-select" id="streamResolution">
<option value="">Original</option>
<?php foreach ($config['resolutions'] as $key => $res): ?>
<option value="<?= $key ?>"><?= $res['label'] ?> (<?= $res['width'] ?>x<?= $res['height'] ?>)</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">Preset</label>
<select class="form-select" id="streamPreset">
<?php foreach ($config['presets'] as $key => $p): ?>
<option value="<?= $key ?>" <?= $key === 'fast' ? 'selected' : '' ?>><?= ucfirst($key) ?> (CRF <?= $p['crf'] ?>)</option>
<?php endforeach; ?>
</select>
</div>
</div>
<div class="modal-footer">
<button class="btn" onclick="closeStreamModal()">Abbrechen</button>
<button class="btn btn-primary" onclick="startNewStream()">&#9654; Stream starten</button>
</div>
</div>
</div>
<!-- ====== NOTIFICATION CONTAINER ====== -->
<div id="notification" class="notification"></div>
<script src="/js/controlpanel.js"></script>
</body>
</html>