diff --git a/video-converter-suite/.gitignore b/video-converter-suite/.gitignore new file mode 100644 index 0000000..aee17c0 --- /dev/null +++ b/video-converter-suite/.gitignore @@ -0,0 +1,15 @@ +/vendor/ +/storage/uploads/* +/storage/outputs/* +/storage/thumbnails/* +/storage/logs/* +/storage/temp/* +!storage/uploads/.gitkeep +!storage/outputs/.gitkeep +!storage/thumbnails/.gitkeep +!storage/logs/.gitkeep +!storage/temp/.gitkeep +.env +*.swp +*.swo +.DS_Store diff --git a/video-converter-suite/Dockerfile b/video-converter-suite/Dockerfile new file mode 100644 index 0000000..9e793ac --- /dev/null +++ b/video-converter-suite/Dockerfile @@ -0,0 +1,40 @@ +FROM php:8.2-cli + +# Install FFmpeg and dependencies +RUN apt-get update && apt-get install -y \ + ffmpeg \ + libzip-dev \ + unzip \ + git \ + && docker-php-ext-install pcntl posix sockets \ + && rm -rf /var/lib/apt/lists/* + +# Install Composer +COPY --from=composer:latest /usr/bin/composer /usr/bin/composer + +WORKDIR /app + +# Copy composer files first for caching +COPY composer.json ./ +RUN composer install --no-dev --optimize-autoloader 2>/dev/null || true + +# Copy application +COPY . . + +# Install dependencies +RUN composer install --no-dev --optimize-autoloader + +# Create storage directories +RUN mkdir -p storage/uploads storage/outputs storage/thumbnails storage/logs storage/temp \ + && chmod -R 777 storage + +# Configure PHP +RUN echo "upload_max_filesize = 5G\n\ +post_max_size = 5G\n\ +memory_limit = 512M\n\ +max_execution_time = 3600\n\ +max_input_time = 3600" > /usr/local/etc/php/conf.d/video-converter.ini + +EXPOSE 8080 8081 + +CMD ["php", "-S", "0.0.0.0:8080", "-t", "public", "public/router.php"] diff --git a/video-converter-suite/bin/queue-worker.php b/video-converter-suite/bin/queue-worker.php new file mode 100644 index 0000000..3da4331 --- /dev/null +++ b/video-converter-suite/bin/queue-worker.php @@ -0,0 +1,68 @@ +#!/usr/bin/env php +getAllJobs(), fn($j) => $j['status'] === 'running'); + $running = count($activeJobs); + + if ($running < $config['limits']['max_concurrent_jobs']) { + $nextJob = $queue->dequeue(); + if ($nextJob) { + echo "[" . date('H:i:s') . "] Processing: {$nextJob['queue_id']}\n"; + + try { + $result = $converter->convert([ + 'input_file' => $nextJob['input_file'] ?? '', + 'output_format' => $nextJob['output_format'] ?? 'mp4', + 'preset' => $nextJob['preset'] ?? 'balanced', + 'resolution' => $nextJob['resolution'] ?? null, + ]); + + if (isset($result['error'])) { + $queue->fail($nextJob['queue_id'], $result['error']); + echo "[" . date('H:i:s') . "] Failed: {$result['error']}\n"; + } else { + $queue->complete($nextJob['queue_id'], $result); + echo "[" . date('H:i:s') . "] Started job: {$result['id']}\n"; + } + } catch (\Throwable $e) { + $queue->fail($nextJob['queue_id'], $e->getMessage()); + echo "[" . date('H:i:s') . "] Error: {$e->getMessage()}\n"; + } + } + } + + sleep(2); +} diff --git a/video-converter-suite/bin/start.sh b/video-converter-suite/bin/start.sh new file mode 100755 index 0000000..703616f --- /dev/null +++ b/video-converter-suite/bin/start.sh @@ -0,0 +1,101 @@ +#!/bin/bash + +# Video Converter Suite - Startup Script +# Starts all services: Web Server, WebSocket Server, Queue Worker + +echo "================================================" +echo " VIDEO CONVERTER SUITE - Starting Services" +echo "================================================" +echo "" + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$DIR" + +# Create storage directories +mkdir -p storage/{uploads,outputs,thumbnails,logs,temp} + +# Check FFmpeg +if command -v ffmpeg &> /dev/null; then + echo "[OK] FFmpeg: $(ffmpeg -version 2>&1 | head -1)" +else + echo "[!!] FFmpeg not found. Install with: apt install ffmpeg" + echo " The application will work but conversions will fail." +fi + +# Check PHP +if command -v php &> /dev/null; then + echo "[OK] PHP: $(php -v 2>&1 | head -1)" +else + echo "[!!] PHP not found." + exit 1 +fi + +# Install dependencies if needed +if [ ! -d "vendor" ]; then + echo "" + echo "Installing dependencies..." + if command -v composer &> /dev/null; then + composer install + else + echo "[!!] Composer not found. WebSocket server won't work." + echo " The web interface will still work without it." + fi +fi + +echo "" +echo "Starting services..." +echo "" + +# Start Web Server +echo "[1/3] Web Server on http://localhost:8080" +php -S 0.0.0.0:8080 -t public public/router.php \ + -d upload_max_filesize=5G \ + -d post_max_size=5G \ + -d memory_limit=512M \ + -d max_execution_time=3600 \ + > storage/logs/web.log 2>&1 & +WEB_PID=$! + +# Start WebSocket Server (optional, requires Ratchet) +if [ -f "vendor/autoload.php" ]; then + echo "[2/3] WebSocket Server on ws://localhost:8081" + php bin/websocket-server.php > storage/logs/websocket.log 2>&1 & + WS_PID=$! +else + echo "[2/3] WebSocket Server: SKIPPED (run composer install first)" + WS_PID="" +fi + +# Start Queue Worker +echo "[3/3] Queue Worker" +php bin/queue-worker.php > storage/logs/worker.log 2>&1 & +WORKER_PID=$! + +echo "" +echo "================================================" +echo " All services started!" +echo "" +echo " Web UI: http://localhost:8080" +echo " WebSocket: ws://localhost:8081" +echo "" +echo " PIDs: Web=$WEB_PID WS=$WS_PID Worker=$WORKER_PID" +echo " Logs: storage/logs/" +echo "" +echo " Press Ctrl+C to stop all services" +echo "================================================" + +# Trap exit to kill all processes +cleanup() { + echo "" + echo "Stopping all services..." + kill $WEB_PID 2>/dev/null + [ -n "$WS_PID" ] && kill $WS_PID 2>/dev/null + kill $WORKER_PID 2>/dev/null + echo "All services stopped." + exit 0 +} + +trap cleanup EXIT INT TERM + +# Wait for any process to exit +wait diff --git a/video-converter-suite/bin/websocket-server.php b/video-converter-suite/bin/websocket-server.php new file mode 100644 index 0000000..4338450 --- /dev/null +++ b/video-converter-suite/bin/websocket-server.php @@ -0,0 +1,41 @@ +#!/usr/bin/env php +loop->addPeriodicTimer(2, function () use ($statusServer) { + $statusServer->broadcastStatus(); +}); + +echo "WebSocket server running. Press Ctrl+C to stop.\n"; +$server->run(); diff --git a/video-converter-suite/composer.json b/video-converter-suite/composer.json new file mode 100644 index 0000000..e63738f --- /dev/null +++ b/video-converter-suite/composer.json @@ -0,0 +1,21 @@ +{ + "name": "videokonverter/suite", + "description": "Video Converter Suite - Live Stream Pipeline Control Panel", + "type": "project", + "require": { + "php": ">=8.1", + "cboden/ratchet": "^0.4", + "react/event-loop": "^1.4", + "react/child-process": "^0.6" + }, + "autoload": { + "psr-4": { + "VideoConverter\\": "src/" + } + }, + "scripts": { + "start": "php -S 0.0.0.0:8080 -t public public/router.php", + "websocket": "php bin/websocket-server.php", + "worker": "php bin/queue-worker.php" + } +} diff --git a/video-converter-suite/config/app.php b/video-converter-suite/config/app.php new file mode 100644 index 0000000..29019ed --- /dev/null +++ b/video-converter-suite/config/app.php @@ -0,0 +1,74 @@ + 'Video Converter Suite', + 'version' => '1.0.0', + 'debug' => true, + + 'ffmpeg' => [ + 'binary' => getenv('FFMPEG_PATH') ?: '/usr/bin/ffmpeg', + 'ffprobe' => getenv('FFPROBE_PATH') ?: '/usr/bin/ffprobe', + 'threads' => (int)(getenv('FFMPEG_THREADS') ?: 4), + 'timeout' => 3600, + 'nice' => 10, + ], + + 'storage' => [ + 'uploads' => __DIR__ . '/../storage/uploads', + 'outputs' => __DIR__ . '/../storage/outputs', + 'thumbnails' => __DIR__ . '/../storage/thumbnails', + 'logs' => __DIR__ . '/../storage/logs', + 'temp' => __DIR__ . '/../storage/temp', + ], + + 'limits' => [ + 'max_upload_size' => 5 * 1024 * 1024 * 1024, // 5 GB + 'max_concurrent_jobs' => 3, + 'max_pipeline_depth' => 10, + ], + + 'websocket' => [ + 'host' => '0.0.0.0', + 'port' => 8081, + ], + + 'formats' => [ + 'video' => [ + 'mp4' => ['codec' => 'libx264', 'ext' => 'mp4', 'mime' => 'video/mp4'], + 'webm' => ['codec' => 'libvpx-vp9', 'ext' => 'webm', 'mime' => 'video/webm'], + 'mkv' => ['codec' => 'libx264', 'ext' => 'mkv', 'mime' => 'video/x-matroska'], + 'avi' => ['codec' => 'mpeg4', 'ext' => 'avi', 'mime' => 'video/x-msvideo'], + 'mov' => ['codec' => 'libx264', 'ext' => 'mov', 'mime' => 'video/quicktime'], + 'flv' => ['codec' => 'flv1', 'ext' => 'flv', 'mime' => 'video/x-flv'], + 'wmv' => ['codec' => 'wmv2', 'ext' => 'wmv', 'mime' => 'video/x-ms-wmv'], + 'ts' => ['codec' => 'libx264', 'ext' => 'ts', 'mime' => 'video/mp2t'], + 'hls' => ['codec' => 'libx264', 'ext' => 'm3u8', 'mime' => 'application/x-mpegURL'], + 'dash' => ['codec' => 'libx264', 'ext' => 'mpd', 'mime' => 'application/dash+xml'], + ], + 'audio' => [ + 'aac' => ['codec' => 'aac', 'ext' => 'aac', 'mime' => 'audio/aac'], + 'mp3' => ['codec' => 'libmp3lame', 'ext' => 'mp3', 'mime' => 'audio/mpeg'], + 'ogg' => ['codec' => 'libvorbis', 'ext' => 'ogg', 'mime' => 'audio/ogg'], + 'wav' => ['codec' => 'pcm_s16le', 'ext' => 'wav', 'mime' => 'audio/wav'], + 'flac' => ['codec' => 'flac', 'ext' => 'flac', 'mime' => 'audio/flac'], + 'opus' => ['codec' => 'libopus', 'ext' => 'opus', 'mime' => 'audio/opus'], + ], + ], + + 'presets' => [ + 'ultrafast' => ['preset' => 'ultrafast', 'crf' => 28], + 'fast' => ['preset' => 'fast', 'crf' => 23], + 'balanced' => ['preset' => 'medium', 'crf' => 20], + 'quality' => ['preset' => 'slow', 'crf' => 18], + 'lossless' => ['preset' => 'veryslow', 'crf' => 0], + ], + + 'resolutions' => [ + '4k' => ['width' => 3840, 'height' => 2160, 'label' => '4K UHD'], + '1440p' => ['width' => 2560, 'height' => 1440, 'label' => '2K QHD'], + '1080p' => ['width' => 1920, 'height' => 1080, 'label' => 'Full HD'], + '720p' => ['width' => 1280, 'height' => 720, 'label' => 'HD'], + '480p' => ['width' => 854, 'height' => 480, 'label' => 'SD'], + '360p' => ['width' => 640, 'height' => 360, 'label' => 'Low'], + ], +]; diff --git a/video-converter-suite/docker-compose.yml b/video-converter-suite/docker-compose.yml new file mode 100644 index 0000000..230d2a3 --- /dev/null +++ b/video-converter-suite/docker-compose.yml @@ -0,0 +1,54 @@ +version: '3.8' + +services: + # Main Web Application + web: + build: . + ports: + - "8080:8080" + volumes: + - ./storage:/app/storage + - ./src:/app/src + - ./public:/app/public + - ./templates:/app/templates + - ./config:/app/config + environment: + - FFMPEG_PATH=/usr/bin/ffmpeg + - FFPROBE_PATH=/usr/bin/ffprobe + - FFMPEG_THREADS=4 + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/api/system"] + interval: 30s + timeout: 10s + retries: 3 + + # WebSocket Server for real-time updates + websocket: + build: . + command: php bin/websocket-server.php + ports: + - "8081:8081" + volumes: + - ./storage:/app/storage + - ./src:/app/src + - ./config:/app/config + depends_on: + - web + restart: unless-stopped + + # Queue Worker for batch processing + worker: + build: . + command: php bin/queue-worker.php + volumes: + - ./storage:/app/storage + - ./src:/app/src + - ./config:/app/config + environment: + - FFMPEG_PATH=/usr/bin/ffmpeg + - FFPROBE_PATH=/usr/bin/ffprobe + - FFMPEG_THREADS=2 + depends_on: + - web + restart: unless-stopped diff --git a/video-converter-suite/public/api.php b/video-converter-suite/public/api.php new file mode 100644 index 0000000..c6cfb1c --- /dev/null +++ b/video-converter-suite/public/api.php @@ -0,0 +1,409 @@ + handleSystem(), + + // Convert + $resource === 'convert' && $method === 'POST' => handleConvert($input), + $resource === 'convert' && $action === 'batch' && $method === 'POST' => handleBatchConvert($input), + $resource === 'upload' && $method === 'POST' => handleUpload(), + + // Jobs + $resource === 'jobs' && $method === 'GET' && !$id => handleGetJobs(), + $resource === 'jobs' && $method === 'GET' && $id && $action === 'progress' => handleJobProgress($id), + $resource === 'jobs' && $method === 'GET' && $id => handleGetJob($id), + $resource === 'jobs' && $method === 'DELETE' && $id => handleDeleteJob($id), + $resource === 'jobs' && $action === 'cancel' && $method === 'POST' => handleCancelJob($id), + + // Streams + $resource === 'streams' && $method === 'GET' && !$id => handleGetStreams(), + $resource === 'streams' && $method === 'POST' => handleStartStream($input), + $resource === 'streams' && $method === 'GET' && $id => handleGetStream($id), + $resource === 'streams' && $method === 'DELETE' && $id => handleStopStream($id), + $resource === 'streams' && $action === 'switch' && $method === 'POST' => handleSwitchFormat($id, $input), + + // Pipelines + $resource === 'pipelines' && $method === 'GET' && !$id => handleGetPipelines(), + $resource === 'pipelines' && $method === 'POST' => handleCreatePipeline($input), + $resource === 'pipelines' && $method === 'GET' && $id => handleGetPipeline($id), + $resource === 'pipelines' && $method === 'PUT' && $id => handleUpdatePipeline($id, $input), + $resource === 'pipelines' && $method === 'DELETE' && $id => handleDeletePipeline($id), + $resource === 'pipelines' && $action === 'run' && $method === 'POST' => handleRunPipeline($id, $input), + $resource === 'pipelines' && $action === 'stage' && $method === 'POST' => handleAddStage($id, $input), + + // Queue + $resource === 'queue' && $method === 'GET' => handleGetQueue(), + $resource === 'queue' && $method === 'POST' => handleEnqueue($input), + $resource === 'queue' && $method === 'DELETE' => handleClearQueue(), + + // Formats info + $resource === 'formats' && $method === 'GET' => handleGetFormats(), + $resource === 'presets' && $method === 'GET' => handleGetPresets(), + $resource === 'resolutions' && $method === 'GET' => handleGetResolutions(), + + // Probe + $resource === 'probe' && $method === 'POST' => handleProbe($input), + + // Downloads + $resource === 'download' && $method === 'GET' && $id => handleDownload($id), + + default => ['error' => 'Not found', 'status' => 404], + }; + + $status = $response['status'] ?? 200; + unset($response['status']); + http_response_code($status); + echo json_encode($response, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + +} catch (\Throwable $e) { + http_response_code(500); + echo json_encode(['error' => $e->getMessage()]); +} + +// ---- Handler Functions ---- + +function handleSystem(): array +{ + $load = sys_getloadavg(); + $config = require __DIR__ . '/../config/app.php'; + return [ + 'app' => $config['app_name'], + 'version' => $config['version'], + 'cpu_load' => $load, + 'memory' => [ + 'used' => memory_get_usage(true), + 'peak' => memory_get_peak_usage(true), + ], + 'disk' => [ + 'free' => disk_free_space('/'), + 'total' => disk_total_space('/'), + ], + 'php_version' => PHP_VERSION, + 'ffmpeg_available' => file_exists($config['ffmpeg']['binary']), + ]; +} + +function handleUpload(): array +{ + $config = require __DIR__ . '/../config/app.php'; + + if (empty($_FILES['file'])) { + return ['error' => 'No file uploaded', 'status' => 400]; + } + + $file = $_FILES['file']; + if ($file['error'] !== UPLOAD_ERR_OK) { + return ['error' => 'Upload error: ' . $file['error'], 'status' => 400]; + } + + if ($file['size'] > $config['limits']['max_upload_size']) { + return ['error' => 'File too large', 'status' => 400]; + } + + $uploadDir = $config['storage']['uploads']; + if (!is_dir($uploadDir)) mkdir($uploadDir, 0755, true); + + $ext = pathinfo($file['name'], PATHINFO_EXTENSION); + $safeName = bin2hex(random_bytes(8)) . '.' . $ext; + $destination = $uploadDir . '/' . $safeName; + + if (!move_uploaded_file($file['tmp_name'], $destination)) { + return ['error' => 'Failed to save file', 'status' => 500]; + } + + $probe = new \VideoConverter\Process\MediaProbe(); + $info = $probe->analyze($destination); + + // Generate thumbnail + $thumbDir = $config['storage']['thumbnails']; + if (!is_dir($thumbDir)) mkdir($thumbDir, 0755, true); + $thumbPath = $thumbDir . '/' . pathinfo($safeName, PATHINFO_FILENAME) . '.jpg'; + $probe->getThumbnail($destination, $thumbPath); + + return [ + 'file' => $safeName, + 'path' => $destination, + 'original_name' => $file['name'], + 'size' => $file['size'], + 'info' => $info, + 'thumbnail' => file_exists($thumbPath) ? '/api/thumbnail/' . pathinfo($safeName, PATHINFO_FILENAME) : null, + ]; +} + +function handleConvert(array $input): array +{ + $converter = new \VideoConverter\Format\FormatConverter(); + return $converter->convert($input); +} + +function handleBatchConvert(array $input): array +{ + $converter = new \VideoConverter\Format\FormatConverter(); + return $converter->batchConvert($input['input_file'] ?? '', $input['formats'] ?? []); +} + +function handleGetJobs(): array +{ + $converter = new \VideoConverter\Format\FormatConverter(); + return ['jobs' => $converter->getAllJobs()]; +} + +function handleGetJob(string $id): array +{ + $converter = new \VideoConverter\Format\FormatConverter(); + $job = $converter->getJob($id); + return $job ? $job : ['error' => 'Job not found', 'status' => 404]; +} + +function handleJobProgress(string $id): array +{ + $converter = new \VideoConverter\Format\FormatConverter(); + return $converter->getProgress($id); +} + +function handleCancelJob(string $id): array +{ + $converter = new \VideoConverter\Format\FormatConverter(); + return ['success' => $converter->cancelJob($id)]; +} + +function handleDeleteJob(string $id): array +{ + $converter = new \VideoConverter\Format\FormatConverter(); + return ['success' => $converter->deleteJob($id)]; +} + +function handleGetStreams(): array +{ + $mgr = new \VideoConverter\Stream\StreamManager(); + return ['streams' => $mgr->getAllStreams(), 'stats' => $mgr->getStats()]; +} + +function handleStartStream(array $input): array +{ + $mgr = new \VideoConverter\Stream\StreamManager(); + return $mgr->startStream($input); +} + +function handleGetStream(string $id): array +{ + $mgr = new \VideoConverter\Stream\StreamManager(); + $stream = $mgr->getStream($id); + return $stream ?: ['error' => 'Stream not found', 'status' => 404]; +} + +function handleStopStream(string $id): array +{ + $mgr = new \VideoConverter\Stream\StreamManager(); + return ['success' => $mgr->stopStream($id)]; +} + +function handleSwitchFormat(string $id, array $input): array +{ + $mgr = new \VideoConverter\Stream\StreamManager(); + return $mgr->switchFormat($id, $input['format'] ?? 'mp4', $input['resolution'] ?? null); +} + +function handleGetPipelines(): array +{ + $mgr = new \VideoConverter\Pipeline\PipelineManager(); + return ['pipelines' => $mgr->toArray()]; +} + +function handleCreatePipeline(array $input): array +{ + $mgr = new \VideoConverter\Pipeline\PipelineManager(); + $pipeline = $mgr->create($input['name'] ?? 'Unnamed Pipeline'); + + foreach (($input['stages'] ?? []) as $stageData) { + $stage = new \VideoConverter\Pipeline\PipelineStage( + $stageData['type'] ?? 'transcode', + $stageData['params'] ?? [], + $stageData['label'] ?? '', + $stageData['enabled'] ?? true + ); + $pipeline->addStage($stage); + } + + $mgr->save(); + return $pipeline->toArray(); +} + +function handleGetPipeline(string $id): array +{ + $mgr = new \VideoConverter\Pipeline\PipelineManager(); + $pipeline = $mgr->get($id); + return $pipeline ? $pipeline->toArray() : ['error' => 'Pipeline not found', 'status' => 404]; +} + +function handleUpdatePipeline(string $id, array $input): array +{ + $mgr = new \VideoConverter\Pipeline\PipelineManager(); + $pipeline = $mgr->get($id); + if (!$pipeline) return ['error' => 'Pipeline not found', 'status' => 404]; + + if (isset($input['stages'])) { + // Rebuild stages + $ref = new \ReflectionProperty($pipeline, 'stages'); + $ref->setAccessible(true); + $ref->setValue($pipeline, []); + + foreach ($input['stages'] as $stageData) { + $stage = \VideoConverter\Pipeline\PipelineStage::fromArray($stageData); + $pipeline->addStage($stage); + } + } + + $mgr->save(); + return $pipeline->toArray(); +} + +function handleDeletePipeline(string $id): array +{ + $mgr = new \VideoConverter\Pipeline\PipelineManager(); + return ['success' => $mgr->delete($id)]; +} + +function handleRunPipeline(string $id, array $input): array +{ + $mgr = new \VideoConverter\Pipeline\PipelineManager(); + $pipeline = $mgr->get($id); + if (!$pipeline) return ['error' => 'Pipeline not found', 'status' => 404]; + + $converter = new \VideoConverter\Format\FormatConverter(); + return $converter->convert([ + 'input_file' => $input['input_file'] ?? '', + 'output_format' => $input['output_format'] ?? 'mp4', + 'pipeline' => $pipeline, + ]); +} + +function handleAddStage(string $id, array $input): array +{ + $mgr = new \VideoConverter\Pipeline\PipelineManager(); + $pipeline = $mgr->get($id); + if (!$pipeline) return ['error' => 'Pipeline not found', 'status' => 404]; + + $stage = new \VideoConverter\Pipeline\PipelineStage( + $input['type'] ?? 'transcode', + $input['params'] ?? [], + $input['label'] ?? '', + $input['enabled'] ?? true + ); + $pipeline->addStage($stage); + $mgr->save(); + + return $pipeline->toArray(); +} + +function handleGetQueue(): array +{ + $queue = new \VideoConverter\Queue\JobQueue(); + return ['queue' => $queue->getQueue(), 'stats' => $queue->getStats()]; +} + +function handleEnqueue(array $input): array +{ + $queue = new \VideoConverter\Queue\JobQueue(); + $queueId = $queue->enqueue($input); + return ['queue_id' => $queueId, 'position' => count($queue->getWaiting())]; +} + +function handleClearQueue(): array +{ + $queue = new \VideoConverter\Queue\JobQueue(); + $cleared = $queue->clear(); + return ['cleared' => $cleared]; +} + +function handleGetFormats(): array +{ + $config = require __DIR__ . '/../config/app.php'; + return $config['formats']; +} + +function handleGetPresets(): array +{ + $config = require __DIR__ . '/../config/app.php'; + return $config['presets']; +} + +function handleGetResolutions(): array +{ + $config = require __DIR__ . '/../config/app.php'; + return $config['resolutions']; +} + +function handleProbe(array $input): array +{ + $probe = new \VideoConverter\Process\MediaProbe(); + return $probe->analyze($input['file'] ?? ''); +} + +function handleDownload(string $id): array +{ + $converter = new \VideoConverter\Format\FormatConverter(); + $job = $converter->getJob($id); + if (!$job || !isset($job['output_file']) || !file_exists($job['output_file'])) { + return ['error' => 'File not found', 'status' => 404]; + } + + header('Content-Type: application/octet-stream'); + header('Content-Disposition: attachment; filename="' . basename($job['output_file']) . '"'); + header('Content-Length: ' . filesize($job['output_file'])); + readfile($job['output_file']); + exit; +} diff --git a/video-converter-suite/public/css/controlpanel.css b/video-converter-suite/public/css/controlpanel.css new file mode 100644 index 0000000..8e9ccc4 --- /dev/null +++ b/video-converter-suite/public/css/controlpanel.css @@ -0,0 +1,1179 @@ +/* ============================================ + VIDEO CONVERTER SUITE - NUCLEAR CONTROL PANEL + Industrial / Power Plant Control Room Theme + ============================================ */ + +:root { + --bg-dark: #0a0e14; + --bg-panel: #111820; + --bg-module: #151c26; + --bg-inset: #0d1117; + --border-dark: #1e2a38; + --border-glow: #1a3a5c; + --text-primary: #c8d6e5; + --text-secondary: #6b7d8f; + --text-dim: #3d4f61; + --accent-blue: #0ea5e9; + --accent-cyan: #22d3ee; + --accent-green: #10b981; + --accent-yellow: #f59e0b; + --accent-orange: #f97316; + --accent-red: #ef4444; + --accent-purple: #8b5cf6; + --glow-blue: 0 0 10px rgba(14, 165, 233, 0.3); + --glow-green: 0 0 10px rgba(16, 185, 129, 0.3); + --glow-red: 0 0 10px rgba(239, 68, 68, 0.3); + --glow-yellow: 0 0 10px rgba(245, 158, 11, 0.3); + --font-mono: 'JetBrains Mono', 'Fira Code', 'SF Mono', 'Consolas', monospace; + --font-display: 'Orbitron', 'Rajdhani', sans-serif; +} + +* { margin: 0; padding: 0; box-sizing: border-box; } + +body { + font-family: var(--font-mono); + background: var(--bg-dark); + color: var(--text-primary); + min-height: 100vh; + overflow-x: hidden; +} + +/* Scanline overlay */ +body::after { + content: ''; + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + pointer-events: none; + background: repeating-linear-gradient( + 0deg, + transparent, + transparent 2px, + rgba(0, 0, 0, 0.03) 2px, + rgba(0, 0, 0, 0.03) 4px + ); + z-index: 9999; +} + +/* ---- TOP BAR ---- */ +.topbar { + background: linear-gradient(180deg, #0f1520 0%, #0a0e14 100%); + border-bottom: 1px solid var(--border-dark); + padding: 0 24px; + height: 56px; + display: flex; + align-items: center; + justify-content: space-between; + position: sticky; + top: 0; + z-index: 100; +} + +.topbar-logo { + display: flex; + align-items: center; + gap: 12px; +} + +.topbar-logo .reactor-icon { + width: 32px; + height: 32px; + border: 2px solid var(--accent-cyan); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + color: var(--accent-cyan); + box-shadow: var(--glow-blue); + animation: pulse-glow 2s ease-in-out infinite; +} + +@keyframes pulse-glow { + 0%, 100% { box-shadow: 0 0 5px rgba(34, 211, 238, 0.3); } + 50% { box-shadow: 0 0 20px rgba(34, 211, 238, 0.6); } +} + +.topbar-title { + font-family: var(--font-display); + font-size: 16px; + font-weight: 700; + letter-spacing: 2px; + text-transform: uppercase; + color: var(--text-primary); +} + +.topbar-subtitle { + font-size: 10px; + color: var(--text-secondary); + letter-spacing: 1px; +} + +.topbar-status { + display: flex; + align-items: center; + gap: 24px; +} + +.status-indicator { + display: flex; + align-items: center; + gap: 8px; + font-size: 11px; + color: var(--text-secondary); +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--accent-green); + box-shadow: var(--glow-green); + animation: blink 2s ease-in-out infinite; +} + +.status-dot.warning { background: var(--accent-yellow); box-shadow: var(--glow-yellow); } +.status-dot.error { background: var(--accent-red); box-shadow: var(--glow-red); animation: blink 0.5s ease-in-out infinite; } + +@keyframes blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +.clock { + font-family: var(--font-display); + font-size: 14px; + color: var(--accent-cyan); + letter-spacing: 2px; +} + +/* ---- NAVIGATION ---- */ +.nav-bar { + background: var(--bg-panel); + border-bottom: 1px solid var(--border-dark); + display: flex; + padding: 0 24px; + gap: 0; +} + +.nav-tab { + padding: 12px 20px; + font-size: 11px; + font-family: var(--font-mono); + letter-spacing: 1px; + text-transform: uppercase; + color: var(--text-secondary); + cursor: pointer; + border: none; + background: none; + border-bottom: 2px solid transparent; + transition: all 0.2s; + position: relative; +} + +.nav-tab:hover { + color: var(--text-primary); + background: rgba(14, 165, 233, 0.05); +} + +.nav-tab.active { + color: var(--accent-cyan); + border-bottom-color: var(--accent-cyan); + background: rgba(14, 165, 233, 0.08); +} + +.nav-tab .badge { + position: absolute; + top: 6px; + right: 6px; + background: var(--accent-red); + color: white; + font-size: 9px; + padding: 1px 5px; + border-radius: 8px; + min-width: 16px; + text-align: center; +} + +/* ---- MAIN LAYOUT ---- */ +.main-container { + display: grid; + grid-template-columns: 1fr; + gap: 16px; + padding: 16px 24px; + max-width: 1800px; + margin: 0 auto; +} + +.panel-row { + display: grid; + gap: 16px; +} + +.panel-row.cols-2 { grid-template-columns: 1fr 1fr; } +.panel-row.cols-3 { grid-template-columns: 1fr 1fr 1fr; } +.panel-row.cols-4 { grid-template-columns: 1fr 1fr 1fr 1fr; } +.panel-row.cols-2-1 { grid-template-columns: 2fr 1fr; } +.panel-row.cols-1-2 { grid-template-columns: 1fr 2fr; } +.panel-row.cols-3-1 { grid-template-columns: 3fr 1fr; } + +/* ---- MODULE / PANEL ---- */ +.module { + background: var(--bg-module); + border: 1px solid var(--border-dark); + border-radius: 4px; + overflow: hidden; +} + +.module-header { + background: linear-gradient(180deg, rgba(30, 42, 56, 0.5) 0%, transparent 100%); + padding: 10px 16px; + border-bottom: 1px solid var(--border-dark); + display: flex; + align-items: center; + justify-content: space-between; +} + +.module-title { + font-family: var(--font-display); + font-size: 11px; + font-weight: 600; + letter-spacing: 2px; + text-transform: uppercase; + color: var(--text-secondary); + display: flex; + align-items: center; + gap: 8px; +} + +.module-title .icon { + color: var(--accent-cyan); + font-size: 14px; +} + +.module-body { + padding: 16px; +} + +.module-footer { + padding: 8px 16px; + border-top: 1px solid var(--border-dark); + font-size: 10px; + color: var(--text-dim); + display: flex; + justify-content: space-between; +} + +/* ---- SYSTEM GAUGES ---- */ +.gauge-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 12px; +} + +.gauge { + background: var(--bg-inset); + border: 1px solid var(--border-dark); + border-radius: 4px; + padding: 12px; + text-align: center; +} + +.gauge-label { + font-size: 9px; + letter-spacing: 2px; + text-transform: uppercase; + color: var(--text-dim); + margin-bottom: 8px; +} + +.gauge-value { + font-family: var(--font-display); + font-size: 24px; + font-weight: 700; + color: var(--accent-cyan); + line-height: 1; +} + +.gauge-unit { + font-size: 10px; + color: var(--text-secondary); + margin-top: 4px; +} + +.gauge-bar { + height: 4px; + background: var(--bg-dark); + border-radius: 2px; + margin-top: 8px; + overflow: hidden; +} + +.gauge-bar-fill { + height: 100%; + border-radius: 2px; + background: var(--accent-cyan); + transition: width 0.5s ease; + box-shadow: var(--glow-blue); +} + +.gauge-bar-fill.warning { background: var(--accent-yellow); box-shadow: var(--glow-yellow); } +.gauge-bar-fill.danger { background: var(--accent-red); box-shadow: var(--glow-red); } + +/* ---- INDUSTRIAL SWITCH / TOGGLE ---- */ +.switch-panel { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + gap: 8px; +} + +.switch-unit { + background: var(--bg-inset); + border: 1px solid var(--border-dark); + border-radius: 4px; + padding: 10px 8px; + text-align: center; + cursor: pointer; + transition: all 0.2s; + position: relative; +} + +.switch-unit:hover { + border-color: var(--border-glow); + background: rgba(14, 165, 233, 0.05); +} + +.switch-unit.active { + border-color: var(--accent-cyan); + background: rgba(14, 165, 233, 0.1); + box-shadow: var(--glow-blue); +} + +.switch-unit.active .switch-led { + background: var(--accent-green); + box-shadow: var(--glow-green); +} + +.switch-led { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--text-dim); + margin: 0 auto 6px; + transition: all 0.2s; +} + +.switch-label { + font-size: 10px; + font-weight: 600; + letter-spacing: 1px; + text-transform: uppercase; + color: var(--text-secondary); +} + +.switch-unit.active .switch-label { + color: var(--accent-cyan); +} + +/* ---- INDUSTRIAL TOGGLE SWITCH ---- */ +.toggle-switch { + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; + user-select: none; +} + +.toggle-track { + width: 40px; + height: 20px; + background: var(--bg-dark); + border: 2px solid var(--border-dark); + border-radius: 10px; + position: relative; + transition: all 0.3s; +} + +.toggle-track::after { + content: ''; + position: absolute; + width: 12px; + height: 12px; + background: var(--text-dim); + border-radius: 50%; + top: 2px; + left: 2px; + transition: all 0.3s; +} + +.toggle-switch.active .toggle-track { + background: rgba(16, 185, 129, 0.2); + border-color: var(--accent-green); +} + +.toggle-switch.active .toggle-track::after { + left: 22px; + background: var(--accent-green); + box-shadow: var(--glow-green); +} + +/* ---- ROTARY SELECTOR ---- */ +.rotary-selector { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; +} + +.rotary-dial { + width: 80px; + height: 80px; + border-radius: 50%; + background: conic-gradient(from -90deg, var(--accent-cyan), var(--accent-blue), var(--accent-purple), var(--accent-cyan)); + padding: 3px; + position: relative; +} + +.rotary-inner { + width: 100%; + height: 100%; + border-radius: 50%; + background: var(--bg-inset); + display: flex; + align-items: center; + justify-content: center; + font-family: var(--font-display); + font-size: 14px; + font-weight: 700; + color: var(--accent-cyan); +} + +.rotary-label { + font-size: 9px; + letter-spacing: 2px; + text-transform: uppercase; + color: var(--text-dim); +} + +/* ---- PIPELINE VISUALIZATION ---- */ +.pipeline-canvas { + background: var(--bg-inset); + border: 1px solid var(--border-dark); + border-radius: 4px; + padding: 20px; + min-height: 120px; + position: relative; + overflow-x: auto; +} + +.pipeline-flow { + display: flex; + align-items: center; + gap: 0; + min-width: max-content; +} + +.pipeline-node { + background: var(--bg-module); + border: 1px solid var(--border-dark); + border-radius: 4px; + padding: 10px 14px; + min-width: 100px; + text-align: center; + position: relative; + transition: all 0.2s; + cursor: pointer; +} + +.pipeline-node:hover { + border-color: var(--accent-cyan); + transform: translateY(-2px); +} + +.pipeline-node.active { + border-color: var(--accent-green); + box-shadow: var(--glow-green); +} + +.pipeline-node.disabled { + opacity: 0.4; + border-style: dashed; +} + +.pipeline-node .node-type { + font-size: 9px; + letter-spacing: 1px; + text-transform: uppercase; + color: var(--text-dim); + margin-bottom: 4px; +} + +.pipeline-node .node-name { + font-size: 11px; + color: var(--text-primary); + font-weight: 600; +} + +.pipeline-node .node-status { + width: 6px; + height: 6px; + border-radius: 50%; + position: absolute; + top: 6px; + right: 6px; + background: var(--accent-green); +} + +.pipeline-node.disabled .node-status { background: var(--text-dim); } + +.pipeline-connector { + width: 40px; + height: 2px; + background: var(--border-dark); + position: relative; + flex-shrink: 0; +} + +.pipeline-connector::after { + content: ''; + position: absolute; + right: -4px; + top: -3px; + border: 4px solid transparent; + border-left-color: var(--border-dark); +} + +.pipeline-connector.active { + background: var(--accent-cyan); + box-shadow: var(--glow-blue); +} + +.pipeline-connector.active::after { + border-left-color: var(--accent-cyan); +} + +/* ---- PROGRESS BAR ---- */ +.progress-bar { + height: 6px; + background: var(--bg-dark); + border-radius: 3px; + overflow: hidden; + position: relative; +} + +.progress-fill { + height: 100%; + background: linear-gradient(90deg, var(--accent-blue), var(--accent-cyan)); + border-radius: 3px; + transition: width 0.3s ease; + position: relative; +} + +.progress-fill::after { + content: ''; + position: absolute; + top: 0; right: 0; + width: 30px; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2)); + animation: shimmer 1.5s infinite; +} + +@keyframes shimmer { + 0% { opacity: 0; } + 50% { opacity: 1; } + 100% { opacity: 0; } +} + +.progress-label { + display: flex; + justify-content: space-between; + font-size: 10px; + color: var(--text-secondary); + margin-top: 4px; +} + +/* ---- JOB LIST ---- */ +.job-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.job-item { + background: var(--bg-inset); + border: 1px solid var(--border-dark); + border-radius: 4px; + padding: 12px; + display: grid; + grid-template-columns: auto 1fr auto auto; + gap: 12px; + align-items: center; + transition: all 0.2s; +} + +.job-item:hover { + border-color: var(--border-glow); +} + +.job-thumb { + width: 48px; + height: 36px; + background: var(--bg-dark); + border-radius: 2px; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + color: var(--text-dim); +} + +.job-thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.job-info .job-name { + font-size: 12px; + color: var(--text-primary); + margin-bottom: 4px; +} + +.job-info .job-meta { + font-size: 10px; + color: var(--text-dim); +} + +.job-status { + font-size: 10px; + letter-spacing: 1px; + text-transform: uppercase; + padding: 3px 8px; + border-radius: 3px; + font-weight: 600; +} + +.job-status.running { + color: var(--accent-cyan); + background: rgba(14, 165, 233, 0.1); + border: 1px solid rgba(14, 165, 233, 0.3); +} + +.job-status.completed { + color: var(--accent-green); + background: rgba(16, 185, 129, 0.1); + border: 1px solid rgba(16, 185, 129, 0.3); +} + +.job-status.error { + color: var(--accent-red); + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.3); +} + +.job-status.queued { + color: var(--accent-yellow); + background: rgba(245, 158, 11, 0.1); + border: 1px solid rgba(245, 158, 11, 0.3); +} + +.job-actions { + display: flex; + gap: 6px; +} + +/* ---- BUTTONS ---- */ +.btn { + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 1px; + text-transform: uppercase; + padding: 6px 14px; + border: 1px solid var(--border-dark); + border-radius: 3px; + background: var(--bg-inset); + color: var(--text-secondary); + cursor: pointer; + transition: all 0.15s; + display: inline-flex; + align-items: center; + gap: 6px; +} + +.btn:hover { + border-color: var(--accent-cyan); + color: var(--accent-cyan); + background: rgba(14, 165, 233, 0.08); +} + +.btn-primary { + background: rgba(14, 165, 233, 0.15); + border-color: var(--accent-cyan); + color: var(--accent-cyan); +} + +.btn-primary:hover { + background: rgba(14, 165, 233, 0.25); + box-shadow: var(--glow-blue); +} + +.btn-danger { + border-color: rgba(239, 68, 68, 0.4); + color: var(--accent-red); +} + +.btn-danger:hover { + background: rgba(239, 68, 68, 0.1); + box-shadow: var(--glow-red); +} + +.btn-success { + border-color: rgba(16, 185, 129, 0.4); + color: var(--accent-green); +} + +.btn-success:hover { + background: rgba(16, 185, 129, 0.1); + box-shadow: var(--glow-green); +} + +.btn-icon { + width: 28px; + height: 28px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; +} + +.btn-large { + padding: 10px 24px; + font-size: 12px; +} + +/* Emergency button */ +.btn-emergency { + background: var(--accent-red); + color: white; + border: 2px solid #dc2626; + border-radius: 6px; + font-weight: 700; + padding: 8px 20px; + box-shadow: 0 0 15px rgba(239, 68, 68, 0.4); + text-shadow: 0 1px 2px rgba(0,0,0,0.3); +} + +.btn-emergency:hover { + background: #dc2626; + box-shadow: 0 0 25px rgba(239, 68, 68, 0.6); + color: white; +} + +/* ---- FORM ELEMENTS ---- */ +.form-group { + margin-bottom: 12px; +} + +.form-label { + font-size: 10px; + letter-spacing: 1px; + text-transform: uppercase; + color: var(--text-dim); + margin-bottom: 4px; + display: block; +} + +.form-input { + width: 100%; + padding: 8px 12px; + font-family: var(--font-mono); + font-size: 12px; + background: var(--bg-dark); + border: 1px solid var(--border-dark); + border-radius: 3px; + color: var(--text-primary); + transition: all 0.2s; +} + +.form-input:focus { + outline: none; + border-color: var(--accent-cyan); + box-shadow: var(--glow-blue); +} + +.form-select { + width: 100%; + padding: 8px 12px; + font-family: var(--font-mono); + font-size: 12px; + background: var(--bg-dark); + border: 1px solid var(--border-dark); + border-radius: 3px; + color: var(--text-primary); + cursor: pointer; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%236b7d8f'%3E%3Cpath d='M6 8L1 3h10z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 10px center; +} + +.form-select:focus { + outline: none; + border-color: var(--accent-cyan); +} + +/* ---- UPLOAD ZONE ---- */ +.upload-zone { + border: 2px dashed var(--border-dark); + border-radius: 8px; + padding: 40px 20px; + text-align: center; + cursor: pointer; + transition: all 0.3s; + background: var(--bg-inset); +} + +.upload-zone:hover, +.upload-zone.dragover { + border-color: var(--accent-cyan); + background: rgba(14, 165, 233, 0.05); +} + +.upload-zone .upload-icon { + font-size: 48px; + color: var(--text-dim); + margin-bottom: 12px; +} + +.upload-zone .upload-text { + font-size: 13px; + color: var(--text-secondary); + margin-bottom: 8px; +} + +.upload-zone .upload-hint { + font-size: 10px; + color: var(--text-dim); +} + +/* ---- LOG CONSOLE ---- */ +.log-console { + background: #000; + border: 1px solid var(--border-dark); + border-radius: 4px; + padding: 12px; + font-size: 11px; + line-height: 1.6; + max-height: 200px; + overflow-y: auto; + font-family: var(--font-mono); +} + +.log-line { color: var(--text-dim); } +.log-line.info { color: var(--accent-cyan); } +.log-line.warn { color: var(--accent-yellow); } +.log-line.error { color: var(--accent-red); } +.log-line.success { color: var(--accent-green); } + +.log-time { + color: var(--text-dim); + margin-right: 8px; +} + +/* ---- STREAM MATRIX ---- */ +.stream-matrix { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 12px; +} + +.stream-card { + background: var(--bg-inset); + border: 1px solid var(--border-dark); + border-radius: 4px; + overflow: hidden; +} + +.stream-preview { + aspect-ratio: 16/9; + background: #000; + display: flex; + align-items: center; + justify-content: center; + position: relative; +} + +.stream-preview .no-signal { + font-family: var(--font-display); + font-size: 14px; + color: var(--text-dim); + letter-spacing: 3px; +} + +.stream-preview .live-badge { + position: absolute; + top: 8px; + right: 8px; + background: var(--accent-red); + color: white; + font-size: 9px; + padding: 2px 6px; + border-radius: 2px; + font-weight: 700; + letter-spacing: 1px; + animation: blink 1s ease-in-out infinite; +} + +.stream-info { + padding: 10px; +} + +.stream-info .stream-name { + font-size: 12px; + margin-bottom: 6px; +} + +.stream-controls { + display: flex; + gap: 6px; + padding: 8px 10px; + border-top: 1px solid var(--border-dark); +} + +/* ---- FORMAT MATRIX (NUCLEAR SWITCHBOARD) ---- */ +.format-matrix { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 6px; +} + +.format-switch { + background: var(--bg-inset); + border: 1px solid var(--border-dark); + border-radius: 3px; + padding: 8px 6px; + text-align: center; + cursor: pointer; + transition: all 0.2s; + position: relative; + overflow: hidden; +} + +.format-switch::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: transparent; + transition: background 0.2s; +} + +.format-switch:hover { + border-color: var(--border-glow); +} + +.format-switch.selected { + border-color: var(--accent-cyan); + background: rgba(14, 165, 233, 0.08); +} + +.format-switch.selected::before { + background: var(--accent-cyan); + box-shadow: 0 0 8px rgba(14, 165, 233, 0.5); +} + +.format-switch .format-name { + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + color: var(--text-secondary); + letter-spacing: 1px; +} + +.format-switch.selected .format-name { + color: var(--accent-cyan); +} + +.format-switch .format-desc { + font-size: 8px; + color: var(--text-dim); + margin-top: 2px; +} + +/* ---- SLIDER ---- */ +.slider-control { + display: flex; + align-items: center; + gap: 12px; +} + +.slider-control input[type="range"] { + flex: 1; + -webkit-appearance: none; + height: 4px; + background: var(--bg-dark); + border-radius: 2px; + outline: none; +} + +.slider-control input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--accent-cyan); + border: 2px solid var(--bg-module); + cursor: pointer; + box-shadow: var(--glow-blue); +} + +.slider-value { + font-family: var(--font-display); + font-size: 14px; + color: var(--accent-cyan); + min-width: 50px; + text-align: right; +} + +/* ---- RESPONSIVE ---- */ +@media (max-width: 1200px) { + .panel-row.cols-3 { grid-template-columns: 1fr 1fr; } + .panel-row.cols-4 { grid-template-columns: 1fr 1fr; } + .gauge-grid { grid-template-columns: repeat(2, 1fr); } +} + +@media (max-width: 768px) { + .panel-row.cols-2, + .panel-row.cols-3, + .panel-row.cols-4, + .panel-row.cols-2-1, + .panel-row.cols-1-2 { grid-template-columns: 1fr; } + + .format-matrix { grid-template-columns: repeat(3, 1fr); } + .gauge-grid { grid-template-columns: 1fr 1fr; } + .main-container { padding: 8px; } + + .topbar { padding: 0 12px; } + .topbar-subtitle { display: none; } +} + +/* ---- MODAL ---- */ +.modal-overlay { + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + opacity: 0; + visibility: hidden; + transition: all 0.3s; +} + +.modal-overlay.visible { + opacity: 1; + visibility: visible; +} + +.modal { + background: var(--bg-module); + border: 1px solid var(--border-dark); + border-radius: 6px; + width: 90%; + max-width: 600px; + max-height: 80vh; + overflow-y: auto; + transform: translateY(20px); + transition: transform 0.3s; +} + +.modal-overlay.visible .modal { + transform: translateY(0); +} + +.modal-header { + padding: 16px; + border-bottom: 1px solid var(--border-dark); + display: flex; + justify-content: space-between; + align-items: center; +} + +.modal-header h3 { + font-family: var(--font-display); + font-size: 13px; + letter-spacing: 2px; + text-transform: uppercase; + color: var(--text-primary); +} + +.modal-close { + background: none; + border: none; + color: var(--text-dim); + font-size: 18px; + cursor: pointer; + padding: 4px; +} + +.modal-close:hover { color: var(--accent-red); } + +.modal-body { padding: 16px; } +.modal-footer { + padding: 12px 16px; + border-top: 1px solid var(--border-dark); + display: flex; + justify-content: flex-end; + gap: 8px; +} + +/* ---- TOOLTIP ---- */ +[data-tooltip] { + position: relative; +} + +[data-tooltip]:hover::after { + content: attr(data-tooltip); + position: absolute; + bottom: calc(100% + 8px); + left: 50%; + transform: translateX(-50%); + background: var(--bg-dark); + border: 1px solid var(--border-dark); + padding: 4px 8px; + font-size: 10px; + color: var(--text-secondary); + white-space: nowrap; + border-radius: 3px; + z-index: 100; +} + +/* ---- NOTIFICATION ---- */ +.notification { + position: fixed; + top: 70px; + right: 20px; + background: var(--bg-module); + border: 1px solid var(--border-dark); + border-radius: 4px; + padding: 12px 16px; + font-size: 12px; + z-index: 1000; + display: flex; + align-items: center; + gap: 10px; + transform: translateX(120%); + transition: transform 0.3s ease; + max-width: 350px; +} + +.notification.show { transform: translateX(0); } +.notification.success { border-left: 3px solid var(--accent-green); } +.notification.error { border-left: 3px solid var(--accent-red); } +.notification.warning { border-left: 3px solid var(--accent-yellow); } +.notification.info { border-left: 3px solid var(--accent-cyan); } diff --git a/video-converter-suite/public/js/controlpanel.js b/video-converter-suite/public/js/controlpanel.js new file mode 100644 index 0000000..61ad2c6 --- /dev/null +++ b/video-converter-suite/public/js/controlpanel.js @@ -0,0 +1,828 @@ +/** + * Video Converter Suite - Control Panel JavaScript + * Nuclear Power Plant Style UI Controller + */ + +// ============ STATE ============ +const state = { + currentPage: 'dashboard', + selectedFormat: 'mp4', + selectedPreset: 'balanced', + selectedResolution: 'original', + uploadedFile: null, + uploadedFilePath: null, + jobs: [], + streams: [], + pipelines: [], + activePipelineId: null, + pipelineStages: [], + activeStreamId: null, + wsConnected: false, + refreshInterval: null, +}; + +// ============ INIT ============ +document.addEventListener('DOMContentLoaded', () => { + initClock(); + initNavigation(); + initUploadZone(); + startAutoRefresh(); + refreshStatus(); + addLog('System initialisiert', 'info'); +}); + +// ============ CLOCK ============ +function initClock() { + const el = document.getElementById('systemClock'); + function update() { + const now = new Date(); + el.textContent = now.toTimeString().split(' ')[0]; + } + update(); + setInterval(update, 1000); +} + +// ============ NAVIGATION ============ +function initNavigation() { + document.querySelectorAll('.nav-tab').forEach(tab => { + tab.addEventListener('click', () => { + const page = tab.dataset.page; + switchPage(page); + }); + }); +} + +function switchPage(page) { + state.currentPage = page; + document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active')); + document.querySelector(`.nav-tab[data-page="${page}"]`)?.classList.add('active'); + document.querySelectorAll('.page-content').forEach(p => p.style.display = 'none'); + document.getElementById(`page-${page}`).style.display = ''; + + // Refresh page-specific data + if (page === 'dashboard') refreshStatus(); + if (page === 'streams') refreshStreams(); + if (page === 'pipelines') refreshPipelines(); + if (page === 'queue') refreshQueue(); +} + +// ============ UPLOAD ============ +function initUploadZone() { + const zone = document.getElementById('uploadZone'); + if (!zone) return; + + zone.addEventListener('dragover', (e) => { + e.preventDefault(); + zone.classList.add('dragover'); + }); + + zone.addEventListener('dragleave', () => { + zone.classList.remove('dragover'); + }); + + zone.addEventListener('drop', (e) => { + e.preventDefault(); + zone.classList.remove('dragover'); + if (e.dataTransfer.files.length > 0) { + uploadFile(e.dataTransfer.files[0]); + } + }); +} + +function handleFileSelect(event) { + if (event.target.files.length > 0) { + uploadFile(event.target.files[0]); + } +} + +async function uploadFile(file) { + state.uploadedFile = file; + addLog(`Upload gestartet: ${file.name} (${formatBytes(file.size)})`, 'info'); + + const formData = new FormData(); + formData.append('file', file); + + try { + const resp = await fetch('/api/upload', { + method: 'POST', + body: formData, + }); + const data = await resp.json(); + + if (data.error) { + addLog(`Upload-Fehler: ${data.error}`, 'error'); + notify('Upload fehlgeschlagen: ' + data.error, 'error'); + return; + } + + state.uploadedFilePath = data.path; + displayUploadedFile(data); + document.getElementById('btnStartConvert').disabled = false; + addLog(`Upload abgeschlossen: ${file.name}`, 'success'); + notify('Datei hochgeladen: ' + file.name, 'success'); + } catch (err) { + addLog(`Upload-Fehler: ${err.message}`, 'error'); + notify('Upload fehlgeschlagen', 'error'); + } +} + +function displayUploadedFile(data) { + const el = document.getElementById('uploadedFileInfo'); + el.style.display = 'block'; + + const info = data.info || {}; + const video = info.video || {}; + const audio = info.audio || {}; + + el.innerHTML = ` +
+
+ ${data.original_name} + ${formatBytes(data.size)} +
+
+
Format: ${info.format_name || 'N/A'}
+
Dauer: ${formatDuration(info.duration || 0)}
+ ${video ? ` +
Video: ${video.codec || 'N/A'} ${video.width || ''}x${video.height || ''}
+
FPS: ${video.fps || 'N/A'}
+ ` : ''} + ${audio ? ` +
Audio: ${audio.codec || 'N/A'}
+
Sample: ${audio.sample_rate || 'N/A'} Hz
+ ` : ''} +
+
+ `; +} + +// ============ FORMAT SELECTION ============ +function selectFormat(format) { + state.selectedFormat = format; + document.querySelectorAll('.format-switch').forEach(s => s.classList.remove('selected')); + document.querySelectorAll(`.format-switch[data-format="${format}"]`).forEach(s => s.classList.add('selected')); + addLog(`Format gewählt: ${format.toUpperCase()}`, 'info'); +} + +function selectPreset(preset) { + state.selectedPreset = preset; + document.querySelectorAll('#presetPanel .switch-unit').forEach(s => s.classList.remove('active')); + document.querySelector(`#presetPanel .switch-unit[data-preset="${preset}"]`)?.classList.add('active'); +} + +function selectResolution(res) { + state.selectedResolution = res; + document.querySelectorAll('#resolutionPanel .switch-unit').forEach(s => s.classList.remove('active')); + document.querySelector(`#resolutionPanel .switch-unit[data-resolution="${res}"]`)?.classList.add('active'); +} + +// ============ CONVERSION ============ +async function startConversion() { + if (!state.uploadedFilePath) { + notify('Keine Datei hochgeladen', 'warning'); + return; + } + + const params = { + input_file: state.uploadedFilePath, + output_format: state.selectedFormat, + preset: state.selectedPreset, + }; + + if (state.selectedResolution !== 'original') { + params.resolution = state.selectedResolution; + } + + addLog(`Konvertierung gestartet: ${state.selectedFormat.toUpperCase()} / ${state.selectedPreset}`, 'info'); + document.getElementById('conversionStatus').textContent = 'Konvertierung wird gestartet...'; + + try { + const resp = await fetch('/api/convert', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params), + }); + const data = await resp.json(); + + if (data.error) { + addLog(`Fehler: ${data.error}`, 'error'); + notify(data.error, 'error'); + return; + } + + addLog(`Job erstellt: ${data.id}`, 'success'); + notify('Konvertierung gestartet', 'success'); + document.getElementById('btnStopAll').style.display = ''; + document.getElementById('conversionStatus').textContent = `Job ${data.id} läuft...`; + startJobPolling(data.id); + + } catch (err) { + addLog(`Fehler: ${err.message}`, 'error'); + notify('Konvertierung fehlgeschlagen', 'error'); + } +} + +function startJobPolling(jobId) { + const poll = setInterval(async () => { + try { + const resp = await fetch(`/api/jobs/${jobId}/progress`); + const progress = await resp.json(); + + document.getElementById('conversionStatus').textContent = + `${progress.percent || 0}% | FPS: ${progress.fps || 0} | Speed: ${progress.speed || '0x'} | Zeit: ${progress.time || '00:00:00'}`; + + updateJobInList(jobId, progress); + + if (progress.percent >= 100) { + clearInterval(poll); + addLog(`Job ${jobId} abgeschlossen`, 'success'); + notify('Konvertierung abgeschlossen!', 'success'); + document.getElementById('conversionStatus').textContent = 'Konvertierung abgeschlossen!'; + refreshJobs(); + } + } catch (e) { + // Keep polling + } + }, 1000); +} + +async function stopAllJobs() { + if (!confirm('Alle laufenden Jobs stoppen?')) return; + + try { + const resp = await fetch('/api/jobs'); + const data = await resp.json(); + for (const job of (data.jobs || [])) { + if (job.status === 'running') { + await fetch(`/api/jobs/${job.id}/cancel`, { method: 'POST' }); + addLog(`Job ${job.id} gestoppt`, 'warn'); + } + } + notify('Alle Jobs gestoppt', 'warning'); + document.getElementById('btnStopAll').style.display = 'none'; + refreshJobs(); + } catch (e) { + notify('Fehler beim Stoppen', 'error'); + } +} + +// ============ JOBS ============ +async function refreshJobs() { + try { + const resp = await fetch('/api/jobs'); + const data = await resp.json(); + state.jobs = data.jobs || []; + renderJobList(); + } catch (e) { + // Silently fail + } +} + +function renderJobList() { + const el = document.getElementById('jobList'); + if (!state.jobs.length) { + el.innerHTML = '
Keine aktiven Jobs
'; + return; + } + + el.innerHTML = state.jobs.map(job => ` +
+
${job.thumbnail ? `` : '🎦'}
+
+
${job.input_file ? job.input_file.split('/').pop() : 'Unknown'}
+
+ ${job.output_format?.toUpperCase() || ''} | ${job.preset || ''} | ${job.resolution || 'Original'} +
+
+
+
+
+ ${job.status === 'completed' ? '100%' : '0%'} + +
+
+ ${job.status} +
+ ${job.status === 'running' ? `` : ''} + ${job.status === 'completed' ? `` : ''} + +
+
+ `).join(''); + + // Update active job count + const running = state.jobs.filter(j => j.status === 'running').length; + document.getElementById('activeJobCount').textContent = running; + document.getElementById('gaugeJobs').textContent = running; +} + +function updateJobInList(jobId, progress) { + const bar = document.getElementById(`progress-${jobId}`); + const text = document.getElementById(`progress-text-${jobId}`); + const speed = document.getElementById(`progress-speed-${jobId}`); + if (bar) bar.style.width = `${progress.percent || 0}%`; + if (text) text.textContent = `${progress.percent || 0}%`; + if (speed) speed.textContent = `${progress.fps || 0} fps | ${progress.speed || ''}`; +} + +async function cancelJob(id) { + await fetch(`/api/jobs/${id}/cancel`, { method: 'POST' }); + addLog(`Job ${id} abgebrochen`, 'warn'); + refreshJobs(); +} + +async function deleteJob(id) { + await fetch(`/api/jobs/${id}`, { method: 'DELETE' }); + addLog(`Job ${id} gelöscht`, 'info'); + refreshJobs(); +} + +function downloadJob(id) { + window.open(`/api/download/${id}`, '_blank'); +} + +// ============ STREAMS ============ +async function refreshStreams() { + try { + const resp = await fetch('/api/streams'); + const data = await resp.json(); + state.streams = data.streams || []; + renderStreamMatrix(); + updateStreamSelect(); + } catch (e) {} +} + +function renderStreamMatrix() { + const el = document.getElementById('streamMatrix'); + if (!state.streams.length) { + el.innerHTML = '
Keine aktiven Streams
'; + return; + } + + el.innerHTML = state.streams.map(s => ` +
+
+ ${s.status === 'running' ? '▶ LIVE' : 'NO SIGNAL'} + ${s.status === 'running' ? 'LIVE' : ''} +
+
+
${s.input_url || 'Stream'}
+
+ ${s.output_format?.toUpperCase() || ''} | ${s.resolution || 'Original'} | ${s.preset || 'fast'} +
+ ${s.status} +
+
+ ${s.status === 'running' ? + `` : + `` + } + +
+
+ `).join(''); +} + +function updateStreamSelect() { + const sel = document.getElementById('activeStreamSelect'); + const runningStreams = state.streams.filter(s => s.status === 'running'); + sel.innerHTML = '' + + runningStreams.map(s => + `` + ).join(''); +} + +function openStreamModal() { + document.getElementById('streamModal').classList.add('visible'); +} + +function closeStreamModal() { + document.getElementById('streamModal').classList.remove('visible'); +} + +async function startNewStream() { + const inputUrl = document.getElementById('streamInputUrl').value; + if (!inputUrl) { + notify('Bitte Stream-URL eingeben', 'warning'); + return; + } + + const params = { + input_url: inputUrl, + output_format: document.getElementById('streamOutputFormat').value, + resolution: document.getElementById('streamResolution').value || null, + preset: document.getElementById('streamPreset').value, + }; + + try { + const resp = await fetch('/api/streams', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params), + }); + const data = await resp.json(); + + if (data.error) { + notify(data.error, 'error'); + return; + } + + addLog(`Stream gestartet: ${data.id}`, 'success'); + notify('Stream gestartet', 'success'); + closeStreamModal(); + refreshStreams(); + } catch (e) { + notify('Stream-Start fehlgeschlagen', 'error'); + } +} + +async function stopStream(id) { + await fetch(`/api/streams/${id}`, { method: 'DELETE' }); + addLog(`Stream ${id} gestoppt`, 'warn'); + refreshStreams(); +} + +async function deleteStream(id) { + await fetch(`/api/streams/${id}`, { method: 'DELETE' }); + refreshStreams(); +} + +function selectActiveStream(id) { + state.activeStreamId = id; + // Highlight current format + const stream = state.streams.find(s => s.id === id); + document.querySelectorAll('[data-stream-format]').forEach(el => el.classList.remove('selected')); + if (stream) { + document.querySelector(`[data-stream-format="${stream.output_format}"]`)?.classList.add('selected'); + } +} + +async function switchStreamFormat(format) { + if (!state.activeStreamId) { + notify('Bitte zuerst einen Stream wählen', 'warning'); + return; + } + + addLog(`Format-Wechsel: ${format.toUpperCase()} für Stream ${state.activeStreamId}`, 'warn'); + + try { + const resp = await fetch(`/api/streams/${state.activeStreamId}/switch`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ format }), + }); + const data = await resp.json(); + + if (data.error) { + notify(data.error, 'error'); + return; + } + + addLog(`Format gewechselt zu ${format.toUpperCase()}`, 'success'); + notify(`Format umgeschaltet: ${format.toUpperCase()}`, 'success'); + + // Update active stream ID to new stream + state.activeStreamId = data.id; + refreshStreams(); + + // Highlight new format + document.querySelectorAll('[data-stream-format]').forEach(el => el.classList.remove('selected')); + document.querySelector(`[data-stream-format="${format}"]`)?.classList.add('selected'); + } catch (e) { + notify('Format-Wechsel fehlgeschlagen', 'error'); + } +} + +// ============ PIPELINES ============ +async function refreshPipelines() { + try { + const resp = await fetch('/api/pipelines'); + const data = await resp.json(); + state.pipelines = data.pipelines || []; + renderPipelineList(); + } catch (e) {} +} + +function renderPipelineList() { + const el = document.getElementById('pipelineList'); + if (!state.pipelines.length) { + el.innerHTML = '
Keine Pipelines vorhanden
'; + return; + } + + el.innerHTML = state.pipelines.map(p => ` +
+
+
+
${p.name}
+
${(p.stages || []).length} Stufen | Status: ${p.status}
+
+ ${p.status} +
+ + +
+
+ `).join(''); +} + +async function createPipeline() { + const name = prompt('Pipeline-Name:', 'Neue Pipeline'); + if (!name) return; + + try { + const resp = await fetch('/api/pipelines', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }), + }); + const data = await resp.json(); + addLog(`Pipeline erstellt: ${data.name}`, 'success'); + refreshPipelines(); + editPipeline(data.id); + } catch (e) { + notify('Pipeline-Erstellung fehlgeschlagen', 'error'); + } +} + +function editPipeline(id) { + state.activePipelineId = id; + const pipeline = state.pipelines.find(p => p.id === id); + if (!pipeline) return; + + state.pipelineStages = pipeline.stages || []; + document.getElementById('pipelineEditor').style.display = ''; + renderPipelineFlow(); +} + +function renderPipelineFlow() { + const flow = document.getElementById('pipelineFlow'); + let html = ` +
+
Input
+
Source
+
+
+ `; + + state.pipelineStages.forEach((stage, i) => { + html += `
`; + html += ` +
+
${stage.type}
+
${stage.label || stage.type}
+
+
+ `; + }); + + html += `
`; + html += ` +
+
Output
+
Target
+
+
+ `; + + flow.innerHTML = html; +} + +function toggleStage(index) { + if (state.pipelineStages[index]) { + state.pipelineStages[index].enabled = !state.pipelineStages[index].enabled; + renderPipelineFlow(); + savePipelineStages(); + } +} + +async function addPipelineStage(type) { + if (!state.activePipelineId) { + notify('Bitte zuerst eine Pipeline auswählen oder erstellen', 'warning'); + return; + } + + const stageDefaults = { + transcode: { params: { video_codec: 'libx264', preset: 'medium', crf: 23 } }, + scale: { params: { width: 1920, height: 1080 } }, + filter: { params: { brightness: 0, contrast: 1, saturation: 1 } }, + audio: { params: { codec: 'aac', bitrate: '128k', sample_rate: 44100 } }, + bitrate: { params: { video: '2M', audio: '128k' } }, + framerate: { params: { fps: 30 } }, + trim: { params: { start: '00:00:00', duration: '' } }, + deinterlace: { params: {} }, + denoise: { params: {} }, + stabilize: { params: {} }, + }; + + const defaults = stageDefaults[type] || { params: {} }; + const stage = { + type, + label: type.charAt(0).toUpperCase() + type.slice(1), + params: defaults.params, + enabled: true, + }; + + state.pipelineStages.push(stage); + renderPipelineFlow(); + await savePipelineStages(); + addLog(`Stufe hinzugefügt: ${type}`, 'info'); +} + +async function savePipelineStages() { + if (!state.activePipelineId) return; + + try { + await fetch(`/api/pipelines/${state.activePipelineId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ stages: state.pipelineStages }), + }); + } catch (e) { + console.error('Failed to save pipeline stages'); + } +} + +async function deletePipeline(id) { + if (!confirm('Pipeline löschen?')) return; + await fetch(`/api/pipelines/${id}`, { method: 'DELETE' }); + if (state.activePipelineId === id) { + state.activePipelineId = null; + document.getElementById('pipelineEditor').style.display = 'none'; + } + refreshPipelines(); +} + +async function runPipeline(id) { + if (!state.uploadedFilePath) { + notify('Bitte zuerst eine Datei hochladen (Konverter-Seite)', 'warning'); + return; + } + + try { + const resp = await fetch(`/api/pipelines/${id}/run`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + input_file: state.uploadedFilePath, + output_format: state.selectedFormat, + }), + }); + const data = await resp.json(); + if (data.error) { + notify(data.error, 'error'); + return; + } + addLog(`Pipeline ${id} ausgeführt, Job: ${data.id}`, 'success'); + notify('Pipeline gestartet', 'success'); + startJobPolling(data.id); + } catch (e) { + notify('Pipeline-Start fehlgeschlagen', 'error'); + } +} + +// ============ QUEUE ============ +async function refreshQueue() { + try { + const resp = await fetch('/api/queue'); + const data = await resp.json(); + renderQueue(data.queue || [], data.stats || {}); + } catch (e) {} +} + +function renderQueue(queue, stats) { + const el = document.getElementById('queueList'); + if (!queue.length) { + el.innerHTML = '
Warteschlange ist leer
'; + } else { + el.innerHTML = queue.map(job => ` +
+
📄
+
+
${job.input_file || job.queue_id}
+
Priorität: ${job.priority || 5} | ${job.output_format || 'mp4'}
+
+ ${job.queue_status} +
+ `).join(''); + } + + document.getElementById('queueWaiting').textContent = stats.waiting || 0; + document.getElementById('queueProcessing').textContent = stats.processing || 0; + document.getElementById('queueCompleted').textContent = stats.completed || 0; + document.getElementById('queueFailed').textContent = stats.failed || 0; +} + +async function clearQueue() { + await fetch('/api/queue', { method: 'DELETE' }); + refreshQueue(); + notify('Queue geleert', 'info'); +} + +// ============ STATUS / SYSTEM ============ +async function refreshStatus() { + try { + const resp = await fetch('/api/system'); + const data = await resp.json(); + updateGauges(data); + refreshJobs(); + } catch (e) { + document.getElementById('systemStatusDot').className = 'status-dot error'; + document.getElementById('systemStatusText').textContent = 'OFFLINE'; + } +} + +function updateGauges(data) { + // CPU + const cpuLoad = data.cpu_load?.[0] || 0; + const cpuPercent = Math.min(100, cpuLoad * 25); // Normalize to ~100% at load 4 + document.getElementById('gaugeCpu').textContent = cpuLoad.toFixed(1); + const cpuBar = document.getElementById('gaugeCpuBar'); + cpuBar.style.width = cpuPercent + '%'; + cpuBar.className = 'gauge-bar-fill' + (cpuPercent > 80 ? ' danger' : cpuPercent > 50 ? ' warning' : ''); + + // Memory + const mem = data.memory || {}; + const memPercent = mem.peak ? Math.round((mem.used / mem.peak) * 100) : 0; + document.getElementById('gaugeMem').textContent = memPercent; + const memBar = document.getElementById('gaugeMemBar'); + memBar.style.width = memPercent + '%'; + memBar.className = 'gauge-bar-fill' + (memPercent > 80 ? ' danger' : memPercent > 50 ? ' warning' : ''); + + // Disk + const diskFree = (data.disk?.free || 0) / (1024 * 1024 * 1024); + const diskTotal = (data.disk?.total || 1) / (1024 * 1024 * 1024); + const diskUsedPercent = Math.round(((diskTotal - diskFree) / diskTotal) * 100); + document.getElementById('gaugeDisk').textContent = diskFree.toFixed(1); + const diskBar = document.getElementById('gaugeDiskBar'); + diskBar.style.width = diskUsedPercent + '%'; + diskBar.className = 'gauge-bar-fill' + (diskUsedPercent > 90 ? ' danger' : diskUsedPercent > 70 ? ' warning' : ''); + + // Status + document.getElementById('systemStatusDot').className = 'status-dot'; + document.getElementById('systemStatusText').textContent = data.ffmpeg_available ? 'SYSTEM ONLINE' : 'FFMPEG MISSING'; + if (!data.ffmpeg_available) { + document.getElementById('systemStatusDot').className = 'status-dot warning'; + } +} + +function startAutoRefresh() { + if (state.refreshInterval) clearInterval(state.refreshInterval); + state.refreshInterval = setInterval(() => { + if (state.currentPage === 'dashboard') refreshStatus(); + if (state.currentPage === 'streams') refreshStreams(); + }, 5000); +} + +// ============ LOGGING ============ +function addLog(message, level = 'info') { + const console = document.getElementById('logConsole'); + const time = new Date().toLocaleTimeString(); + const line = document.createElement('div'); + line.className = `log-line ${level}`; + line.innerHTML = `[${time}] ${escapeHtml(message)}`; + console.appendChild(line); + console.scrollTop = console.scrollHeight; + + // Keep max 100 lines + while (console.children.length > 100) { + console.removeChild(console.firstChild); + } +} + +function clearLog() { + document.getElementById('logConsole').innerHTML = + '
[CLEAR] Log bereinigt
'; +} + +// ============ NOTIFICATIONS ============ +function notify(message, type = 'info') { + const el = document.getElementById('notification'); + el.className = `notification ${type} show`; + el.textContent = message; + setTimeout(() => { el.classList.remove('show'); }, 3000); +} + +// ============ HELPERS ============ +function formatBytes(bytes) { + if (bytes === 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB']; + let i = 0, size = bytes; + while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; } + return size.toFixed(1) + ' ' + units[i]; +} + +function formatDuration(seconds) { + if (!seconds) return '0:00'; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = Math.floor(seconds % 60); + if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; + return `${m}:${String(s).padStart(2, '0')}`; +} + +function escapeHtml(str) { + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; +} diff --git a/video-converter-suite/public/router.php b/video-converter-suite/public/router.php new file mode 100644 index 0000000..61d508b --- /dev/null +++ b/video-converter-suite/public/router.php @@ -0,0 +1,15 @@ +stateFile = __DIR__ . '/../../storage/temp/jobs.json'; + $this->probe = new MediaProbe(); + $this->load(); + } + + private function load(): void + { + if (file_exists($this->stateFile)) { + $this->jobs = json_decode(file_get_contents($this->stateFile), true) ?: []; + } + } + + private function save(): void + { + $dir = dirname($this->stateFile); + if (!is_dir($dir)) mkdir($dir, 0755, true); + file_put_contents($this->stateFile, json_encode($this->jobs, JSON_PRETTY_PRINT)); + } + + public function convert(array $params): array + { + $config = require __DIR__ . '/../../config/app.php'; + + $inputFile = $params['input_file'] ?? ''; + $outputFormat = $params['output_format'] ?? 'mp4'; + $preset = $params['preset'] ?? 'balanced'; + $resolution = $params['resolution'] ?? null; + $customPipeline = $params['pipeline'] ?? null; + + if (!file_exists($inputFile)) { + return ['error' => 'Input file not found']; + } + + $id = bin2hex(random_bytes(8)); + $formatConfig = $config['formats']['video'][$outputFormat] + ?? $config['formats']['audio'][$outputFormat] + ?? null; + + if (!$formatConfig) { + return ['error' => "Unknown format: {$outputFormat}"]; + } + + $inputInfo = $this->probe->analyze($inputFile); + $baseName = pathinfo($inputFile, PATHINFO_FILENAME); + $outputFile = $config['storage']['outputs'] . "/{$baseName}_{$id}.{$formatConfig['ext']}"; + + // Build command + if ($customPipeline instanceof Pipeline) { + $cmd = $customPipeline->buildFFmpegCommand($inputFile, $outputFile); + } else { + $cmd = $this->buildCommand($inputFile, $outputFile, $outputFormat, $preset, $resolution, $params); + } + + $process = new FFmpegProcess($cmd, $id); + if (isset($inputInfo['duration'])) { + $process->setDuration($inputInfo['duration']); + } + + // Generate thumbnail + $thumbPath = $config['storage']['thumbnails'] . "/{$id}.jpg"; + $this->probe->getThumbnail($inputFile, $thumbPath); + + $job = [ + 'id' => $id, + 'input_file' => $inputFile, + 'input_info' => $inputInfo, + 'output_file' => $outputFile, + 'output_format' => $outputFormat, + 'preset' => $preset, + 'resolution' => $resolution, + 'thumbnail' => file_exists($thumbPath) ? $thumbPath : null, + 'status' => 'starting', + 'pid' => null, + 'command' => $cmd, + 'created_at' => date('c'), + ]; + + if ($process->start()) { + $job['status'] = 'running'; + $job['pid'] = $process->getPid(); + } else { + $job['status'] = 'error'; + $job['error'] = 'Failed to start FFmpeg process'; + } + + $this->jobs[$id] = $job; + $this->save(); + + return $job; + } + + public function batchConvert(string $inputFile, array $formats): array + { + $results = []; + foreach ($formats as $format => $settings) { + $params = array_merge( + ['input_file' => $inputFile, 'output_format' => $format], + $settings + ); + $results[$format] = $this->convert($params); + } + return $results; + } + + public function getJob(string $id): ?array + { + $this->refreshJob($id); + return $this->jobs[$id] ?? null; + } + + public function getAllJobs(): array + { + foreach (array_keys($this->jobs) as $id) { + $this->refreshJob($id); + } + return array_values($this->jobs); + } + + public function cancelJob(string $id): bool + { + if (!isset($this->jobs[$id])) return false; + + $job = $this->jobs[$id]; + if ($job['pid'] && $job['status'] === 'running') { + posix_kill($job['pid'], SIGTERM); + $this->jobs[$id]['status'] = 'cancelled'; + $this->save(); + return true; + } + return false; + } + + public function deleteJob(string $id): bool + { + if (isset($this->jobs[$id])) { + $this->cancelJob($id); + // Clean up output file + if (isset($this->jobs[$id]['output_file']) && file_exists($this->jobs[$id]['output_file'])) { + unlink($this->jobs[$id]['output_file']); + } + unset($this->jobs[$id]); + $this->save(); + return true; + } + return false; + } + + public function getProgress(string $id): array + { + if (!isset($this->jobs[$id])) { + return ['error' => 'Job not found']; + } + + $config = require __DIR__ . '/../../config/app.php'; + $progressFile = $config['storage']['logs'] . "/progress_{$id}.txt"; + + $progress = ['percent' => 0, 'fps' => 0, 'speed' => '0x', 'time' => '00:00:00']; + + if (file_exists($progressFile)) { + $content = file_get_contents($progressFile); + foreach (explode("\n", $content) as $line) { + if (str_contains($line, '=')) { + [$key, $val] = explode('=', $line, 2); + $key = trim($key); + $val = trim($val); + if ($key === 'out_time') $progress['time'] = $val; + if ($key === 'fps') $progress['fps'] = (float)$val; + if ($key === 'speed') $progress['speed'] = $val; + if ($key === 'progress' && $val === 'end') $progress['percent'] = 100; + } + } + + $duration = $this->jobs[$id]['input_info']['duration'] ?? 0; + if ($duration > 0 && $progress['percent'] < 100) { + $current = $this->timeToSeconds($progress['time']); + $progress['percent'] = min(99, round(($current / $duration) * 100, 1)); + } + } + + return $progress; + } + + private function refreshJob(string $id): void + { + if (!isset($this->jobs[$id])) return; + $job = &$this->jobs[$id]; + + if ($job['status'] === 'running' && $job['pid']) { + if (!posix_kill($job['pid'], 0)) { + // Check if output file exists and has size + if (isset($job['output_file']) && file_exists($job['output_file']) && filesize($job['output_file']) > 0) { + $job['status'] = 'completed'; + $job['completed_at'] = date('c'); + $job['output_size'] = filesize($job['output_file']); + } else { + $job['status'] = 'error'; + $job['error'] = 'Process ended without output'; + } + $this->save(); + } + } + } + + private function buildCommand(string $input, string $output, string $format, string $preset, ?string $resolution, array $params): string + { + $config = require __DIR__ . '/../../config/app.php'; + $ffmpeg = $config['ffmpeg']['binary']; + $formatConfig = $config['formats']['video'][$format] ?? $config['formats']['audio'][$format] ?? []; + $presetConfig = $config['presets'][$preset] ?? $config['presets']['balanced']; + $threads = $config['ffmpeg']['threads']; + + $cmd = "{$ffmpeg} -y -i " . escapeshellarg($input); + $cmd .= " -threads {$threads}"; + + // Check if audio-only + $isAudio = isset($config['formats']['audio'][$format]); + + if ($isAudio) { + $cmd .= " -vn"; + $cmd .= " -c:a " . escapeshellarg($formatConfig['codec']); + if (isset($params['audio_bitrate'])) { + $cmd .= " -b:a " . escapeshellarg($params['audio_bitrate']); + } + } else { + $cmd .= " -c:v " . escapeshellarg($formatConfig['codec']); + $cmd .= " -preset " . escapeshellarg($presetConfig['preset']); + $cmd .= " -crf " . (int)$presetConfig['crf']; + + if ($resolution && isset($config['resolutions'][$resolution])) { + $res = $config['resolutions'][$resolution]; + $cmd .= " -vf scale={$res['width']}:{$res['height']}"; + } + + $cmd .= " -c:a aac -b:a 128k"; + } + + // HLS specific + if ($format === 'hls') { + $cmd .= " -hls_time 4 -hls_list_size 0 -hls_segment_filename " + . escapeshellarg(dirname($output) . "/segment_%03d.ts"); + } + + // DASH specific + if ($format === 'dash') { + $cmd .= " -use_timeline 1 -use_template 1 -adaptation_sets 'id=0,streams=v id=1,streams=a'"; + } + + // Extra params + if (isset($params['video_bitrate'])) { + $cmd .= " -b:v " . escapeshellarg($params['video_bitrate']); + } + if (isset($params['fps'])) { + $cmd .= " -r " . (int)$params['fps']; + } + + $cmd .= " " . escapeshellarg($output); + return $cmd; + } + + private function timeToSeconds(string $time): float + { + $parts = explode(':', $time); + if (count($parts) !== 3) return 0; + return (int)$parts[0] * 3600 + (int)$parts[1] * 60 + (float)$parts[2]; + } +} diff --git a/video-converter-suite/src/Pipeline/Pipeline.php b/video-converter-suite/src/Pipeline/Pipeline.php new file mode 100644 index 0000000..0fcfe67 --- /dev/null +++ b/video-converter-suite/src/Pipeline/Pipeline.php @@ -0,0 +1,127 @@ +id = $id ?? bin2hex(random_bytes(8)); + $this->name = $name; + $this->createdAt = date('c'); + } + + public function getId(): string { return $this->id; } + public function getName(): string { return $this->name; } + public function getStatus(): string { return $this->status; } + public function getProgress(): float { return $this->progress; } + public function getPid(): ?int { return $this->pid; } + public function getStages(): array { return $this->stages; } + public function getInputSource(): ?string { return $this->inputSource; } + + public function setStatus(string $status): void { $this->status = $status; } + public function setProgress(float $progress): void { $this->progress = min(100, max(0, $progress)); } + public function setPid(?int $pid): void { $this->pid = $pid; } + public function setInputSource(string $source): void { $this->inputSource = $source; } + + public function addStage(PipelineStage $stage): self + { + $this->stages[] = $stage; + return $this; + } + + public function removeStage(int $index): self + { + if (isset($this->stages[$index])) { + array_splice($this->stages, $index, 1); + } + return $this; + } + + public function insertStage(int $index, PipelineStage $stage): self + { + array_splice($this->stages, $index, 0, [$stage]); + return $this; + } + + public function setMetadata(string $key, mixed $value): void + { + $this->metadata[$key] = $value; + } + + public function getMetadata(?string $key = null): mixed + { + if ($key === null) return $this->metadata; + return $this->metadata[$key] ?? null; + } + + public function buildFFmpegCommand(string $inputPath, string $outputPath): string + { + $config = require __DIR__ . '/../../config/app.php'; + $cmd = $config['ffmpeg']['binary']; + $parts = ["-y -i " . escapeshellarg($inputPath)]; + + foreach ($this->stages as $stage) { + $parts[] = $stage->toFFmpegArgs(); + } + + $parts[] = escapeshellarg($outputPath); + return $cmd . ' ' . implode(' ', $parts); + } + + public function buildStreamCommand(string $inputUrl, string $outputUrl): string + { + $config = require __DIR__ . '/../../config/app.php'; + $cmd = $config['ffmpeg']['binary']; + $parts = ["-re -i " . escapeshellarg($inputUrl)]; + + foreach ($this->stages as $stage) { + $parts[] = $stage->toFFmpegArgs(); + } + + $parts[] = "-f flv " . escapeshellarg($outputUrl); + return $cmd . ' ' . implode(' ', $parts); + } + + public function toArray(): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'status' => $this->status, + 'progress' => $this->progress, + 'pid' => $this->pid, + 'input_source' => $this->inputSource, + 'stages' => array_map(fn(PipelineStage $s) => $s->toArray(), $this->stages), + 'metadata' => $this->metadata, + 'created_at' => $this->createdAt, + ]; + } + + public static function fromArray(array $data): self + { + $pipeline = new self($data['name'], $data['id']); + $pipeline->status = $data['status'] ?? 'idle'; + $pipeline->progress = $data['progress'] ?? 0; + $pipeline->pid = $data['pid'] ?? null; + $pipeline->inputSource = $data['input_source'] ?? null; + $pipeline->metadata = $data['metadata'] ?? []; + $pipeline->createdAt = $data['created_at'] ?? date('c'); + + foreach (($data['stages'] ?? []) as $stageData) { + $pipeline->addStage(PipelineStage::fromArray($stageData)); + } + + return $pipeline; + } +} diff --git a/video-converter-suite/src/Pipeline/PipelineManager.php b/video-converter-suite/src/Pipeline/PipelineManager.php new file mode 100644 index 0000000..af78cea --- /dev/null +++ b/video-converter-suite/src/Pipeline/PipelineManager.php @@ -0,0 +1,89 @@ +stateFile = __DIR__ . '/../../storage/temp/pipelines.json'; + $this->load(); + } + + private function load(): void + { + if (file_exists($this->stateFile)) { + $data = json_decode(file_get_contents($this->stateFile), true); + foreach (($data['pipelines'] ?? []) as $pData) { + $this->pipelines[$pData['id']] = Pipeline::fromArray($pData); + } + } + } + + public function save(): void + { + $dir = dirname($this->stateFile); + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + } + $data = ['pipelines' => []]; + foreach ($this->pipelines as $pipeline) { + $data['pipelines'][] = $pipeline->toArray(); + } + file_put_contents($this->stateFile, json_encode($data, JSON_PRETTY_PRINT)); + } + + public function create(string $name): Pipeline + { + $pipeline = new Pipeline($name); + $this->pipelines[$pipeline->getId()] = $pipeline; + $this->save(); + return $pipeline; + } + + public function get(string $id): ?Pipeline + { + return $this->pipelines[$id] ?? null; + } + + public function getAll(): array + { + return $this->pipelines; + } + + public function delete(string $id): bool + { + if (isset($this->pipelines[$id])) { + $pipeline = $this->pipelines[$id]; + if ($pipeline->getStatus() === 'running' && $pipeline->getPid()) { + posix_kill($pipeline->getPid(), SIGTERM); + } + unset($this->pipelines[$id]); + $this->save(); + return true; + } + return false; + } + + public function getRunningCount(): int + { + $count = 0; + foreach ($this->pipelines as $p) { + if ($p->getStatus() === 'running') $count++; + } + return $count; + } + + public function getByStatus(string $status): array + { + return array_filter($this->pipelines, fn(Pipeline $p) => $p->getStatus() === $status); + } + + public function toArray(): array + { + return array_map(fn(Pipeline $p) => $p->toArray(), array_values($this->pipelines)); + } +} diff --git a/video-converter-suite/src/Pipeline/PipelineStage.php b/video-converter-suite/src/Pipeline/PipelineStage.php new file mode 100644 index 0000000..8b1a04f --- /dev/null +++ b/video-converter-suite/src/Pipeline/PipelineStage.php @@ -0,0 +1,197 @@ +id = bin2hex(random_bytes(4)); + $this->type = $type; + $this->params = $params; + $this->label = $label ?: ucfirst($type); + $this->enabled = $enabled; + } + + public function getId(): string { return $this->id; } + public function getType(): string { return $this->type; } + public function getParams(): array { return $this->params; } + public function isEnabled(): bool { return $this->enabled; } + public function getLabel(): string { return $this->label; } + + public function setEnabled(bool $enabled): void { $this->enabled = $enabled; } + public function setParams(array $params): void { $this->params = $params; } + + public function toFFmpegArgs(): string + { + if (!$this->enabled) return ''; + + return match ($this->type) { + 'transcode' => $this->buildTranscodeArgs(), + 'scale' => $this->buildScaleArgs(), + 'filter' => $this->buildFilterArgs(), + 'audio' => $this->buildAudioArgs(), + 'watermark' => $this->buildWatermarkArgs(), + 'trim' => $this->buildTrimArgs(), + 'bitrate' => $this->buildBitrateArgs(), + 'framerate' => $this->buildFramerateArgs(), + 'deinterlace' => '-vf yadif', + 'denoise' => '-vf hqdn3d', + 'stabilize' => '-vf deshake', + default => '', + }; + } + + private function buildTranscodeArgs(): string + { + $args = []; + if (isset($this->params['video_codec'])) { + $args[] = "-c:v " . escapeshellarg($this->params['video_codec']); + } + if (isset($this->params['audio_codec'])) { + $args[] = "-c:a " . escapeshellarg($this->params['audio_codec']); + } + if (isset($this->params['preset'])) { + $args[] = "-preset " . escapeshellarg($this->params['preset']); + } + if (isset($this->params['crf'])) { + $args[] = "-crf " . (int)$this->params['crf']; + } + return implode(' ', $args); + } + + private function buildScaleArgs(): string + { + $w = (int)($this->params['width'] ?? -1); + $h = (int)($this->params['height'] ?? -1); + $algo = $this->params['algorithm'] ?? 'lanczos'; + return "-vf scale={$w}:{$h}:flags={$algo}"; + } + + private function buildFilterArgs(): string + { + $filters = []; + if (isset($this->params['brightness'])) { + $filters[] = "eq=brightness=" . (float)$this->params['brightness']; + } + if (isset($this->params['contrast'])) { + $filters[] = "eq=contrast=" . (float)$this->params['contrast']; + } + if (isset($this->params['saturation'])) { + $filters[] = "eq=saturation=" . (float)$this->params['saturation']; + } + if (isset($this->params['gamma'])) { + $filters[] = "eq=gamma=" . (float)$this->params['gamma']; + } + if (isset($this->params['custom'])) { + $filters[] = $this->params['custom']; + } + return $filters ? '-vf ' . escapeshellarg(implode(',', $filters)) : ''; + } + + private function buildAudioArgs(): string + { + $args = []; + if (isset($this->params['codec'])) { + $args[] = "-c:a " . escapeshellarg($this->params['codec']); + } + if (isset($this->params['bitrate'])) { + $args[] = "-b:a " . escapeshellarg($this->params['bitrate']); + } + if (isset($this->params['sample_rate'])) { + $args[] = "-ar " . (int)$this->params['sample_rate']; + } + if (isset($this->params['channels'])) { + $args[] = "-ac " . (int)$this->params['channels']; + } + if (isset($this->params['volume'])) { + $args[] = "-af volume=" . (float)$this->params['volume']; + } + return implode(' ', $args); + } + + private function buildWatermarkArgs(): string + { + $image = $this->params['image'] ?? ''; + $position = $this->params['position'] ?? 'topright'; + $overlay = match ($position) { + 'topleft' => 'overlay=10:10', + 'topright' => 'overlay=W-w-10:10', + 'bottomleft' => 'overlay=10:H-h-10', + 'bottomright' => 'overlay=W-w-10:H-h-10', + 'center' => 'overlay=(W-w)/2:(H-h)/2', + default => 'overlay=W-w-10:10', + }; + return "-i " . escapeshellarg($image) . " -filter_complex \"{$overlay}\""; + } + + private function buildTrimArgs(): string + { + $args = []; + if (isset($this->params['start'])) { + $args[] = "-ss " . escapeshellarg($this->params['start']); + } + if (isset($this->params['duration'])) { + $args[] = "-t " . escapeshellarg($this->params['duration']); + } + if (isset($this->params['end'])) { + $args[] = "-to " . escapeshellarg($this->params['end']); + } + return implode(' ', $args); + } + + private function buildBitrateArgs(): string + { + $args = []; + if (isset($this->params['video'])) { + $args[] = "-b:v " . escapeshellarg($this->params['video']); + } + if (isset($this->params['audio'])) { + $args[] = "-b:a " . escapeshellarg($this->params['audio']); + } + if (isset($this->params['maxrate'])) { + $args[] = "-maxrate " . escapeshellarg($this->params['maxrate']); + $args[] = "-bufsize " . escapeshellarg($this->params['bufsize'] ?? $this->params['maxrate']); + } + return implode(' ', $args); + } + + private function buildFramerateArgs(): string + { + $fps = (float)($this->params['fps'] ?? 30); + return "-r {$fps}"; + } + + public function toArray(): array + { + return [ + 'id' => $this->id, + 'type' => $this->type, + 'label' => $this->label, + 'params' => $this->params, + 'enabled' => $this->enabled, + ]; + } + + public static function fromArray(array $data): self + { + $stage = new self( + $data['type'], + $data['params'] ?? [], + $data['label'] ?? '', + $data['enabled'] ?? true + ); + if (isset($data['id'])) { + // Use reflection to set the id for deserialization + $ref = new \ReflectionProperty($stage, 'id'); + $ref->setValue($stage, $data['id']); + } + return $stage; + } +} diff --git a/video-converter-suite/src/Process/FFmpegProcess.php b/video-converter-suite/src/Process/FFmpegProcess.php new file mode 100644 index 0000000..56d4705 --- /dev/null +++ b/video-converter-suite/src/Process/FFmpegProcess.php @@ -0,0 +1,159 @@ +command = $command; + $logDir = $config['storage']['logs']; + if (!is_dir($logDir)) mkdir($logDir, 0755, true); + $this->logFile = $logDir . "/ffmpeg_{$jobId}.log"; + $this->progressFile = $logDir . "/progress_{$jobId}.txt"; + } + + public function start(): bool + { + $cmd = $this->command + . " -progress " . escapeshellarg($this->progressFile) + . " -stats_period 0.5" + . " 2>" . escapeshellarg($this->logFile) + . " & echo $!"; + + $output = []; + exec($cmd, $output); + $this->pid = (int)($output[0] ?? 0); + + if ($this->pid > 0) { + $this->status = 'running'; + return true; + } + $this->status = 'error'; + return false; + } + + public function stop(): void + { + if ($this->pid && $this->isRunning()) { + posix_kill($this->pid, SIGTERM); + usleep(500000); + if ($this->isRunning()) { + posix_kill($this->pid, SIGKILL); + } + } + $this->status = 'stopped'; + } + + public function pause(): void + { + if ($this->pid && $this->isRunning()) { + posix_kill($this->pid, SIGSTOP); + $this->status = 'paused'; + } + } + + public function resume(): void + { + if ($this->pid) { + posix_kill($this->pid, SIGCONT); + $this->status = 'running'; + } + } + + public function isRunning(): bool + { + if (!$this->pid) return false; + return posix_kill($this->pid, 0); + } + + public function getProgress(): array + { + $progress = [ + 'percent' => 0, + 'frame' => 0, + 'fps' => 0, + 'speed' => '0x', + 'time' => '00:00:00.00', + 'bitrate' => '0kbits/s', + 'size' => '0kB', + ]; + + if (!file_exists($this->progressFile)) return $progress; + + $content = file_get_contents($this->progressFile); + $lines = explode("\n", $content); + + foreach ($lines as $line) { + $line = trim($line); + if (str_contains($line, '=')) { + [$key, $value] = explode('=', $line, 2); + $key = trim($key); + $value = trim($value); + + switch ($key) { + case 'frame': $progress['frame'] = (int)$value; break; + case 'fps': $progress['fps'] = (float)$value; break; + case 'speed': $progress['speed'] = $value; break; + case 'out_time': $progress['time'] = $value; break; + case 'total_size': $progress['size'] = $this->formatBytes((int)$value); break; + case 'bitrate': $progress['bitrate'] = $value; break; + case 'progress': + if ($value === 'end') $progress['percent'] = 100; + break; + } + } + } + + if ($this->duration > 0 && $progress['percent'] < 100) { + $currentTime = $this->timeToSeconds($progress['time']); + $progress['percent'] = min(99, round(($currentTime / $this->duration) * 100, 1)); + } + + return $progress; + } + + public function getLog(int $lines = 50): string + { + if (!file_exists($this->logFile)) return ''; + $all = file($this->logFile); + return implode('', array_slice($all, -$lines)); + } + + public function setDuration(float $duration): void + { + $this->duration = $duration; + } + + public function getPid(): ?int { return $this->pid; } + public function getStatus(): string { return $this->status; } + public function getCommand(): string { return $this->command; } + + private function timeToSeconds(string $time): float + { + $parts = explode(':', $time); + if (count($parts) !== 3) return 0; + return (int)$parts[0] * 3600 + (int)$parts[1] * 60 + (float)$parts[2]; + } + + private function formatBytes(int $bytes): string + { + $units = ['B', 'KB', 'MB', 'GB']; + $i = 0; + $size = (float)$bytes; + while ($size >= 1024 && $i < count($units) - 1) { + $size /= 1024; + $i++; + } + return round($size, 1) . $units[$i]; + } +} diff --git a/video-converter-suite/src/Process/MediaProbe.php b/video-converter-suite/src/Process/MediaProbe.php new file mode 100644 index 0000000..32b60c1 --- /dev/null +++ b/video-converter-suite/src/Process/MediaProbe.php @@ -0,0 +1,106 @@ +ffprobe = $config['ffmpeg']['ffprobe']; + } + + public function analyze(string $filePath): array + { + $cmd = sprintf( + '%s -v quiet -print_format json -show_format -show_streams %s', + $this->ffprobe, + escapeshellarg($filePath) + ); + + $output = shell_exec($cmd); + $data = json_decode($output ?: '{}', true); + + if (!$data) { + return ['error' => 'Could not analyze file']; + } + + return $this->parseProbeData($data); + } + + public function getDuration(string $filePath): float + { + $info = $this->analyze($filePath); + return (float)($info['duration'] ?? 0); + } + + public function getThumbnail(string $filePath, string $outputPath, string $time = '00:00:01'): bool + { + $config = require __DIR__ . '/../../config/app.php'; + $cmd = sprintf( + '%s -y -i %s -ss %s -vframes 1 -vf scale=320:-1 %s 2>/dev/null', + $config['ffmpeg']['binary'], + escapeshellarg($filePath), + escapeshellarg($time), + escapeshellarg($outputPath) + ); + exec($cmd, $output, $exitCode); + return $exitCode === 0; + } + + private function parseProbeData(array $data): array + { + $result = [ + 'format' => $data['format']['format_long_name'] ?? 'Unknown', + 'format_name' => $data['format']['format_name'] ?? '', + 'duration' => (float)($data['format']['duration'] ?? 0), + 'size' => (int)($data['format']['size'] ?? 0), + 'bitrate' => (int)($data['format']['bit_rate'] ?? 0), + 'streams' => [], + 'video' => null, + 'audio' => null, + ]; + + foreach (($data['streams'] ?? []) as $stream) { + $type = $stream['codec_type'] ?? ''; + $info = [ + 'index' => $stream['index'], + 'type' => $type, + 'codec' => $stream['codec_name'] ?? 'unknown', + 'codec_long' => $stream['codec_long_name'] ?? '', + ]; + + if ($type === 'video') { + $info['width'] = (int)($stream['width'] ?? 0); + $info['height'] = (int)($stream['height'] ?? 0); + $info['fps'] = $this->parseFps($stream['r_frame_rate'] ?? '0/1'); + $info['pix_fmt'] = $stream['pix_fmt'] ?? ''; + $info['bitrate'] = (int)($stream['bit_rate'] ?? 0); + $info['profile'] = $stream['profile'] ?? ''; + $info['level'] = $stream['level'] ?? ''; + if (!$result['video']) $result['video'] = $info; + } elseif ($type === 'audio') { + $info['sample_rate'] = (int)($stream['sample_rate'] ?? 0); + $info['channels'] = (int)($stream['channels'] ?? 0); + $info['channel_layout'] = $stream['channel_layout'] ?? ''; + $info['bitrate'] = (int)($stream['bit_rate'] ?? 0); + if (!$result['audio']) $result['audio'] = $info; + } + + $result['streams'][] = $info; + } + + return $result; + } + + private function parseFps(string $frac): float + { + $parts = explode('/', $frac); + if (count($parts) === 2 && (int)$parts[1] > 0) { + return round((int)$parts[0] / (int)$parts[1], 2); + } + return (float)$frac; + } +} diff --git a/video-converter-suite/src/Queue/JobQueue.php b/video-converter-suite/src/Queue/JobQueue.php new file mode 100644 index 0000000..c95fb8c --- /dev/null +++ b/video-converter-suite/src/Queue/JobQueue.php @@ -0,0 +1,115 @@ +queueFile = __DIR__ . '/../../storage/temp/queue.json'; + $this->load(); + } + + private function load(): void + { + if (file_exists($this->queueFile)) { + $this->queue = json_decode(file_get_contents($this->queueFile), true) ?: []; + } + } + + private function save(): void + { + $dir = dirname($this->queueFile); + if (!is_dir($dir)) mkdir($dir, 0755, true); + file_put_contents($this->queueFile, json_encode($this->queue, JSON_PRETTY_PRINT)); + } + + public function enqueue(array $job): string + { + $id = bin2hex(random_bytes(8)); + $job['queue_id'] = $id; + $job['queued_at'] = date('c'); + $job['queue_status'] = 'waiting'; + $job['priority'] = $job['priority'] ?? 5; + + $this->queue[] = $job; + + // Sort by priority (lower = higher priority) + usort($this->queue, fn($a, $b) => ($a['priority'] ?? 5) <=> ($b['priority'] ?? 5)); + + $this->save(); + return $id; + } + + public function dequeue(): ?array + { + foreach ($this->queue as &$job) { + if ($job['queue_status'] === 'waiting') { + $job['queue_status'] = 'processing'; + $job['started_at'] = date('c'); + $this->save(); + return $job; + } + } + return null; + } + + public function complete(string $queueId, array $result = []): void + { + foreach ($this->queue as &$job) { + if ($job['queue_id'] === $queueId) { + $job['queue_status'] = 'completed'; + $job['completed_at'] = date('c'); + $job['result'] = $result; + break; + } + } + $this->save(); + } + + public function fail(string $queueId, string $error): void + { + foreach ($this->queue as &$job) { + if ($job['queue_id'] === $queueId) { + $job['queue_status'] = 'failed'; + $job['failed_at'] = date('c'); + $job['error'] = $error; + break; + } + } + $this->save(); + } + + public function getQueue(): array { return $this->queue; } + + public function getWaiting(): array + { + return array_values(array_filter($this->queue, fn($j) => $j['queue_status'] === 'waiting')); + } + + public function getProcessing(): array + { + return array_values(array_filter($this->queue, fn($j) => $j['queue_status'] === 'processing')); + } + + public function clear(string $status = 'completed'): int + { + $before = count($this->queue); + $this->queue = array_values(array_filter($this->queue, fn($j) => $j['queue_status'] !== $status)); + $this->save(); + return $before - count($this->queue); + } + + public function getStats(): array + { + $stats = ['waiting' => 0, 'processing' => 0, 'completed' => 0, 'failed' => 0]; + foreach ($this->queue as $job) { + $status = $job['queue_status'] ?? 'waiting'; + $stats[$status] = ($stats[$status] ?? 0) + 1; + } + return $stats; + } +} diff --git a/video-converter-suite/src/Stream/StreamManager.php b/video-converter-suite/src/Stream/StreamManager.php new file mode 100644 index 0000000..6140c96 --- /dev/null +++ b/video-converter-suite/src/Stream/StreamManager.php @@ -0,0 +1,197 @@ +stateFile = __DIR__ . '/../../storage/temp/streams.json'; + $this->load(); + } + + private function load(): void + { + if (file_exists($this->stateFile)) { + $this->activeStreams = json_decode(file_get_contents($this->stateFile), true) ?: []; + } + } + + private function save(): void + { + $dir = dirname($this->stateFile); + if (!is_dir($dir)) mkdir($dir, 0755, true); + file_put_contents($this->stateFile, json_encode($this->activeStreams, JSON_PRETTY_PRINT)); + } + + public function startStream(array $params): array + { + $id = bin2hex(random_bytes(8)); + $config = require __DIR__ . '/../../config/app.php'; + + $inputUrl = $params['input_url'] ?? ''; + $outputFormat = $params['output_format'] ?? 'mp4'; + $resolution = $params['resolution'] ?? null; + $preset = $params['preset'] ?? 'fast'; + + $formatConfig = $config['formats']['video'][$outputFormat] ?? $config['formats']['video']['mp4']; + $presetConfig = $config['presets'][$preset] ?? $config['presets']['fast']; + + $outputDir = $config['storage']['outputs']; + $outputFile = "{$outputDir}/stream_{$id}.{$formatConfig['ext']}"; + + $cmd = $config['ffmpeg']['binary'] . " -y"; + + // Input + if (str_starts_with($inputUrl, 'rtmp://') || str_starts_with($inputUrl, 'rtsp://')) { + $cmd .= " -re"; + } + $cmd .= " -i " . escapeshellarg($inputUrl); + + // Video codec + $cmd .= " -c:v " . escapeshellarg($formatConfig['codec']); + $cmd .= " -preset " . escapeshellarg($presetConfig['preset']); + $cmd .= " -crf " . (int)$presetConfig['crf']; + + // Resolution + if ($resolution && isset($config['resolutions'][$resolution])) { + $res = $config['resolutions'][$resolution]; + $cmd .= " -vf scale={$res['width']}:{$res['height']}"; + } + + // Audio + $audioCodec = $params['audio_codec'] ?? 'aac'; + $audioBitrate = $params['audio_bitrate'] ?? '128k'; + $cmd .= " -c:a " . escapeshellarg($audioCodec); + $cmd .= " -b:a " . escapeshellarg($audioBitrate); + + $cmd .= " " . escapeshellarg($outputFile); + + $process = new FFmpegProcess($cmd, $id); + + $stream = [ + 'id' => $id, + 'input_url' => $inputUrl, + 'output_file' => $outputFile, + 'output_format' => $outputFormat, + 'resolution' => $resolution, + 'preset' => $preset, + 'status' => 'starting', + 'pid' => null, + 'command' => $cmd, + 'started_at' => date('c'), + ]; + + if ($process->start()) { + $stream['status'] = 'running'; + $stream['pid'] = $process->getPid(); + } else { + $stream['status'] = 'error'; + } + + $this->activeStreams[$id] = $stream; + $this->save(); + + return $stream; + } + + public function stopStream(string $id): bool + { + if (!isset($this->activeStreams[$id])) return false; + + $stream = $this->activeStreams[$id]; + if ($stream['pid']) { + posix_kill($stream['pid'], SIGTERM); + usleep(500000); + if (posix_kill($stream['pid'], 0)) { + posix_kill($stream['pid'], SIGKILL); + } + } + + $this->activeStreams[$id]['status'] = 'stopped'; + $this->activeStreams[$id]['stopped_at'] = date('c'); + $this->save(); + return true; + } + + public function switchFormat(string $id, string $newFormat, ?string $resolution = null): array + { + if (!isset($this->activeStreams[$id])) { + return ['error' => 'Stream not found']; + } + + $oldStream = $this->activeStreams[$id]; + $this->stopStream($id); + + // Start new stream with same input but different output format + return $this->startStream([ + 'input_url' => $oldStream['input_url'], + 'output_format' => $newFormat, + 'resolution' => $resolution ?? $oldStream['resolution'], + 'preset' => $oldStream['preset'], + 'audio_codec' => 'aac', + ]); + } + + public function getStream(string $id): ?array + { + $this->refreshStatus($id); + return $this->activeStreams[$id] ?? null; + } + + public function getAllStreams(): array + { + foreach (array_keys($this->activeStreams) as $id) { + $this->refreshStatus($id); + } + return array_values($this->activeStreams); + } + + public function getActiveStreams(): array + { + return array_values(array_filter($this->getAllStreams(), fn($s) => $s['status'] === 'running')); + } + + private function refreshStatus(string $id): void + { + if (!isset($this->activeStreams[$id])) return; + $stream = &$this->activeStreams[$id]; + + if ($stream['status'] === 'running' && $stream['pid']) { + if (!posix_kill($stream['pid'], 0)) { + $stream['status'] = 'completed'; + $stream['completed_at'] = date('c'); + $this->save(); + } + } + } + + public function deleteStream(string $id): bool + { + if (isset($this->activeStreams[$id])) { + if ($this->activeStreams[$id]['status'] === 'running') { + $this->stopStream($id); + } + unset($this->activeStreams[$id]); + $this->save(); + return true; + } + return false; + } + + public function getStats(): array + { + $all = $this->getAllStreams(); + return [ + 'total' => count($all), + 'running' => count(array_filter($all, fn($s) => $s['status'] === 'running')), + 'completed' => count(array_filter($all, fn($s) => $s['status'] === 'completed')), + 'errors' => count(array_filter($all, fn($s) => $s['status'] === 'error')), + ]; + } +} diff --git a/video-converter-suite/src/WebSocket/StatusServer.php b/video-converter-suite/src/WebSocket/StatusServer.php new file mode 100644 index 0000000..02e3095 --- /dev/null +++ b/video-converter-suite/src/WebSocket/StatusServer.php @@ -0,0 +1,182 @@ +clients = new \SplObjectStorage(); + $this->converter = new FormatConverter(); + $this->streamManager = new StreamManager(); + $this->pipelineManager = new PipelineManager(); + $this->queue = new JobQueue(); + } + + public function onOpen(ConnectionInterface $conn): void + { + $this->clients->attach($conn); + $conn->send(json_encode([ + 'type' => 'connected', + 'message' => 'Connected to Video Converter Suite', + 'client_id' => spl_object_id($conn), + ])); + } + + public function onMessage(ConnectionInterface $from, $msg): void + { + $data = json_decode($msg, true); + if (!$data || !isset($data['action'])) return; + + $response = match ($data['action']) { + 'get_status' => $this->getFullStatus(), + 'get_jobs' => ['type' => 'jobs', 'data' => $this->converter->getAllJobs()], + 'get_streams' => ['type' => 'streams', 'data' => $this->streamManager->getAllStreams()], + 'get_pipelines' => ['type' => 'pipelines', 'data' => $this->pipelineManager->toArray()], + 'get_queue' => ['type' => 'queue', 'data' => $this->queue->getQueue()], + 'get_progress' => $this->getJobProgress($data['job_id'] ?? ''), + 'start_stream' => $this->handleStartStream($data), + 'stop_stream' => $this->handleStopStream($data['stream_id'] ?? ''), + 'switch_format' => $this->handleSwitchFormat($data), + default => ['type' => 'error', 'message' => 'Unknown action'], + }; + + $from->send(json_encode($response)); + } + + public function onClose(ConnectionInterface $conn): void + { + $this->clients->detach($conn); + } + + public function onError(ConnectionInterface $conn, \Exception $e): void + { + $conn->send(json_encode([ + 'type' => 'error', + 'message' => $e->getMessage(), + ])); + $conn->close(); + } + + public function broadcastStatus(): void + { + $status = $this->getFullStatus(); + $json = json_encode($status); + + foreach ($this->clients as $client) { + $client->send($json); + } + } + + private function getFullStatus(): array + { + // Reload state + $this->converter = new FormatConverter(); + $this->streamManager = new StreamManager(); + $this->pipelineManager = new PipelineManager(); + $this->queue = new JobQueue(); + + $jobs = $this->converter->getAllJobs(); + $runningJobs = array_filter($jobs, fn($j) => $j['status'] === 'running'); + + $progressData = []; + foreach ($runningJobs as $job) { + $progressData[$job['id']] = $this->converter->getProgress($job['id']); + } + + return [ + 'type' => 'status', + 'timestamp' => date('c'), + 'system' => $this->getSystemStats(), + 'jobs' => $jobs, + 'progress' => $progressData, + 'streams' => $this->streamManager->getAllStreams(), + 'pipelines' => $this->pipelineManager->toArray(), + 'queue' => $this->queue->getStats(), + ]; + } + + private function getJobProgress(string $jobId): array + { + $this->converter = new FormatConverter(); + return [ + 'type' => 'progress', + 'job_id' => $jobId, + 'data' => $this->converter->getProgress($jobId), + ]; + } + + private function handleStartStream(array $data): array + { + $this->streamManager = new StreamManager(); + $result = $this->streamManager->startStream($data); + return ['type' => 'stream_started', 'data' => $result]; + } + + private function handleStopStream(string $streamId): array + { + $this->streamManager = new StreamManager(); + $success = $this->streamManager->stopStream($streamId); + return ['type' => 'stream_stopped', 'success' => $success, 'stream_id' => $streamId]; + } + + private function handleSwitchFormat(array $data): array + { + $this->streamManager = new StreamManager(); + $result = $this->streamManager->switchFormat( + $data['stream_id'] ?? '', + $data['format'] ?? 'mp4', + $data['resolution'] ?? null + ); + return ['type' => 'format_switched', 'data' => $result]; + } + + private function getSystemStats(): array + { + $load = sys_getloadavg(); + $memInfo = $this->getMemoryInfo(); + + return [ + 'cpu_load' => $load[0] ?? 0, + 'memory_used' => $memInfo['used'] ?? 0, + 'memory_total' => $memInfo['total'] ?? 0, + 'memory_percent' => $memInfo['percent'] ?? 0, + 'disk_free' => disk_free_space('/'), + 'disk_total' => disk_total_space('/'), + 'uptime' => (int)(file_exists('/proc/uptime') + ? (float)explode(' ', file_get_contents('/proc/uptime'))[0] + : 0), + ]; + } + + private function getMemoryInfo(): array + { + if (!file_exists('/proc/meminfo')) { + return ['total' => 0, 'used' => 0, 'percent' => 0]; + } + $content = file_get_contents('/proc/meminfo'); + preg_match('/MemTotal:\s+(\d+)/', $content, $total); + preg_match('/MemAvailable:\s+(\d+)/', $content, $available); + $t = (int)($total[1] ?? 0) * 1024; + $a = (int)($available[1] ?? 0) * 1024; + $u = $t - $a; + return [ + 'total' => $t, + 'used' => $u, + 'percent' => $t > 0 ? round(($u / $t) * 100, 1) : 0, + ]; + } +} diff --git a/video-converter-suite/storage/logs/.gitkeep b/video-converter-suite/storage/logs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/video-converter-suite/storage/outputs/.gitkeep b/video-converter-suite/storage/outputs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/video-converter-suite/storage/temp/.gitkeep b/video-converter-suite/storage/temp/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/video-converter-suite/storage/thumbnails/.gitkeep b/video-converter-suite/storage/thumbnails/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/video-converter-suite/storage/uploads/.gitkeep b/video-converter-suite/storage/uploads/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/video-converter-suite/templates/dashboard.php b/video-converter-suite/templates/dashboard.php new file mode 100644 index 0000000..8b50dbc --- /dev/null +++ b/video-converter-suite/templates/dashboard.php @@ -0,0 +1,447 @@ + + + + + + <?= $config['app_name'] ?> - Control Panel + + + + + + + +
+ +
+
+ + SYSTEM ONLINE +
+
+ JOBS: + 0 +
+
00:00:00
+
+
+ + + + + +
+ + +
+ +
+
+
+
SYSTEM MONITOR
+ +
+
+
+
+
CPU Load
+
0.0
+
Load Avg
+
+
+
+
Memory
+
0
+
% Used
+
+
+
+
Disk
+
0
+
GB Free
+
+
+
+
Active Jobs
+
0
+
Running
+
+
+
+
+
+
+ + +
+
+
+
AKTIVE JOBS
+ +
+
+
+
+ Keine aktiven Jobs +
+
+
+
+
+
+
SYSTEM LOG
+ +
+
+
+
[INIT] Video Converter Suite gestartet
+
+
+
+
+
+ + + + + + + + + + + + +
+ + + + + +
+ + + +