Merge pull request #51 from metacube2/claude/mail-finetuning-webapp-01BsRXQNeVFrCBky8aw35YHw
asdf
This commit is contained in:
+4001
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
FROM php:8.2-cli
|
||||||
|
|
||||||
|
# Install FFmpeg and dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
ffmpeg \
|
||||||
|
libzip-dev \
|
||||||
|
unzip \
|
||||||
|
git \
|
||||||
|
&& docker-php-ext-install pcntl posix sockets \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Composer
|
||||||
|
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy composer files first for caching
|
||||||
|
COPY composer.json ./
|
||||||
|
RUN composer install --no-dev --optimize-autoloader 2>/dev/null || true
|
||||||
|
|
||||||
|
# Copy application
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN composer install --no-dev --optimize-autoloader
|
||||||
|
|
||||||
|
# Create storage directories
|
||||||
|
RUN mkdir -p storage/uploads storage/outputs storage/thumbnails storage/logs storage/temp \
|
||||||
|
&& chmod -R 777 storage
|
||||||
|
|
||||||
|
# Configure PHP
|
||||||
|
RUN echo "upload_max_filesize = 5G\n\
|
||||||
|
post_max_size = 5G\n\
|
||||||
|
memory_limit = 512M\n\
|
||||||
|
max_execution_time = 3600\n\
|
||||||
|
max_input_time = 3600" > /usr/local/etc/php/conf.d/video-converter.ini
|
||||||
|
|
||||||
|
EXPOSE 8080 8081
|
||||||
|
|
||||||
|
CMD ["php", "-S", "0.0.0.0:8080", "-t", "public", "public/router.php"]
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Video Converter Suite - Queue Worker
|
||||||
|
*
|
||||||
|
* Processes jobs from the queue sequentially.
|
||||||
|
* Usage: php bin/queue-worker.php
|
||||||
|
*/
|
||||||
|
|
||||||
|
spl_autoload_register(function ($class) {
|
||||||
|
$prefix = 'VideoConverter\\';
|
||||||
|
$baseDir = __DIR__ . '/../src/';
|
||||||
|
$len = strlen($prefix);
|
||||||
|
if (strncmp($prefix, $class, $len) !== 0) return;
|
||||||
|
$relative = substr($class, $len);
|
||||||
|
$file = $baseDir . str_replace('\\', '/', $relative) . '.php';
|
||||||
|
if (file_exists($file)) require $file;
|
||||||
|
});
|
||||||
|
|
||||||
|
use VideoConverter\Queue\JobQueue;
|
||||||
|
use VideoConverter\Format\FormatConverter;
|
||||||
|
|
||||||
|
$config = require __DIR__ . '/../config/app.php';
|
||||||
|
|
||||||
|
echo "=== Video Converter Suite - Queue Worker ===\n";
|
||||||
|
echo "Max concurrent: {$config['limits']['max_concurrent_jobs']}\n\n";
|
||||||
|
|
||||||
|
$queue = new JobQueue();
|
||||||
|
$converter = new FormatConverter();
|
||||||
|
$running = 0;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
$queue = new JobQueue(); // Reload state
|
||||||
|
$converter = new FormatConverter();
|
||||||
|
|
||||||
|
$activeJobs = array_filter($converter->getAllJobs(), fn($j) => $j['status'] === 'running');
|
||||||
|
$running = count($activeJobs);
|
||||||
|
|
||||||
|
if ($running < $config['limits']['max_concurrent_jobs']) {
|
||||||
|
$nextJob = $queue->dequeue();
|
||||||
|
if ($nextJob) {
|
||||||
|
echo "[" . date('H:i:s') . "] Processing: {$nextJob['queue_id']}\n";
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $converter->convert([
|
||||||
|
'input_file' => $nextJob['input_file'] ?? '',
|
||||||
|
'output_format' => $nextJob['output_format'] ?? 'mp4',
|
||||||
|
'preset' => $nextJob['preset'] ?? 'balanced',
|
||||||
|
'resolution' => $nextJob['resolution'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (isset($result['error'])) {
|
||||||
|
$queue->fail($nextJob['queue_id'], $result['error']);
|
||||||
|
echo "[" . date('H:i:s') . "] Failed: {$result['error']}\n";
|
||||||
|
} else {
|
||||||
|
$queue->complete($nextJob['queue_id'], $result);
|
||||||
|
echo "[" . date('H:i:s') . "] Started job: {$result['id']}\n";
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$queue->fail($nextJob['queue_id'], $e->getMessage());
|
||||||
|
echo "[" . date('H:i:s') . "] Error: {$e->getMessage()}\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sleep(2);
|
||||||
|
}
|
||||||
Executable
+101
@@ -0,0 +1,101 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Video Converter Suite - Startup Script
|
||||||
|
# Starts all services: Web Server, WebSocket Server, Queue Worker
|
||||||
|
|
||||||
|
echo "================================================"
|
||||||
|
echo " VIDEO CONVERTER SUITE - Starting Services"
|
||||||
|
echo "================================================"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$DIR"
|
||||||
|
|
||||||
|
# Create storage directories
|
||||||
|
mkdir -p storage/{uploads,outputs,thumbnails,logs,temp}
|
||||||
|
|
||||||
|
# Check FFmpeg
|
||||||
|
if command -v ffmpeg &> /dev/null; then
|
||||||
|
echo "[OK] FFmpeg: $(ffmpeg -version 2>&1 | head -1)"
|
||||||
|
else
|
||||||
|
echo "[!!] FFmpeg not found. Install with: apt install ffmpeg"
|
||||||
|
echo " The application will work but conversions will fail."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check PHP
|
||||||
|
if command -v php &> /dev/null; then
|
||||||
|
echo "[OK] PHP: $(php -v 2>&1 | head -1)"
|
||||||
|
else
|
||||||
|
echo "[!!] PHP not found."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install dependencies if needed
|
||||||
|
if [ ! -d "vendor" ]; then
|
||||||
|
echo ""
|
||||||
|
echo "Installing dependencies..."
|
||||||
|
if command -v composer &> /dev/null; then
|
||||||
|
composer install
|
||||||
|
else
|
||||||
|
echo "[!!] Composer not found. WebSocket server won't work."
|
||||||
|
echo " The web interface will still work without it."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Starting services..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Start Web Server
|
||||||
|
echo "[1/3] Web Server on http://localhost:8080"
|
||||||
|
php -S 0.0.0.0:8080 -t public public/router.php \
|
||||||
|
-d upload_max_filesize=5G \
|
||||||
|
-d post_max_size=5G \
|
||||||
|
-d memory_limit=512M \
|
||||||
|
-d max_execution_time=3600 \
|
||||||
|
> storage/logs/web.log 2>&1 &
|
||||||
|
WEB_PID=$!
|
||||||
|
|
||||||
|
# Start WebSocket Server (optional, requires Ratchet)
|
||||||
|
if [ -f "vendor/autoload.php" ]; then
|
||||||
|
echo "[2/3] WebSocket Server on ws://localhost:8081"
|
||||||
|
php bin/websocket-server.php > storage/logs/websocket.log 2>&1 &
|
||||||
|
WS_PID=$!
|
||||||
|
else
|
||||||
|
echo "[2/3] WebSocket Server: SKIPPED (run composer install first)"
|
||||||
|
WS_PID=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Start Queue Worker
|
||||||
|
echo "[3/3] Queue Worker"
|
||||||
|
php bin/queue-worker.php > storage/logs/worker.log 2>&1 &
|
||||||
|
WORKER_PID=$!
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "================================================"
|
||||||
|
echo " All services started!"
|
||||||
|
echo ""
|
||||||
|
echo " Web UI: http://localhost:8080"
|
||||||
|
echo " WebSocket: ws://localhost:8081"
|
||||||
|
echo ""
|
||||||
|
echo " PIDs: Web=$WEB_PID WS=$WS_PID Worker=$WORKER_PID"
|
||||||
|
echo " Logs: storage/logs/"
|
||||||
|
echo ""
|
||||||
|
echo " Press Ctrl+C to stop all services"
|
||||||
|
echo "================================================"
|
||||||
|
|
||||||
|
# Trap exit to kill all processes
|
||||||
|
cleanup() {
|
||||||
|
echo ""
|
||||||
|
echo "Stopping all services..."
|
||||||
|
kill $WEB_PID 2>/dev/null
|
||||||
|
[ -n "$WS_PID" ] && kill $WS_PID 2>/dev/null
|
||||||
|
kill $WORKER_PID 2>/dev/null
|
||||||
|
echo "All services stopped."
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
|
# Wait for any process to exit
|
||||||
|
wait
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Video Converter Suite - WebSocket Server
|
||||||
|
*
|
||||||
|
* Provides real-time status updates to connected clients.
|
||||||
|
* Usage: php bin/websocket-server.php
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../vendor/autoload.php';
|
||||||
|
|
||||||
|
use Ratchet\Server\IoServer;
|
||||||
|
use Ratchet\Http\HttpServer;
|
||||||
|
use Ratchet\WebSocket\WsServer;
|
||||||
|
use VideoConverter\WebSocket\StatusServer;
|
||||||
|
|
||||||
|
$config = require __DIR__ . '/../config/app.php';
|
||||||
|
$host = $config['websocket']['host'];
|
||||||
|
$port = $config['websocket']['port'];
|
||||||
|
|
||||||
|
echo "=== Video Converter Suite - WebSocket Server ===\n";
|
||||||
|
echo "Starting on {$host}:{$port}\n\n";
|
||||||
|
|
||||||
|
$statusServer = new StatusServer();
|
||||||
|
|
||||||
|
$server = IoServer::factory(
|
||||||
|
new HttpServer(
|
||||||
|
new WsServer($statusServer)
|
||||||
|
),
|
||||||
|
$port,
|
||||||
|
$host
|
||||||
|
);
|
||||||
|
|
||||||
|
// Broadcast status every 2 seconds
|
||||||
|
$server->loop->addPeriodicTimer(2, function () use ($statusServer) {
|
||||||
|
$statusServer->broadcastStatus();
|
||||||
|
});
|
||||||
|
|
||||||
|
echo "WebSocket server running. Press Ctrl+C to stop.\n";
|
||||||
|
$server->run();
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'app_name' => 'Video Converter Suite',
|
||||||
|
'version' => '1.0.0',
|
||||||
|
'debug' => true,
|
||||||
|
|
||||||
|
'ffmpeg' => [
|
||||||
|
'binary' => getenv('FFMPEG_PATH') ?: '/usr/bin/ffmpeg',
|
||||||
|
'ffprobe' => getenv('FFPROBE_PATH') ?: '/usr/bin/ffprobe',
|
||||||
|
'threads' => (int)(getenv('FFMPEG_THREADS') ?: 4),
|
||||||
|
'timeout' => 3600,
|
||||||
|
'nice' => 10,
|
||||||
|
],
|
||||||
|
|
||||||
|
'storage' => [
|
||||||
|
'uploads' => __DIR__ . '/../storage/uploads',
|
||||||
|
'outputs' => __DIR__ . '/../storage/outputs',
|
||||||
|
'thumbnails' => __DIR__ . '/../storage/thumbnails',
|
||||||
|
'logs' => __DIR__ . '/../storage/logs',
|
||||||
|
'temp' => __DIR__ . '/../storage/temp',
|
||||||
|
],
|
||||||
|
|
||||||
|
'limits' => [
|
||||||
|
'max_upload_size' => 5 * 1024 * 1024 * 1024, // 5 GB
|
||||||
|
'max_concurrent_jobs' => 3,
|
||||||
|
'max_pipeline_depth' => 10,
|
||||||
|
],
|
||||||
|
|
||||||
|
'websocket' => [
|
||||||
|
'host' => '0.0.0.0',
|
||||||
|
'port' => 8081,
|
||||||
|
],
|
||||||
|
|
||||||
|
'formats' => [
|
||||||
|
'video' => [
|
||||||
|
'mp4' => ['codec' => 'libx264', 'ext' => 'mp4', 'mime' => 'video/mp4'],
|
||||||
|
'webm' => ['codec' => 'libvpx-vp9', 'ext' => 'webm', 'mime' => 'video/webm'],
|
||||||
|
'mkv' => ['codec' => 'libx264', 'ext' => 'mkv', 'mime' => 'video/x-matroska'],
|
||||||
|
'avi' => ['codec' => 'mpeg4', 'ext' => 'avi', 'mime' => 'video/x-msvideo'],
|
||||||
|
'mov' => ['codec' => 'libx264', 'ext' => 'mov', 'mime' => 'video/quicktime'],
|
||||||
|
'flv' => ['codec' => 'flv1', 'ext' => 'flv', 'mime' => 'video/x-flv'],
|
||||||
|
'wmv' => ['codec' => 'wmv2', 'ext' => 'wmv', 'mime' => 'video/x-ms-wmv'],
|
||||||
|
'ts' => ['codec' => 'libx264', 'ext' => 'ts', 'mime' => 'video/mp2t'],
|
||||||
|
'hls' => ['codec' => 'libx264', 'ext' => 'm3u8', 'mime' => 'application/x-mpegURL'],
|
||||||
|
'dash' => ['codec' => 'libx264', 'ext' => 'mpd', 'mime' => 'application/dash+xml'],
|
||||||
|
],
|
||||||
|
'audio' => [
|
||||||
|
'aac' => ['codec' => 'aac', 'ext' => 'aac', 'mime' => 'audio/aac'],
|
||||||
|
'mp3' => ['codec' => 'libmp3lame', 'ext' => 'mp3', 'mime' => 'audio/mpeg'],
|
||||||
|
'ogg' => ['codec' => 'libvorbis', 'ext' => 'ogg', 'mime' => 'audio/ogg'],
|
||||||
|
'wav' => ['codec' => 'pcm_s16le', 'ext' => 'wav', 'mime' => 'audio/wav'],
|
||||||
|
'flac' => ['codec' => 'flac', 'ext' => 'flac', 'mime' => 'audio/flac'],
|
||||||
|
'opus' => ['codec' => 'libopus', 'ext' => 'opus', 'mime' => 'audio/opus'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'presets' => [
|
||||||
|
'ultrafast' => ['preset' => 'ultrafast', 'crf' => 28],
|
||||||
|
'fast' => ['preset' => 'fast', 'crf' => 23],
|
||||||
|
'balanced' => ['preset' => 'medium', 'crf' => 20],
|
||||||
|
'quality' => ['preset' => 'slow', 'crf' => 18],
|
||||||
|
'lossless' => ['preset' => 'veryslow', 'crf' => 0],
|
||||||
|
],
|
||||||
|
|
||||||
|
'resolutions' => [
|
||||||
|
'4k' => ['width' => 3840, 'height' => 2160, 'label' => '4K UHD'],
|
||||||
|
'1440p' => ['width' => 2560, 'height' => 1440, 'label' => '2K QHD'],
|
||||||
|
'1080p' => ['width' => 1920, 'height' => 1080, 'label' => 'Full HD'],
|
||||||
|
'720p' => ['width' => 1280, 'height' => 720, 'label' => 'HD'],
|
||||||
|
'480p' => ['width' => 854, 'height' => 480, 'label' => 'SD'],
|
||||||
|
'360p' => ['width' => 640, 'height' => 360, 'label' => 'Low'],
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,409 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Video Converter Suite - REST API
|
||||||
|
*/
|
||||||
|
|
||||||
|
spl_autoload_register(function ($class) {
|
||||||
|
$prefix = 'VideoConverter\\';
|
||||||
|
$baseDir = __DIR__ . '/../src/';
|
||||||
|
$len = strlen($prefix);
|
||||||
|
if (strncmp($prefix, $class, $len) !== 0) return;
|
||||||
|
$relative = substr($class, $len);
|
||||||
|
$file = $baseDir . str_replace('\\', '/', $relative) . '.php';
|
||||||
|
if (file_exists($file)) require $file;
|
||||||
|
});
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
header('Access-Control-Allow-Origin: *');
|
||||||
|
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
|
||||||
|
header('Access-Control-Allow-Headers: Content-Type');
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||||
|
http_response_code(204);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
|
||||||
|
$segments = array_values(array_filter(explode('/', $path)));
|
||||||
|
|
||||||
|
// Page routes (return HTML)
|
||||||
|
if (empty($segments) || ($segments[0] ?? '') !== 'api') {
|
||||||
|
header('Content-Type: text/html; charset=utf-8');
|
||||||
|
$page = $segments[0] ?? 'dashboard';
|
||||||
|
$templateFile = __DIR__ . '/../templates/' . basename($page) . '.php';
|
||||||
|
if (file_exists($templateFile)) {
|
||||||
|
$config = require __DIR__ . '/../config/app.php';
|
||||||
|
require $templateFile;
|
||||||
|
} else {
|
||||||
|
$config = require __DIR__ . '/../config/app.php';
|
||||||
|
require __DIR__ . '/../templates/dashboard.php';
|
||||||
|
}
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API routes
|
||||||
|
array_shift($segments); // remove 'api'
|
||||||
|
$resource = $segments[0] ?? '';
|
||||||
|
$id = $segments[1] ?? null;
|
||||||
|
$action = $segments[2] ?? null;
|
||||||
|
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = match (true) {
|
||||||
|
// System
|
||||||
|
$resource === 'system' && $method === 'GET' => handleSystem(),
|
||||||
|
|
||||||
|
// Convert
|
||||||
|
$resource === 'convert' && $method === 'POST' => handleConvert($input),
|
||||||
|
$resource === 'convert' && $action === 'batch' && $method === 'POST' => handleBatchConvert($input),
|
||||||
|
$resource === 'upload' && $method === 'POST' => handleUpload(),
|
||||||
|
|
||||||
|
// Jobs
|
||||||
|
$resource === 'jobs' && $method === 'GET' && !$id => handleGetJobs(),
|
||||||
|
$resource === 'jobs' && $method === 'GET' && $id && $action === 'progress' => handleJobProgress($id),
|
||||||
|
$resource === 'jobs' && $method === 'GET' && $id => handleGetJob($id),
|
||||||
|
$resource === 'jobs' && $method === 'DELETE' && $id => handleDeleteJob($id),
|
||||||
|
$resource === 'jobs' && $action === 'cancel' && $method === 'POST' => handleCancelJob($id),
|
||||||
|
|
||||||
|
// Streams
|
||||||
|
$resource === 'streams' && $method === 'GET' && !$id => handleGetStreams(),
|
||||||
|
$resource === 'streams' && $method === 'POST' => handleStartStream($input),
|
||||||
|
$resource === 'streams' && $method === 'GET' && $id => handleGetStream($id),
|
||||||
|
$resource === 'streams' && $method === 'DELETE' && $id => handleStopStream($id),
|
||||||
|
$resource === 'streams' && $action === 'switch' && $method === 'POST' => handleSwitchFormat($id, $input),
|
||||||
|
|
||||||
|
// Pipelines
|
||||||
|
$resource === 'pipelines' && $method === 'GET' && !$id => handleGetPipelines(),
|
||||||
|
$resource === 'pipelines' && $method === 'POST' => handleCreatePipeline($input),
|
||||||
|
$resource === 'pipelines' && $method === 'GET' && $id => handleGetPipeline($id),
|
||||||
|
$resource === 'pipelines' && $method === 'PUT' && $id => handleUpdatePipeline($id, $input),
|
||||||
|
$resource === 'pipelines' && $method === 'DELETE' && $id => handleDeletePipeline($id),
|
||||||
|
$resource === 'pipelines' && $action === 'run' && $method === 'POST' => handleRunPipeline($id, $input),
|
||||||
|
$resource === 'pipelines' && $action === 'stage' && $method === 'POST' => handleAddStage($id, $input),
|
||||||
|
|
||||||
|
// Queue
|
||||||
|
$resource === 'queue' && $method === 'GET' => handleGetQueue(),
|
||||||
|
$resource === 'queue' && $method === 'POST' => handleEnqueue($input),
|
||||||
|
$resource === 'queue' && $method === 'DELETE' => handleClearQueue(),
|
||||||
|
|
||||||
|
// Formats info
|
||||||
|
$resource === 'formats' && $method === 'GET' => handleGetFormats(),
|
||||||
|
$resource === 'presets' && $method === 'GET' => handleGetPresets(),
|
||||||
|
$resource === 'resolutions' && $method === 'GET' => handleGetResolutions(),
|
||||||
|
|
||||||
|
// Probe
|
||||||
|
$resource === 'probe' && $method === 'POST' => handleProbe($input),
|
||||||
|
|
||||||
|
// Downloads
|
||||||
|
$resource === 'download' && $method === 'GET' && $id => handleDownload($id),
|
||||||
|
|
||||||
|
default => ['error' => 'Not found', 'status' => 404],
|
||||||
|
};
|
||||||
|
|
||||||
|
$status = $response['status'] ?? 200;
|
||||||
|
unset($response['status']);
|
||||||
|
http_response_code($status);
|
||||||
|
echo json_encode($response, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||||
|
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Handler Functions ----
|
||||||
|
|
||||||
|
function handleSystem(): array
|
||||||
|
{
|
||||||
|
$load = sys_getloadavg();
|
||||||
|
$config = require __DIR__ . '/../config/app.php';
|
||||||
|
return [
|
||||||
|
'app' => $config['app_name'],
|
||||||
|
'version' => $config['version'],
|
||||||
|
'cpu_load' => $load,
|
||||||
|
'memory' => [
|
||||||
|
'used' => memory_get_usage(true),
|
||||||
|
'peak' => memory_get_peak_usage(true),
|
||||||
|
],
|
||||||
|
'disk' => [
|
||||||
|
'free' => disk_free_space('/'),
|
||||||
|
'total' => disk_total_space('/'),
|
||||||
|
],
|
||||||
|
'php_version' => PHP_VERSION,
|
||||||
|
'ffmpeg_available' => file_exists($config['ffmpeg']['binary']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUpload(): array
|
||||||
|
{
|
||||||
|
$config = require __DIR__ . '/../config/app.php';
|
||||||
|
|
||||||
|
if (empty($_FILES['file'])) {
|
||||||
|
return ['error' => 'No file uploaded', 'status' => 400];
|
||||||
|
}
|
||||||
|
|
||||||
|
$file = $_FILES['file'];
|
||||||
|
if ($file['error'] !== UPLOAD_ERR_OK) {
|
||||||
|
return ['error' => 'Upload error: ' . $file['error'], 'status' => 400];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($file['size'] > $config['limits']['max_upload_size']) {
|
||||||
|
return ['error' => 'File too large', 'status' => 400];
|
||||||
|
}
|
||||||
|
|
||||||
|
$uploadDir = $config['storage']['uploads'];
|
||||||
|
if (!is_dir($uploadDir)) mkdir($uploadDir, 0755, true);
|
||||||
|
|
||||||
|
$ext = pathinfo($file['name'], PATHINFO_EXTENSION);
|
||||||
|
$safeName = bin2hex(random_bytes(8)) . '.' . $ext;
|
||||||
|
$destination = $uploadDir . '/' . $safeName;
|
||||||
|
|
||||||
|
if (!move_uploaded_file($file['tmp_name'], $destination)) {
|
||||||
|
return ['error' => 'Failed to save file', 'status' => 500];
|
||||||
|
}
|
||||||
|
|
||||||
|
$probe = new \VideoConverter\Process\MediaProbe();
|
||||||
|
$info = $probe->analyze($destination);
|
||||||
|
|
||||||
|
// Generate thumbnail
|
||||||
|
$thumbDir = $config['storage']['thumbnails'];
|
||||||
|
if (!is_dir($thumbDir)) mkdir($thumbDir, 0755, true);
|
||||||
|
$thumbPath = $thumbDir . '/' . pathinfo($safeName, PATHINFO_FILENAME) . '.jpg';
|
||||||
|
$probe->getThumbnail($destination, $thumbPath);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'file' => $safeName,
|
||||||
|
'path' => $destination,
|
||||||
|
'original_name' => $file['name'],
|
||||||
|
'size' => $file['size'],
|
||||||
|
'info' => $info,
|
||||||
|
'thumbnail' => file_exists($thumbPath) ? '/api/thumbnail/' . pathinfo($safeName, PATHINFO_FILENAME) : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConvert(array $input): array
|
||||||
|
{
|
||||||
|
$converter = new \VideoConverter\Format\FormatConverter();
|
||||||
|
return $converter->convert($input);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBatchConvert(array $input): array
|
||||||
|
{
|
||||||
|
$converter = new \VideoConverter\Format\FormatConverter();
|
||||||
|
return $converter->batchConvert($input['input_file'] ?? '', $input['formats'] ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGetJobs(): array
|
||||||
|
{
|
||||||
|
$converter = new \VideoConverter\Format\FormatConverter();
|
||||||
|
return ['jobs' => $converter->getAllJobs()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGetJob(string $id): array
|
||||||
|
{
|
||||||
|
$converter = new \VideoConverter\Format\FormatConverter();
|
||||||
|
$job = $converter->getJob($id);
|
||||||
|
return $job ? $job : ['error' => 'Job not found', 'status' => 404];
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleJobProgress(string $id): array
|
||||||
|
{
|
||||||
|
$converter = new \VideoConverter\Format\FormatConverter();
|
||||||
|
return $converter->getProgress($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancelJob(string $id): array
|
||||||
|
{
|
||||||
|
$converter = new \VideoConverter\Format\FormatConverter();
|
||||||
|
return ['success' => $converter->cancelJob($id)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDeleteJob(string $id): array
|
||||||
|
{
|
||||||
|
$converter = new \VideoConverter\Format\FormatConverter();
|
||||||
|
return ['success' => $converter->deleteJob($id)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGetStreams(): array
|
||||||
|
{
|
||||||
|
$mgr = new \VideoConverter\Stream\StreamManager();
|
||||||
|
return ['streams' => $mgr->getAllStreams(), 'stats' => $mgr->getStats()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStartStream(array $input): array
|
||||||
|
{
|
||||||
|
$mgr = new \VideoConverter\Stream\StreamManager();
|
||||||
|
return $mgr->startStream($input);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGetStream(string $id): array
|
||||||
|
{
|
||||||
|
$mgr = new \VideoConverter\Stream\StreamManager();
|
||||||
|
$stream = $mgr->getStream($id);
|
||||||
|
return $stream ?: ['error' => 'Stream not found', 'status' => 404];
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStopStream(string $id): array
|
||||||
|
{
|
||||||
|
$mgr = new \VideoConverter\Stream\StreamManager();
|
||||||
|
return ['success' => $mgr->stopStream($id)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSwitchFormat(string $id, array $input): array
|
||||||
|
{
|
||||||
|
$mgr = new \VideoConverter\Stream\StreamManager();
|
||||||
|
return $mgr->switchFormat($id, $input['format'] ?? 'mp4', $input['resolution'] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGetPipelines(): array
|
||||||
|
{
|
||||||
|
$mgr = new \VideoConverter\Pipeline\PipelineManager();
|
||||||
|
return ['pipelines' => $mgr->toArray()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCreatePipeline(array $input): array
|
||||||
|
{
|
||||||
|
$mgr = new \VideoConverter\Pipeline\PipelineManager();
|
||||||
|
$pipeline = $mgr->create($input['name'] ?? 'Unnamed Pipeline');
|
||||||
|
|
||||||
|
foreach (($input['stages'] ?? []) as $stageData) {
|
||||||
|
$stage = new \VideoConverter\Pipeline\PipelineStage(
|
||||||
|
$stageData['type'] ?? 'transcode',
|
||||||
|
$stageData['params'] ?? [],
|
||||||
|
$stageData['label'] ?? '',
|
||||||
|
$stageData['enabled'] ?? true
|
||||||
|
);
|
||||||
|
$pipeline->addStage($stage);
|
||||||
|
}
|
||||||
|
|
||||||
|
$mgr->save();
|
||||||
|
return $pipeline->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGetPipeline(string $id): array
|
||||||
|
{
|
||||||
|
$mgr = new \VideoConverter\Pipeline\PipelineManager();
|
||||||
|
$pipeline = $mgr->get($id);
|
||||||
|
return $pipeline ? $pipeline->toArray() : ['error' => 'Pipeline not found', 'status' => 404];
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUpdatePipeline(string $id, array $input): array
|
||||||
|
{
|
||||||
|
$mgr = new \VideoConverter\Pipeline\PipelineManager();
|
||||||
|
$pipeline = $mgr->get($id);
|
||||||
|
if (!$pipeline) return ['error' => 'Pipeline not found', 'status' => 404];
|
||||||
|
|
||||||
|
if (isset($input['stages'])) {
|
||||||
|
// Rebuild stages
|
||||||
|
$ref = new \ReflectionProperty($pipeline, 'stages');
|
||||||
|
$ref->setAccessible(true);
|
||||||
|
$ref->setValue($pipeline, []);
|
||||||
|
|
||||||
|
foreach ($input['stages'] as $stageData) {
|
||||||
|
$stage = \VideoConverter\Pipeline\PipelineStage::fromArray($stageData);
|
||||||
|
$pipeline->addStage($stage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$mgr->save();
|
||||||
|
return $pipeline->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDeletePipeline(string $id): array
|
||||||
|
{
|
||||||
|
$mgr = new \VideoConverter\Pipeline\PipelineManager();
|
||||||
|
return ['success' => $mgr->delete($id)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRunPipeline(string $id, array $input): array
|
||||||
|
{
|
||||||
|
$mgr = new \VideoConverter\Pipeline\PipelineManager();
|
||||||
|
$pipeline = $mgr->get($id);
|
||||||
|
if (!$pipeline) return ['error' => 'Pipeline not found', 'status' => 404];
|
||||||
|
|
||||||
|
$converter = new \VideoConverter\Format\FormatConverter();
|
||||||
|
return $converter->convert([
|
||||||
|
'input_file' => $input['input_file'] ?? '',
|
||||||
|
'output_format' => $input['output_format'] ?? 'mp4',
|
||||||
|
'pipeline' => $pipeline,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAddStage(string $id, array $input): array
|
||||||
|
{
|
||||||
|
$mgr = new \VideoConverter\Pipeline\PipelineManager();
|
||||||
|
$pipeline = $mgr->get($id);
|
||||||
|
if (!$pipeline) return ['error' => 'Pipeline not found', 'status' => 404];
|
||||||
|
|
||||||
|
$stage = new \VideoConverter\Pipeline\PipelineStage(
|
||||||
|
$input['type'] ?? 'transcode',
|
||||||
|
$input['params'] ?? [],
|
||||||
|
$input['label'] ?? '',
|
||||||
|
$input['enabled'] ?? true
|
||||||
|
);
|
||||||
|
$pipeline->addStage($stage);
|
||||||
|
$mgr->save();
|
||||||
|
|
||||||
|
return $pipeline->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGetQueue(): array
|
||||||
|
{
|
||||||
|
$queue = new \VideoConverter\Queue\JobQueue();
|
||||||
|
return ['queue' => $queue->getQueue(), 'stats' => $queue->getStats()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEnqueue(array $input): array
|
||||||
|
{
|
||||||
|
$queue = new \VideoConverter\Queue\JobQueue();
|
||||||
|
$queueId = $queue->enqueue($input);
|
||||||
|
return ['queue_id' => $queueId, 'position' => count($queue->getWaiting())];
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClearQueue(): array
|
||||||
|
{
|
||||||
|
$queue = new \VideoConverter\Queue\JobQueue();
|
||||||
|
$cleared = $queue->clear();
|
||||||
|
return ['cleared' => $cleared];
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGetFormats(): array
|
||||||
|
{
|
||||||
|
$config = require __DIR__ . '/../config/app.php';
|
||||||
|
return $config['formats'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGetPresets(): array
|
||||||
|
{
|
||||||
|
$config = require __DIR__ . '/../config/app.php';
|
||||||
|
return $config['presets'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGetResolutions(): array
|
||||||
|
{
|
||||||
|
$config = require __DIR__ . '/../config/app.php';
|
||||||
|
return $config['resolutions'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleProbe(array $input): array
|
||||||
|
{
|
||||||
|
$probe = new \VideoConverter\Process\MediaProbe();
|
||||||
|
return $probe->analyze($input['file'] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDownload(string $id): array
|
||||||
|
{
|
||||||
|
$converter = new \VideoConverter\Format\FormatConverter();
|
||||||
|
$job = $converter->getJob($id);
|
||||||
|
if (!$job || !isset($job['output_file']) || !file_exists($job['output_file'])) {
|
||||||
|
return ['error' => 'File not found', 'status' => 404];
|
||||||
|
}
|
||||||
|
|
||||||
|
header('Content-Type: application/octet-stream');
|
||||||
|
header('Content-Disposition: attachment; filename="' . basename($job['output_file']) . '"');
|
||||||
|
header('Content-Length: ' . filesize($job['output_file']));
|
||||||
|
readfile($job['output_file']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,828 @@
|
|||||||
|
/**
|
||||||
|
* Video Converter Suite - Control Panel JavaScript
|
||||||
|
* Nuclear Power Plant Style UI Controller
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============ STATE ============
|
||||||
|
const state = {
|
||||||
|
currentPage: 'dashboard',
|
||||||
|
selectedFormat: 'mp4',
|
||||||
|
selectedPreset: 'balanced',
|
||||||
|
selectedResolution: 'original',
|
||||||
|
uploadedFile: null,
|
||||||
|
uploadedFilePath: null,
|
||||||
|
jobs: [],
|
||||||
|
streams: [],
|
||||||
|
pipelines: [],
|
||||||
|
activePipelineId: null,
|
||||||
|
pipelineStages: [],
|
||||||
|
activeStreamId: null,
|
||||||
|
wsConnected: false,
|
||||||
|
refreshInterval: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ INIT ============
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
initClock();
|
||||||
|
initNavigation();
|
||||||
|
initUploadZone();
|
||||||
|
startAutoRefresh();
|
||||||
|
refreshStatus();
|
||||||
|
addLog('System initialisiert', 'info');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ CLOCK ============
|
||||||
|
function initClock() {
|
||||||
|
const el = document.getElementById('systemClock');
|
||||||
|
function update() {
|
||||||
|
const now = new Date();
|
||||||
|
el.textContent = now.toTimeString().split(' ')[0];
|
||||||
|
}
|
||||||
|
update();
|
||||||
|
setInterval(update, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ NAVIGATION ============
|
||||||
|
function initNavigation() {
|
||||||
|
document.querySelectorAll('.nav-tab').forEach(tab => {
|
||||||
|
tab.addEventListener('click', () => {
|
||||||
|
const page = tab.dataset.page;
|
||||||
|
switchPage(page);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchPage(page) {
|
||||||
|
state.currentPage = page;
|
||||||
|
document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active'));
|
||||||
|
document.querySelector(`.nav-tab[data-page="${page}"]`)?.classList.add('active');
|
||||||
|
document.querySelectorAll('.page-content').forEach(p => p.style.display = 'none');
|
||||||
|
document.getElementById(`page-${page}`).style.display = '';
|
||||||
|
|
||||||
|
// Refresh page-specific data
|
||||||
|
if (page === 'dashboard') refreshStatus();
|
||||||
|
if (page === 'streams') refreshStreams();
|
||||||
|
if (page === 'pipelines') refreshPipelines();
|
||||||
|
if (page === 'queue') refreshQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ UPLOAD ============
|
||||||
|
function initUploadZone() {
|
||||||
|
const zone = document.getElementById('uploadZone');
|
||||||
|
if (!zone) return;
|
||||||
|
|
||||||
|
zone.addEventListener('dragover', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
zone.classList.add('dragover');
|
||||||
|
});
|
||||||
|
|
||||||
|
zone.addEventListener('dragleave', () => {
|
||||||
|
zone.classList.remove('dragover');
|
||||||
|
});
|
||||||
|
|
||||||
|
zone.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
zone.classList.remove('dragover');
|
||||||
|
if (e.dataTransfer.files.length > 0) {
|
||||||
|
uploadFile(e.dataTransfer.files[0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileSelect(event) {
|
||||||
|
if (event.target.files.length > 0) {
|
||||||
|
uploadFile(event.target.files[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadFile(file) {
|
||||||
|
state.uploadedFile = file;
|
||||||
|
addLog(`Upload gestartet: ${file.name} (${formatBytes(file.size)})`, 'info');
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
addLog(`Upload-Fehler: ${data.error}`, 'error');
|
||||||
|
notify('Upload fehlgeschlagen: ' + data.error, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.uploadedFilePath = data.path;
|
||||||
|
displayUploadedFile(data);
|
||||||
|
document.getElementById('btnStartConvert').disabled = false;
|
||||||
|
addLog(`Upload abgeschlossen: ${file.name}`, 'success');
|
||||||
|
notify('Datei hochgeladen: ' + file.name, 'success');
|
||||||
|
} catch (err) {
|
||||||
|
addLog(`Upload-Fehler: ${err.message}`, 'error');
|
||||||
|
notify('Upload fehlgeschlagen', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayUploadedFile(data) {
|
||||||
|
const el = document.getElementById('uploadedFileInfo');
|
||||||
|
el.style.display = 'block';
|
||||||
|
|
||||||
|
const info = data.info || {};
|
||||||
|
const video = info.video || {};
|
||||||
|
const audio = info.audio || {};
|
||||||
|
|
||||||
|
el.innerHTML = `
|
||||||
|
<div style="background:var(--bg-inset); border:1px solid var(--border-dark); border-radius:4px; padding:12px;">
|
||||||
|
<div style="display:flex; justify-content:space-between; margin-bottom:8px;">
|
||||||
|
<strong style="color:var(--accent-cyan);">${data.original_name}</strong>
|
||||||
|
<span style="color:var(--text-dim); font-size:11px;">${formatBytes(data.size)}</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:grid; grid-template-columns: 1fr 1fr; gap:8px; font-size:11px;">
|
||||||
|
<div><span style="color:var(--text-dim)">Format:</span> ${info.format_name || 'N/A'}</div>
|
||||||
|
<div><span style="color:var(--text-dim)">Dauer:</span> ${formatDuration(info.duration || 0)}</div>
|
||||||
|
${video ? `
|
||||||
|
<div><span style="color:var(--text-dim)">Video:</span> ${video.codec || 'N/A'} ${video.width || ''}x${video.height || ''}</div>
|
||||||
|
<div><span style="color:var(--text-dim)">FPS:</span> ${video.fps || 'N/A'}</div>
|
||||||
|
` : ''}
|
||||||
|
${audio ? `
|
||||||
|
<div><span style="color:var(--text-dim)">Audio:</span> ${audio.codec || 'N/A'}</div>
|
||||||
|
<div><span style="color:var(--text-dim)">Sample:</span> ${audio.sample_rate || 'N/A'} Hz</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ FORMAT SELECTION ============
|
||||||
|
function selectFormat(format) {
|
||||||
|
state.selectedFormat = format;
|
||||||
|
document.querySelectorAll('.format-switch').forEach(s => s.classList.remove('selected'));
|
||||||
|
document.querySelectorAll(`.format-switch[data-format="${format}"]`).forEach(s => s.classList.add('selected'));
|
||||||
|
addLog(`Format gewählt: ${format.toUpperCase()}`, 'info');
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectPreset(preset) {
|
||||||
|
state.selectedPreset = preset;
|
||||||
|
document.querySelectorAll('#presetPanel .switch-unit').forEach(s => s.classList.remove('active'));
|
||||||
|
document.querySelector(`#presetPanel .switch-unit[data-preset="${preset}"]`)?.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectResolution(res) {
|
||||||
|
state.selectedResolution = res;
|
||||||
|
document.querySelectorAll('#resolutionPanel .switch-unit').forEach(s => s.classList.remove('active'));
|
||||||
|
document.querySelector(`#resolutionPanel .switch-unit[data-resolution="${res}"]`)?.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ CONVERSION ============
|
||||||
|
async function startConversion() {
|
||||||
|
if (!state.uploadedFilePath) {
|
||||||
|
notify('Keine Datei hochgeladen', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
input_file: state.uploadedFilePath,
|
||||||
|
output_format: state.selectedFormat,
|
||||||
|
preset: state.selectedPreset,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (state.selectedResolution !== 'original') {
|
||||||
|
params.resolution = state.selectedResolution;
|
||||||
|
}
|
||||||
|
|
||||||
|
addLog(`Konvertierung gestartet: ${state.selectedFormat.toUpperCase()} / ${state.selectedPreset}`, 'info');
|
||||||
|
document.getElementById('conversionStatus').textContent = 'Konvertierung wird gestartet...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/convert', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(params),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
addLog(`Fehler: ${data.error}`, 'error');
|
||||||
|
notify(data.error, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
addLog(`Job erstellt: ${data.id}`, 'success');
|
||||||
|
notify('Konvertierung gestartet', 'success');
|
||||||
|
document.getElementById('btnStopAll').style.display = '';
|
||||||
|
document.getElementById('conversionStatus').textContent = `Job ${data.id} läuft...`;
|
||||||
|
startJobPolling(data.id);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
addLog(`Fehler: ${err.message}`, 'error');
|
||||||
|
notify('Konvertierung fehlgeschlagen', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startJobPolling(jobId) {
|
||||||
|
const poll = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/jobs/${jobId}/progress`);
|
||||||
|
const progress = await resp.json();
|
||||||
|
|
||||||
|
document.getElementById('conversionStatus').textContent =
|
||||||
|
`${progress.percent || 0}% | FPS: ${progress.fps || 0} | Speed: ${progress.speed || '0x'} | Zeit: ${progress.time || '00:00:00'}`;
|
||||||
|
|
||||||
|
updateJobInList(jobId, progress);
|
||||||
|
|
||||||
|
if (progress.percent >= 100) {
|
||||||
|
clearInterval(poll);
|
||||||
|
addLog(`Job ${jobId} abgeschlossen`, 'success');
|
||||||
|
notify('Konvertierung abgeschlossen!', 'success');
|
||||||
|
document.getElementById('conversionStatus').textContent = 'Konvertierung abgeschlossen!';
|
||||||
|
refreshJobs();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Keep polling
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopAllJobs() {
|
||||||
|
if (!confirm('Alle laufenden Jobs stoppen?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/jobs');
|
||||||
|
const data = await resp.json();
|
||||||
|
for (const job of (data.jobs || [])) {
|
||||||
|
if (job.status === 'running') {
|
||||||
|
await fetch(`/api/jobs/${job.id}/cancel`, { method: 'POST' });
|
||||||
|
addLog(`Job ${job.id} gestoppt`, 'warn');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
notify('Alle Jobs gestoppt', 'warning');
|
||||||
|
document.getElementById('btnStopAll').style.display = 'none';
|
||||||
|
refreshJobs();
|
||||||
|
} catch (e) {
|
||||||
|
notify('Fehler beim Stoppen', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ JOBS ============
|
||||||
|
async function refreshJobs() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/jobs');
|
||||||
|
const data = await resp.json();
|
||||||
|
state.jobs = data.jobs || [];
|
||||||
|
renderJobList();
|
||||||
|
} catch (e) {
|
||||||
|
// Silently fail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderJobList() {
|
||||||
|
const el = document.getElementById('jobList');
|
||||||
|
if (!state.jobs.length) {
|
||||||
|
el.innerHTML = '<div style="text-align:center; color:var(--text-dim); padding:20px;">Keine aktiven Jobs</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
el.innerHTML = state.jobs.map(job => `
|
||||||
|
<div class="job-item" id="job-${job.id}">
|
||||||
|
<div class="job-thumb">${job.thumbnail ? `<img src="${job.thumbnail}">` : '🎦'}</div>
|
||||||
|
<div class="job-info">
|
||||||
|
<div class="job-name">${job.input_file ? job.input_file.split('/').pop() : 'Unknown'}</div>
|
||||||
|
<div class="job-meta">
|
||||||
|
${job.output_format?.toUpperCase() || ''} | ${job.preset || ''} | ${job.resolution || 'Original'}
|
||||||
|
</div>
|
||||||
|
<div class="progress-bar" style="margin-top:6px;">
|
||||||
|
<div class="progress-fill" id="progress-${job.id}" style="width:${job.status === 'completed' ? 100 : 0}%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="progress-label">
|
||||||
|
<span id="progress-text-${job.id}">${job.status === 'completed' ? '100%' : '0%'}</span>
|
||||||
|
<span id="progress-speed-${job.id}"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="job-status ${job.status}">${job.status}</span>
|
||||||
|
<div class="job-actions">
|
||||||
|
${job.status === 'running' ? `<button class="btn btn-icon btn-danger" onclick="cancelJob('${job.id}')" data-tooltip="Stop">■</button>` : ''}
|
||||||
|
${job.status === 'completed' ? `<button class="btn btn-icon btn-success" onclick="downloadJob('${job.id}')" data-tooltip="Download">⇩</button>` : ''}
|
||||||
|
<button class="btn btn-icon btn-danger" onclick="deleteJob('${job.id}')" data-tooltip="Löschen">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
// Update active job count
|
||||||
|
const running = state.jobs.filter(j => j.status === 'running').length;
|
||||||
|
document.getElementById('activeJobCount').textContent = running;
|
||||||
|
document.getElementById('gaugeJobs').textContent = running;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateJobInList(jobId, progress) {
|
||||||
|
const bar = document.getElementById(`progress-${jobId}`);
|
||||||
|
const text = document.getElementById(`progress-text-${jobId}`);
|
||||||
|
const speed = document.getElementById(`progress-speed-${jobId}`);
|
||||||
|
if (bar) bar.style.width = `${progress.percent || 0}%`;
|
||||||
|
if (text) text.textContent = `${progress.percent || 0}%`;
|
||||||
|
if (speed) speed.textContent = `${progress.fps || 0} fps | ${progress.speed || ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelJob(id) {
|
||||||
|
await fetch(`/api/jobs/${id}/cancel`, { method: 'POST' });
|
||||||
|
addLog(`Job ${id} abgebrochen`, 'warn');
|
||||||
|
refreshJobs();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteJob(id) {
|
||||||
|
await fetch(`/api/jobs/${id}`, { method: 'DELETE' });
|
||||||
|
addLog(`Job ${id} gelöscht`, 'info');
|
||||||
|
refreshJobs();
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadJob(id) {
|
||||||
|
window.open(`/api/download/${id}`, '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ STREAMS ============
|
||||||
|
async function refreshStreams() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/streams');
|
||||||
|
const data = await resp.json();
|
||||||
|
state.streams = data.streams || [];
|
||||||
|
renderStreamMatrix();
|
||||||
|
updateStreamSelect();
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStreamMatrix() {
|
||||||
|
const el = document.getElementById('streamMatrix');
|
||||||
|
if (!state.streams.length) {
|
||||||
|
el.innerHTML = '<div style="text-align:center; color:var(--text-dim); padding:40px; grid-column:1/-1;">Keine aktiven Streams</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
el.innerHTML = state.streams.map(s => `
|
||||||
|
<div class="stream-card">
|
||||||
|
<div class="stream-preview">
|
||||||
|
<span class="no-signal">${s.status === 'running' ? '▶ LIVE' : 'NO SIGNAL'}</span>
|
||||||
|
${s.status === 'running' ? '<span class="live-badge">LIVE</span>' : ''}
|
||||||
|
</div>
|
||||||
|
<div class="stream-info">
|
||||||
|
<div class="stream-name">${s.input_url || 'Stream'}</div>
|
||||||
|
<div style="font-size:10px; color:var(--text-dim);">
|
||||||
|
${s.output_format?.toUpperCase() || ''} | ${s.resolution || 'Original'} | ${s.preset || 'fast'}
|
||||||
|
</div>
|
||||||
|
<span class="job-status ${s.status}" style="margin-top:6px; display:inline-block;">${s.status}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stream-controls">
|
||||||
|
${s.status === 'running' ?
|
||||||
|
`<button class="btn btn-danger" onclick="stopStream('${s.id}')">■ Stop</button>` :
|
||||||
|
`<button class="btn btn-success" onclick="restartStream('${s.id}')">▶ Restart</button>`
|
||||||
|
}
|
||||||
|
<button class="btn" onclick="deleteStream('${s.id}')">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStreamSelect() {
|
||||||
|
const sel = document.getElementById('activeStreamSelect');
|
||||||
|
const runningStreams = state.streams.filter(s => s.status === 'running');
|
||||||
|
sel.innerHTML = '<option value="">-- Stream wählen --</option>' +
|
||||||
|
runningStreams.map(s =>
|
||||||
|
`<option value="${s.id}">${s.input_url} (${s.output_format?.toUpperCase()})</option>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openStreamModal() {
|
||||||
|
document.getElementById('streamModal').classList.add('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeStreamModal() {
|
||||||
|
document.getElementById('streamModal').classList.remove('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startNewStream() {
|
||||||
|
const inputUrl = document.getElementById('streamInputUrl').value;
|
||||||
|
if (!inputUrl) {
|
||||||
|
notify('Bitte Stream-URL eingeben', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
input_url: inputUrl,
|
||||||
|
output_format: document.getElementById('streamOutputFormat').value,
|
||||||
|
resolution: document.getElementById('streamResolution').value || null,
|
||||||
|
preset: document.getElementById('streamPreset').value,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/streams', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(params),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
notify(data.error, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
addLog(`Stream gestartet: ${data.id}`, 'success');
|
||||||
|
notify('Stream gestartet', 'success');
|
||||||
|
closeStreamModal();
|
||||||
|
refreshStreams();
|
||||||
|
} catch (e) {
|
||||||
|
notify('Stream-Start fehlgeschlagen', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopStream(id) {
|
||||||
|
await fetch(`/api/streams/${id}`, { method: 'DELETE' });
|
||||||
|
addLog(`Stream ${id} gestoppt`, 'warn');
|
||||||
|
refreshStreams();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteStream(id) {
|
||||||
|
await fetch(`/api/streams/${id}`, { method: 'DELETE' });
|
||||||
|
refreshStreams();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectActiveStream(id) {
|
||||||
|
state.activeStreamId = id;
|
||||||
|
// Highlight current format
|
||||||
|
const stream = state.streams.find(s => s.id === id);
|
||||||
|
document.querySelectorAll('[data-stream-format]').forEach(el => el.classList.remove('selected'));
|
||||||
|
if (stream) {
|
||||||
|
document.querySelector(`[data-stream-format="${stream.output_format}"]`)?.classList.add('selected');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function switchStreamFormat(format) {
|
||||||
|
if (!state.activeStreamId) {
|
||||||
|
notify('Bitte zuerst einen Stream wählen', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
addLog(`Format-Wechsel: ${format.toUpperCase()} für Stream ${state.activeStreamId}`, 'warn');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/streams/${state.activeStreamId}/switch`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ format }),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
notify(data.error, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
addLog(`Format gewechselt zu ${format.toUpperCase()}`, 'success');
|
||||||
|
notify(`Format umgeschaltet: ${format.toUpperCase()}`, 'success');
|
||||||
|
|
||||||
|
// Update active stream ID to new stream
|
||||||
|
state.activeStreamId = data.id;
|
||||||
|
refreshStreams();
|
||||||
|
|
||||||
|
// Highlight new format
|
||||||
|
document.querySelectorAll('[data-stream-format]').forEach(el => el.classList.remove('selected'));
|
||||||
|
document.querySelector(`[data-stream-format="${format}"]`)?.classList.add('selected');
|
||||||
|
} catch (e) {
|
||||||
|
notify('Format-Wechsel fehlgeschlagen', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ PIPELINES ============
|
||||||
|
async function refreshPipelines() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/pipelines');
|
||||||
|
const data = await resp.json();
|
||||||
|
state.pipelines = data.pipelines || [];
|
||||||
|
renderPipelineList();
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPipelineList() {
|
||||||
|
const el = document.getElementById('pipelineList');
|
||||||
|
if (!state.pipelines.length) {
|
||||||
|
el.innerHTML = '<div style="text-align:center; color:var(--text-dim); padding:16px;">Keine Pipelines vorhanden</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
el.innerHTML = state.pipelines.map(p => `
|
||||||
|
<div class="job-item" style="cursor:pointer" onclick="editPipeline('${p.id}')">
|
||||||
|
<div class="job-thumb" style="font-size:24px">☰</div>
|
||||||
|
<div class="job-info">
|
||||||
|
<div class="job-name">${p.name}</div>
|
||||||
|
<div class="job-meta">${(p.stages || []).length} Stufen | Status: ${p.status}</div>
|
||||||
|
</div>
|
||||||
|
<span class="job-status ${p.status}">${p.status}</span>
|
||||||
|
<div class="job-actions">
|
||||||
|
<button class="btn btn-icon btn-primary" onclick="event.stopPropagation(); runPipeline('${p.id}')" data-tooltip="Ausführen">▶</button>
|
||||||
|
<button class="btn btn-icon btn-danger" onclick="event.stopPropagation(); deletePipeline('${p.id}')" data-tooltip="Löschen">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createPipeline() {
|
||||||
|
const name = prompt('Pipeline-Name:', 'Neue Pipeline');
|
||||||
|
if (!name) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/pipelines', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
addLog(`Pipeline erstellt: ${data.name}`, 'success');
|
||||||
|
refreshPipelines();
|
||||||
|
editPipeline(data.id);
|
||||||
|
} catch (e) {
|
||||||
|
notify('Pipeline-Erstellung fehlgeschlagen', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function editPipeline(id) {
|
||||||
|
state.activePipelineId = id;
|
||||||
|
const pipeline = state.pipelines.find(p => p.id === id);
|
||||||
|
if (!pipeline) return;
|
||||||
|
|
||||||
|
state.pipelineStages = pipeline.stages || [];
|
||||||
|
document.getElementById('pipelineEditor').style.display = '';
|
||||||
|
renderPipelineFlow();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPipelineFlow() {
|
||||||
|
const flow = document.getElementById('pipelineFlow');
|
||||||
|
let html = `
|
||||||
|
<div class="pipeline-node active">
|
||||||
|
<div class="node-type">Input</div>
|
||||||
|
<div class="node-name">Source</div>
|
||||||
|
<div class="node-status"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
state.pipelineStages.forEach((stage, i) => {
|
||||||
|
html += `<div class="pipeline-connector ${stage.enabled ? 'active' : ''}"></div>`;
|
||||||
|
html += `
|
||||||
|
<div class="pipeline-node ${stage.enabled ? 'active' : 'disabled'}" onclick="toggleStage(${i})">
|
||||||
|
<div class="node-type">${stage.type}</div>
|
||||||
|
<div class="node-name">${stage.label || stage.type}</div>
|
||||||
|
<div class="node-status"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
html += `<div class="pipeline-connector active"></div>`;
|
||||||
|
html += `
|
||||||
|
<div class="pipeline-node active">
|
||||||
|
<div class="node-type">Output</div>
|
||||||
|
<div class="node-name">Target</div>
|
||||||
|
<div class="node-status"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
flow.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleStage(index) {
|
||||||
|
if (state.pipelineStages[index]) {
|
||||||
|
state.pipelineStages[index].enabled = !state.pipelineStages[index].enabled;
|
||||||
|
renderPipelineFlow();
|
||||||
|
savePipelineStages();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addPipelineStage(type) {
|
||||||
|
if (!state.activePipelineId) {
|
||||||
|
notify('Bitte zuerst eine Pipeline auswählen oder erstellen', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stageDefaults = {
|
||||||
|
transcode: { params: { video_codec: 'libx264', preset: 'medium', crf: 23 } },
|
||||||
|
scale: { params: { width: 1920, height: 1080 } },
|
||||||
|
filter: { params: { brightness: 0, contrast: 1, saturation: 1 } },
|
||||||
|
audio: { params: { codec: 'aac', bitrate: '128k', sample_rate: 44100 } },
|
||||||
|
bitrate: { params: { video: '2M', audio: '128k' } },
|
||||||
|
framerate: { params: { fps: 30 } },
|
||||||
|
trim: { params: { start: '00:00:00', duration: '' } },
|
||||||
|
deinterlace: { params: {} },
|
||||||
|
denoise: { params: {} },
|
||||||
|
stabilize: { params: {} },
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaults = stageDefaults[type] || { params: {} };
|
||||||
|
const stage = {
|
||||||
|
type,
|
||||||
|
label: type.charAt(0).toUpperCase() + type.slice(1),
|
||||||
|
params: defaults.params,
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
state.pipelineStages.push(stage);
|
||||||
|
renderPipelineFlow();
|
||||||
|
await savePipelineStages();
|
||||||
|
addLog(`Stufe hinzugefügt: ${type}`, 'info');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePipelineStages() {
|
||||||
|
if (!state.activePipelineId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(`/api/pipelines/${state.activePipelineId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ stages: state.pipelineStages }),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to save pipeline stages');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deletePipeline(id) {
|
||||||
|
if (!confirm('Pipeline löschen?')) return;
|
||||||
|
await fetch(`/api/pipelines/${id}`, { method: 'DELETE' });
|
||||||
|
if (state.activePipelineId === id) {
|
||||||
|
state.activePipelineId = null;
|
||||||
|
document.getElementById('pipelineEditor').style.display = 'none';
|
||||||
|
}
|
||||||
|
refreshPipelines();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runPipeline(id) {
|
||||||
|
if (!state.uploadedFilePath) {
|
||||||
|
notify('Bitte zuerst eine Datei hochladen (Konverter-Seite)', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/pipelines/${id}/run`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
input_file: state.uploadedFilePath,
|
||||||
|
output_format: state.selectedFormat,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.error) {
|
||||||
|
notify(data.error, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
addLog(`Pipeline ${id} ausgeführt, Job: ${data.id}`, 'success');
|
||||||
|
notify('Pipeline gestartet', 'success');
|
||||||
|
startJobPolling(data.id);
|
||||||
|
} catch (e) {
|
||||||
|
notify('Pipeline-Start fehlgeschlagen', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ QUEUE ============
|
||||||
|
async function refreshQueue() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/queue');
|
||||||
|
const data = await resp.json();
|
||||||
|
renderQueue(data.queue || [], data.stats || {});
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderQueue(queue, stats) {
|
||||||
|
const el = document.getElementById('queueList');
|
||||||
|
if (!queue.length) {
|
||||||
|
el.innerHTML = '<div style="text-align:center; color:var(--text-dim); padding:20px;">Warteschlange ist leer</div>';
|
||||||
|
} else {
|
||||||
|
el.innerHTML = queue.map(job => `
|
||||||
|
<div class="job-item">
|
||||||
|
<div class="job-thumb">📄</div>
|
||||||
|
<div class="job-info">
|
||||||
|
<div class="job-name">${job.input_file || job.queue_id}</div>
|
||||||
|
<div class="job-meta">Priorität: ${job.priority || 5} | ${job.output_format || 'mp4'}</div>
|
||||||
|
</div>
|
||||||
|
<span class="job-status ${job.queue_status === 'waiting' ? 'queued' : job.queue_status}">${job.queue_status}</span>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('queueWaiting').textContent = stats.waiting || 0;
|
||||||
|
document.getElementById('queueProcessing').textContent = stats.processing || 0;
|
||||||
|
document.getElementById('queueCompleted').textContent = stats.completed || 0;
|
||||||
|
document.getElementById('queueFailed').textContent = stats.failed || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearQueue() {
|
||||||
|
await fetch('/api/queue', { method: 'DELETE' });
|
||||||
|
refreshQueue();
|
||||||
|
notify('Queue geleert', 'info');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ STATUS / SYSTEM ============
|
||||||
|
async function refreshStatus() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/system');
|
||||||
|
const data = await resp.json();
|
||||||
|
updateGauges(data);
|
||||||
|
refreshJobs();
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('systemStatusDot').className = 'status-dot error';
|
||||||
|
document.getElementById('systemStatusText').textContent = 'OFFLINE';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateGauges(data) {
|
||||||
|
// CPU
|
||||||
|
const cpuLoad = data.cpu_load?.[0] || 0;
|
||||||
|
const cpuPercent = Math.min(100, cpuLoad * 25); // Normalize to ~100% at load 4
|
||||||
|
document.getElementById('gaugeCpu').textContent = cpuLoad.toFixed(1);
|
||||||
|
const cpuBar = document.getElementById('gaugeCpuBar');
|
||||||
|
cpuBar.style.width = cpuPercent + '%';
|
||||||
|
cpuBar.className = 'gauge-bar-fill' + (cpuPercent > 80 ? ' danger' : cpuPercent > 50 ? ' warning' : '');
|
||||||
|
|
||||||
|
// Memory
|
||||||
|
const mem = data.memory || {};
|
||||||
|
const memPercent = mem.peak ? Math.round((mem.used / mem.peak) * 100) : 0;
|
||||||
|
document.getElementById('gaugeMem').textContent = memPercent;
|
||||||
|
const memBar = document.getElementById('gaugeMemBar');
|
||||||
|
memBar.style.width = memPercent + '%';
|
||||||
|
memBar.className = 'gauge-bar-fill' + (memPercent > 80 ? ' danger' : memPercent > 50 ? ' warning' : '');
|
||||||
|
|
||||||
|
// Disk
|
||||||
|
const diskFree = (data.disk?.free || 0) / (1024 * 1024 * 1024);
|
||||||
|
const diskTotal = (data.disk?.total || 1) / (1024 * 1024 * 1024);
|
||||||
|
const diskUsedPercent = Math.round(((diskTotal - diskFree) / diskTotal) * 100);
|
||||||
|
document.getElementById('gaugeDisk').textContent = diskFree.toFixed(1);
|
||||||
|
const diskBar = document.getElementById('gaugeDiskBar');
|
||||||
|
diskBar.style.width = diskUsedPercent + '%';
|
||||||
|
diskBar.className = 'gauge-bar-fill' + (diskUsedPercent > 90 ? ' danger' : diskUsedPercent > 70 ? ' warning' : '');
|
||||||
|
|
||||||
|
// Status
|
||||||
|
document.getElementById('systemStatusDot').className = 'status-dot';
|
||||||
|
document.getElementById('systemStatusText').textContent = data.ffmpeg_available ? 'SYSTEM ONLINE' : 'FFMPEG MISSING';
|
||||||
|
if (!data.ffmpeg_available) {
|
||||||
|
document.getElementById('systemStatusDot').className = 'status-dot warning';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAutoRefresh() {
|
||||||
|
if (state.refreshInterval) clearInterval(state.refreshInterval);
|
||||||
|
state.refreshInterval = setInterval(() => {
|
||||||
|
if (state.currentPage === 'dashboard') refreshStatus();
|
||||||
|
if (state.currentPage === 'streams') refreshStreams();
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ LOGGING ============
|
||||||
|
function addLog(message, level = 'info') {
|
||||||
|
const console = document.getElementById('logConsole');
|
||||||
|
const time = new Date().toLocaleTimeString();
|
||||||
|
const line = document.createElement('div');
|
||||||
|
line.className = `log-line ${level}`;
|
||||||
|
line.innerHTML = `<span class="log-time">[${time}]</span> ${escapeHtml(message)}`;
|
||||||
|
console.appendChild(line);
|
||||||
|
console.scrollTop = console.scrollHeight;
|
||||||
|
|
||||||
|
// Keep max 100 lines
|
||||||
|
while (console.children.length > 100) {
|
||||||
|
console.removeChild(console.firstChild);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLog() {
|
||||||
|
document.getElementById('logConsole').innerHTML =
|
||||||
|
'<div class="log-line info"><span class="log-time">[CLEAR]</span> Log bereinigt</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ NOTIFICATIONS ============
|
||||||
|
function notify(message, type = 'info') {
|
||||||
|
const el = document.getElementById('notification');
|
||||||
|
el.className = `notification ${type} show`;
|
||||||
|
el.textContent = message;
|
||||||
|
setTimeout(() => { el.classList.remove('show'); }, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ HELPERS ============
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
let i = 0, size = bytes;
|
||||||
|
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; }
|
||||||
|
return size.toFixed(1) + ' ' + units[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds) {
|
||||||
|
if (!seconds) return '0:00';
|
||||||
|
const h = Math.floor(seconds / 3600);
|
||||||
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
|
const s = Math.floor(seconds % 60);
|
||||||
|
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||||
|
return `${m}:${String(s).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = str;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Video Converter Suite - Front Controller / Router
|
||||||
|
*
|
||||||
|
* Usage: php -S 0.0.0.0:8080 -t public public/router.php
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Serve static files directly
|
||||||
|
$uri = urldecode(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH));
|
||||||
|
if ($uri !== '/' && file_exists(__DIR__ . $uri)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once __DIR__ . '/api.php';
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace VideoConverter\Format;
|
||||||
|
|
||||||
|
use VideoConverter\Process\FFmpegProcess;
|
||||||
|
use VideoConverter\Process\MediaProbe;
|
||||||
|
use VideoConverter\Pipeline\Pipeline;
|
||||||
|
|
||||||
|
class FormatConverter
|
||||||
|
{
|
||||||
|
private array $jobs = [];
|
||||||
|
private string $stateFile;
|
||||||
|
private MediaProbe $probe;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->stateFile = __DIR__ . '/../../storage/temp/jobs.json';
|
||||||
|
$this->probe = new MediaProbe();
|
||||||
|
$this->load();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function load(): void
|
||||||
|
{
|
||||||
|
if (file_exists($this->stateFile)) {
|
||||||
|
$this->jobs = json_decode(file_get_contents($this->stateFile), true) ?: [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function save(): void
|
||||||
|
{
|
||||||
|
$dir = dirname($this->stateFile);
|
||||||
|
if (!is_dir($dir)) mkdir($dir, 0755, true);
|
||||||
|
file_put_contents($this->stateFile, json_encode($this->jobs, JSON_PRETTY_PRINT));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function convert(array $params): array
|
||||||
|
{
|
||||||
|
$config = require __DIR__ . '/../../config/app.php';
|
||||||
|
|
||||||
|
$inputFile = $params['input_file'] ?? '';
|
||||||
|
$outputFormat = $params['output_format'] ?? 'mp4';
|
||||||
|
$preset = $params['preset'] ?? 'balanced';
|
||||||
|
$resolution = $params['resolution'] ?? null;
|
||||||
|
$customPipeline = $params['pipeline'] ?? null;
|
||||||
|
|
||||||
|
if (!file_exists($inputFile)) {
|
||||||
|
return ['error' => 'Input file not found'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = bin2hex(random_bytes(8));
|
||||||
|
$formatConfig = $config['formats']['video'][$outputFormat]
|
||||||
|
?? $config['formats']['audio'][$outputFormat]
|
||||||
|
?? null;
|
||||||
|
|
||||||
|
if (!$formatConfig) {
|
||||||
|
return ['error' => "Unknown format: {$outputFormat}"];
|
||||||
|
}
|
||||||
|
|
||||||
|
$inputInfo = $this->probe->analyze($inputFile);
|
||||||
|
$baseName = pathinfo($inputFile, PATHINFO_FILENAME);
|
||||||
|
$outputFile = $config['storage']['outputs'] . "/{$baseName}_{$id}.{$formatConfig['ext']}";
|
||||||
|
|
||||||
|
// Build command
|
||||||
|
if ($customPipeline instanceof Pipeline) {
|
||||||
|
$cmd = $customPipeline->buildFFmpegCommand($inputFile, $outputFile);
|
||||||
|
} else {
|
||||||
|
$cmd = $this->buildCommand($inputFile, $outputFile, $outputFormat, $preset, $resolution, $params);
|
||||||
|
}
|
||||||
|
|
||||||
|
$process = new FFmpegProcess($cmd, $id);
|
||||||
|
if (isset($inputInfo['duration'])) {
|
||||||
|
$process->setDuration($inputInfo['duration']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate thumbnail
|
||||||
|
$thumbPath = $config['storage']['thumbnails'] . "/{$id}.jpg";
|
||||||
|
$this->probe->getThumbnail($inputFile, $thumbPath);
|
||||||
|
|
||||||
|
$job = [
|
||||||
|
'id' => $id,
|
||||||
|
'input_file' => $inputFile,
|
||||||
|
'input_info' => $inputInfo,
|
||||||
|
'output_file' => $outputFile,
|
||||||
|
'output_format' => $outputFormat,
|
||||||
|
'preset' => $preset,
|
||||||
|
'resolution' => $resolution,
|
||||||
|
'thumbnail' => file_exists($thumbPath) ? $thumbPath : null,
|
||||||
|
'status' => 'starting',
|
||||||
|
'pid' => null,
|
||||||
|
'command' => $cmd,
|
||||||
|
'created_at' => date('c'),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($process->start()) {
|
||||||
|
$job['status'] = 'running';
|
||||||
|
$job['pid'] = $process->getPid();
|
||||||
|
} else {
|
||||||
|
$job['status'] = 'error';
|
||||||
|
$job['error'] = 'Failed to start FFmpeg process';
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->jobs[$id] = $job;
|
||||||
|
$this->save();
|
||||||
|
|
||||||
|
return $job;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function batchConvert(string $inputFile, array $formats): array
|
||||||
|
{
|
||||||
|
$results = [];
|
||||||
|
foreach ($formats as $format => $settings) {
|
||||||
|
$params = array_merge(
|
||||||
|
['input_file' => $inputFile, 'output_format' => $format],
|
||||||
|
$settings
|
||||||
|
);
|
||||||
|
$results[$format] = $this->convert($params);
|
||||||
|
}
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getJob(string $id): ?array
|
||||||
|
{
|
||||||
|
$this->refreshJob($id);
|
||||||
|
return $this->jobs[$id] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAllJobs(): array
|
||||||
|
{
|
||||||
|
foreach (array_keys($this->jobs) as $id) {
|
||||||
|
$this->refreshJob($id);
|
||||||
|
}
|
||||||
|
return array_values($this->jobs);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancelJob(string $id): bool
|
||||||
|
{
|
||||||
|
if (!isset($this->jobs[$id])) return false;
|
||||||
|
|
||||||
|
$job = $this->jobs[$id];
|
||||||
|
if ($job['pid'] && $job['status'] === 'running') {
|
||||||
|
posix_kill($job['pid'], SIGTERM);
|
||||||
|
$this->jobs[$id]['status'] = 'cancelled';
|
||||||
|
$this->save();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteJob(string $id): bool
|
||||||
|
{
|
||||||
|
if (isset($this->jobs[$id])) {
|
||||||
|
$this->cancelJob($id);
|
||||||
|
// Clean up output file
|
||||||
|
if (isset($this->jobs[$id]['output_file']) && file_exists($this->jobs[$id]['output_file'])) {
|
||||||
|
unlink($this->jobs[$id]['output_file']);
|
||||||
|
}
|
||||||
|
unset($this->jobs[$id]);
|
||||||
|
$this->save();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getProgress(string $id): array
|
||||||
|
{
|
||||||
|
if (!isset($this->jobs[$id])) {
|
||||||
|
return ['error' => 'Job not found'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$config = require __DIR__ . '/../../config/app.php';
|
||||||
|
$progressFile = $config['storage']['logs'] . "/progress_{$id}.txt";
|
||||||
|
|
||||||
|
$progress = ['percent' => 0, 'fps' => 0, 'speed' => '0x', 'time' => '00:00:00'];
|
||||||
|
|
||||||
|
if (file_exists($progressFile)) {
|
||||||
|
$content = file_get_contents($progressFile);
|
||||||
|
foreach (explode("\n", $content) as $line) {
|
||||||
|
if (str_contains($line, '=')) {
|
||||||
|
[$key, $val] = explode('=', $line, 2);
|
||||||
|
$key = trim($key);
|
||||||
|
$val = trim($val);
|
||||||
|
if ($key === 'out_time') $progress['time'] = $val;
|
||||||
|
if ($key === 'fps') $progress['fps'] = (float)$val;
|
||||||
|
if ($key === 'speed') $progress['speed'] = $val;
|
||||||
|
if ($key === 'progress' && $val === 'end') $progress['percent'] = 100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$duration = $this->jobs[$id]['input_info']['duration'] ?? 0;
|
||||||
|
if ($duration > 0 && $progress['percent'] < 100) {
|
||||||
|
$current = $this->timeToSeconds($progress['time']);
|
||||||
|
$progress['percent'] = min(99, round(($current / $duration) * 100, 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $progress;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function refreshJob(string $id): void
|
||||||
|
{
|
||||||
|
if (!isset($this->jobs[$id])) return;
|
||||||
|
$job = &$this->jobs[$id];
|
||||||
|
|
||||||
|
if ($job['status'] === 'running' && $job['pid']) {
|
||||||
|
if (!posix_kill($job['pid'], 0)) {
|
||||||
|
// Check if output file exists and has size
|
||||||
|
if (isset($job['output_file']) && file_exists($job['output_file']) && filesize($job['output_file']) > 0) {
|
||||||
|
$job['status'] = 'completed';
|
||||||
|
$job['completed_at'] = date('c');
|
||||||
|
$job['output_size'] = filesize($job['output_file']);
|
||||||
|
} else {
|
||||||
|
$job['status'] = 'error';
|
||||||
|
$job['error'] = 'Process ended without output';
|
||||||
|
}
|
||||||
|
$this->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildCommand(string $input, string $output, string $format, string $preset, ?string $resolution, array $params): string
|
||||||
|
{
|
||||||
|
$config = require __DIR__ . '/../../config/app.php';
|
||||||
|
$ffmpeg = $config['ffmpeg']['binary'];
|
||||||
|
$formatConfig = $config['formats']['video'][$format] ?? $config['formats']['audio'][$format] ?? [];
|
||||||
|
$presetConfig = $config['presets'][$preset] ?? $config['presets']['balanced'];
|
||||||
|
$threads = $config['ffmpeg']['threads'];
|
||||||
|
|
||||||
|
$cmd = "{$ffmpeg} -y -i " . escapeshellarg($input);
|
||||||
|
$cmd .= " -threads {$threads}";
|
||||||
|
|
||||||
|
// Check if audio-only
|
||||||
|
$isAudio = isset($config['formats']['audio'][$format]);
|
||||||
|
|
||||||
|
if ($isAudio) {
|
||||||
|
$cmd .= " -vn";
|
||||||
|
$cmd .= " -c:a " . escapeshellarg($formatConfig['codec']);
|
||||||
|
if (isset($params['audio_bitrate'])) {
|
||||||
|
$cmd .= " -b:a " . escapeshellarg($params['audio_bitrate']);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$cmd .= " -c:v " . escapeshellarg($formatConfig['codec']);
|
||||||
|
$cmd .= " -preset " . escapeshellarg($presetConfig['preset']);
|
||||||
|
$cmd .= " -crf " . (int)$presetConfig['crf'];
|
||||||
|
|
||||||
|
if ($resolution && isset($config['resolutions'][$resolution])) {
|
||||||
|
$res = $config['resolutions'][$resolution];
|
||||||
|
$cmd .= " -vf scale={$res['width']}:{$res['height']}";
|
||||||
|
}
|
||||||
|
|
||||||
|
$cmd .= " -c:a aac -b:a 128k";
|
||||||
|
}
|
||||||
|
|
||||||
|
// HLS specific
|
||||||
|
if ($format === 'hls') {
|
||||||
|
$cmd .= " -hls_time 4 -hls_list_size 0 -hls_segment_filename "
|
||||||
|
. escapeshellarg(dirname($output) . "/segment_%03d.ts");
|
||||||
|
}
|
||||||
|
|
||||||
|
// DASH specific
|
||||||
|
if ($format === 'dash') {
|
||||||
|
$cmd .= " -use_timeline 1 -use_template 1 -adaptation_sets 'id=0,streams=v id=1,streams=a'";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extra params
|
||||||
|
if (isset($params['video_bitrate'])) {
|
||||||
|
$cmd .= " -b:v " . escapeshellarg($params['video_bitrate']);
|
||||||
|
}
|
||||||
|
if (isset($params['fps'])) {
|
||||||
|
$cmd .= " -r " . (int)$params['fps'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$cmd .= " " . escapeshellarg($output);
|
||||||
|
return $cmd;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function timeToSeconds(string $time): float
|
||||||
|
{
|
||||||
|
$parts = explode(':', $time);
|
||||||
|
if (count($parts) !== 3) return 0;
|
||||||
|
return (int)$parts[0] * 3600 + (int)$parts[1] * 60 + (float)$parts[2];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace VideoConverter\Pipeline;
|
||||||
|
|
||||||
|
class Pipeline
|
||||||
|
{
|
||||||
|
private string $id;
|
||||||
|
private string $name;
|
||||||
|
private array $stages = [];
|
||||||
|
private string $status = 'idle'; // idle, running, paused, error, completed
|
||||||
|
private ?string $inputSource = null;
|
||||||
|
private array $metadata = [];
|
||||||
|
private float $progress = 0;
|
||||||
|
private ?int $pid = null;
|
||||||
|
private string $createdAt;
|
||||||
|
|
||||||
|
public function __construct(string $name, ?string $id = null)
|
||||||
|
{
|
||||||
|
$this->id = $id ?? bin2hex(random_bytes(8));
|
||||||
|
$this->name = $name;
|
||||||
|
$this->createdAt = date('c');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): string { return $this->id; }
|
||||||
|
public function getName(): string { return $this->name; }
|
||||||
|
public function getStatus(): string { return $this->status; }
|
||||||
|
public function getProgress(): float { return $this->progress; }
|
||||||
|
public function getPid(): ?int { return $this->pid; }
|
||||||
|
public function getStages(): array { return $this->stages; }
|
||||||
|
public function getInputSource(): ?string { return $this->inputSource; }
|
||||||
|
|
||||||
|
public function setStatus(string $status): void { $this->status = $status; }
|
||||||
|
public function setProgress(float $progress): void { $this->progress = min(100, max(0, $progress)); }
|
||||||
|
public function setPid(?int $pid): void { $this->pid = $pid; }
|
||||||
|
public function setInputSource(string $source): void { $this->inputSource = $source; }
|
||||||
|
|
||||||
|
public function addStage(PipelineStage $stage): self
|
||||||
|
{
|
||||||
|
$this->stages[] = $stage;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function removeStage(int $index): self
|
||||||
|
{
|
||||||
|
if (isset($this->stages[$index])) {
|
||||||
|
array_splice($this->stages, $index, 1);
|
||||||
|
}
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function insertStage(int $index, PipelineStage $stage): self
|
||||||
|
{
|
||||||
|
array_splice($this->stages, $index, 0, [$stage]);
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setMetadata(string $key, mixed $value): void
|
||||||
|
{
|
||||||
|
$this->metadata[$key] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMetadata(?string $key = null): mixed
|
||||||
|
{
|
||||||
|
if ($key === null) return $this->metadata;
|
||||||
|
return $this->metadata[$key] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function buildFFmpegCommand(string $inputPath, string $outputPath): string
|
||||||
|
{
|
||||||
|
$config = require __DIR__ . '/../../config/app.php';
|
||||||
|
$cmd = $config['ffmpeg']['binary'];
|
||||||
|
$parts = ["-y -i " . escapeshellarg($inputPath)];
|
||||||
|
|
||||||
|
foreach ($this->stages as $stage) {
|
||||||
|
$parts[] = $stage->toFFmpegArgs();
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts[] = escapeshellarg($outputPath);
|
||||||
|
return $cmd . ' ' . implode(' ', $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function buildStreamCommand(string $inputUrl, string $outputUrl): string
|
||||||
|
{
|
||||||
|
$config = require __DIR__ . '/../../config/app.php';
|
||||||
|
$cmd = $config['ffmpeg']['binary'];
|
||||||
|
$parts = ["-re -i " . escapeshellarg($inputUrl)];
|
||||||
|
|
||||||
|
foreach ($this->stages as $stage) {
|
||||||
|
$parts[] = $stage->toFFmpegArgs();
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts[] = "-f flv " . escapeshellarg($outputUrl);
|
||||||
|
return $cmd . ' ' . implode(' ', $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'name' => $this->name,
|
||||||
|
'status' => $this->status,
|
||||||
|
'progress' => $this->progress,
|
||||||
|
'pid' => $this->pid,
|
||||||
|
'input_source' => $this->inputSource,
|
||||||
|
'stages' => array_map(fn(PipelineStage $s) => $s->toArray(), $this->stages),
|
||||||
|
'metadata' => $this->metadata,
|
||||||
|
'created_at' => $this->createdAt,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fromArray(array $data): self
|
||||||
|
{
|
||||||
|
$pipeline = new self($data['name'], $data['id']);
|
||||||
|
$pipeline->status = $data['status'] ?? 'idle';
|
||||||
|
$pipeline->progress = $data['progress'] ?? 0;
|
||||||
|
$pipeline->pid = $data['pid'] ?? null;
|
||||||
|
$pipeline->inputSource = $data['input_source'] ?? null;
|
||||||
|
$pipeline->metadata = $data['metadata'] ?? [];
|
||||||
|
$pipeline->createdAt = $data['created_at'] ?? date('c');
|
||||||
|
|
||||||
|
foreach (($data['stages'] ?? []) as $stageData) {
|
||||||
|
$pipeline->addStage(PipelineStage::fromArray($stageData));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $pipeline;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace VideoConverter\Pipeline;
|
||||||
|
|
||||||
|
class PipelineManager
|
||||||
|
{
|
||||||
|
private string $stateFile;
|
||||||
|
private array $pipelines = [];
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->stateFile = __DIR__ . '/../../storage/temp/pipelines.json';
|
||||||
|
$this->load();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function load(): void
|
||||||
|
{
|
||||||
|
if (file_exists($this->stateFile)) {
|
||||||
|
$data = json_decode(file_get_contents($this->stateFile), true);
|
||||||
|
foreach (($data['pipelines'] ?? []) as $pData) {
|
||||||
|
$this->pipelines[$pData['id']] = Pipeline::fromArray($pData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(): void
|
||||||
|
{
|
||||||
|
$dir = dirname($this->stateFile);
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
mkdir($dir, 0755, true);
|
||||||
|
}
|
||||||
|
$data = ['pipelines' => []];
|
||||||
|
foreach ($this->pipelines as $pipeline) {
|
||||||
|
$data['pipelines'][] = $pipeline->toArray();
|
||||||
|
}
|
||||||
|
file_put_contents($this->stateFile, json_encode($data, JSON_PRETTY_PRINT));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(string $name): Pipeline
|
||||||
|
{
|
||||||
|
$pipeline = new Pipeline($name);
|
||||||
|
$this->pipelines[$pipeline->getId()] = $pipeline;
|
||||||
|
$this->save();
|
||||||
|
return $pipeline;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(string $id): ?Pipeline
|
||||||
|
{
|
||||||
|
return $this->pipelines[$id] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAll(): array
|
||||||
|
{
|
||||||
|
return $this->pipelines;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(string $id): bool
|
||||||
|
{
|
||||||
|
if (isset($this->pipelines[$id])) {
|
||||||
|
$pipeline = $this->pipelines[$id];
|
||||||
|
if ($pipeline->getStatus() === 'running' && $pipeline->getPid()) {
|
||||||
|
posix_kill($pipeline->getPid(), SIGTERM);
|
||||||
|
}
|
||||||
|
unset($this->pipelines[$id]);
|
||||||
|
$this->save();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRunningCount(): int
|
||||||
|
{
|
||||||
|
$count = 0;
|
||||||
|
foreach ($this->pipelines as $p) {
|
||||||
|
if ($p->getStatus() === 'running') $count++;
|
||||||
|
}
|
||||||
|
return $count;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getByStatus(string $status): array
|
||||||
|
{
|
||||||
|
return array_filter($this->pipelines, fn(Pipeline $p) => $p->getStatus() === $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return array_map(fn(Pipeline $p) => $p->toArray(), array_values($this->pipelines));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace VideoConverter\Pipeline;
|
||||||
|
|
||||||
|
class PipelineStage
|
||||||
|
{
|
||||||
|
private string $id;
|
||||||
|
private string $type; // transcode, scale, filter, audio, watermark, trim, split
|
||||||
|
private array $params;
|
||||||
|
private bool $enabled;
|
||||||
|
private string $label;
|
||||||
|
|
||||||
|
public function __construct(string $type, array $params = [], string $label = '', bool $enabled = true)
|
||||||
|
{
|
||||||
|
$this->id = bin2hex(random_bytes(4));
|
||||||
|
$this->type = $type;
|
||||||
|
$this->params = $params;
|
||||||
|
$this->label = $label ?: ucfirst($type);
|
||||||
|
$this->enabled = $enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): string { return $this->id; }
|
||||||
|
public function getType(): string { return $this->type; }
|
||||||
|
public function getParams(): array { return $this->params; }
|
||||||
|
public function isEnabled(): bool { return $this->enabled; }
|
||||||
|
public function getLabel(): string { return $this->label; }
|
||||||
|
|
||||||
|
public function setEnabled(bool $enabled): void { $this->enabled = $enabled; }
|
||||||
|
public function setParams(array $params): void { $this->params = $params; }
|
||||||
|
|
||||||
|
public function toFFmpegArgs(): string
|
||||||
|
{
|
||||||
|
if (!$this->enabled) return '';
|
||||||
|
|
||||||
|
return match ($this->type) {
|
||||||
|
'transcode' => $this->buildTranscodeArgs(),
|
||||||
|
'scale' => $this->buildScaleArgs(),
|
||||||
|
'filter' => $this->buildFilterArgs(),
|
||||||
|
'audio' => $this->buildAudioArgs(),
|
||||||
|
'watermark' => $this->buildWatermarkArgs(),
|
||||||
|
'trim' => $this->buildTrimArgs(),
|
||||||
|
'bitrate' => $this->buildBitrateArgs(),
|
||||||
|
'framerate' => $this->buildFramerateArgs(),
|
||||||
|
'deinterlace' => '-vf yadif',
|
||||||
|
'denoise' => '-vf hqdn3d',
|
||||||
|
'stabilize' => '-vf deshake',
|
||||||
|
default => '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildTranscodeArgs(): string
|
||||||
|
{
|
||||||
|
$args = [];
|
||||||
|
if (isset($this->params['video_codec'])) {
|
||||||
|
$args[] = "-c:v " . escapeshellarg($this->params['video_codec']);
|
||||||
|
}
|
||||||
|
if (isset($this->params['audio_codec'])) {
|
||||||
|
$args[] = "-c:a " . escapeshellarg($this->params['audio_codec']);
|
||||||
|
}
|
||||||
|
if (isset($this->params['preset'])) {
|
||||||
|
$args[] = "-preset " . escapeshellarg($this->params['preset']);
|
||||||
|
}
|
||||||
|
if (isset($this->params['crf'])) {
|
||||||
|
$args[] = "-crf " . (int)$this->params['crf'];
|
||||||
|
}
|
||||||
|
return implode(' ', $args);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildScaleArgs(): string
|
||||||
|
{
|
||||||
|
$w = (int)($this->params['width'] ?? -1);
|
||||||
|
$h = (int)($this->params['height'] ?? -1);
|
||||||
|
$algo = $this->params['algorithm'] ?? 'lanczos';
|
||||||
|
return "-vf scale={$w}:{$h}:flags={$algo}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildFilterArgs(): string
|
||||||
|
{
|
||||||
|
$filters = [];
|
||||||
|
if (isset($this->params['brightness'])) {
|
||||||
|
$filters[] = "eq=brightness=" . (float)$this->params['brightness'];
|
||||||
|
}
|
||||||
|
if (isset($this->params['contrast'])) {
|
||||||
|
$filters[] = "eq=contrast=" . (float)$this->params['contrast'];
|
||||||
|
}
|
||||||
|
if (isset($this->params['saturation'])) {
|
||||||
|
$filters[] = "eq=saturation=" . (float)$this->params['saturation'];
|
||||||
|
}
|
||||||
|
if (isset($this->params['gamma'])) {
|
||||||
|
$filters[] = "eq=gamma=" . (float)$this->params['gamma'];
|
||||||
|
}
|
||||||
|
if (isset($this->params['custom'])) {
|
||||||
|
$filters[] = $this->params['custom'];
|
||||||
|
}
|
||||||
|
return $filters ? '-vf ' . escapeshellarg(implode(',', $filters)) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildAudioArgs(): string
|
||||||
|
{
|
||||||
|
$args = [];
|
||||||
|
if (isset($this->params['codec'])) {
|
||||||
|
$args[] = "-c:a " . escapeshellarg($this->params['codec']);
|
||||||
|
}
|
||||||
|
if (isset($this->params['bitrate'])) {
|
||||||
|
$args[] = "-b:a " . escapeshellarg($this->params['bitrate']);
|
||||||
|
}
|
||||||
|
if (isset($this->params['sample_rate'])) {
|
||||||
|
$args[] = "-ar " . (int)$this->params['sample_rate'];
|
||||||
|
}
|
||||||
|
if (isset($this->params['channels'])) {
|
||||||
|
$args[] = "-ac " . (int)$this->params['channels'];
|
||||||
|
}
|
||||||
|
if (isset($this->params['volume'])) {
|
||||||
|
$args[] = "-af volume=" . (float)$this->params['volume'];
|
||||||
|
}
|
||||||
|
return implode(' ', $args);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildWatermarkArgs(): string
|
||||||
|
{
|
||||||
|
$image = $this->params['image'] ?? '';
|
||||||
|
$position = $this->params['position'] ?? 'topright';
|
||||||
|
$overlay = match ($position) {
|
||||||
|
'topleft' => 'overlay=10:10',
|
||||||
|
'topright' => 'overlay=W-w-10:10',
|
||||||
|
'bottomleft' => 'overlay=10:H-h-10',
|
||||||
|
'bottomright' => 'overlay=W-w-10:H-h-10',
|
||||||
|
'center' => 'overlay=(W-w)/2:(H-h)/2',
|
||||||
|
default => 'overlay=W-w-10:10',
|
||||||
|
};
|
||||||
|
return "-i " . escapeshellarg($image) . " -filter_complex \"{$overlay}\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildTrimArgs(): string
|
||||||
|
{
|
||||||
|
$args = [];
|
||||||
|
if (isset($this->params['start'])) {
|
||||||
|
$args[] = "-ss " . escapeshellarg($this->params['start']);
|
||||||
|
}
|
||||||
|
if (isset($this->params['duration'])) {
|
||||||
|
$args[] = "-t " . escapeshellarg($this->params['duration']);
|
||||||
|
}
|
||||||
|
if (isset($this->params['end'])) {
|
||||||
|
$args[] = "-to " . escapeshellarg($this->params['end']);
|
||||||
|
}
|
||||||
|
return implode(' ', $args);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildBitrateArgs(): string
|
||||||
|
{
|
||||||
|
$args = [];
|
||||||
|
if (isset($this->params['video'])) {
|
||||||
|
$args[] = "-b:v " . escapeshellarg($this->params['video']);
|
||||||
|
}
|
||||||
|
if (isset($this->params['audio'])) {
|
||||||
|
$args[] = "-b:a " . escapeshellarg($this->params['audio']);
|
||||||
|
}
|
||||||
|
if (isset($this->params['maxrate'])) {
|
||||||
|
$args[] = "-maxrate " . escapeshellarg($this->params['maxrate']);
|
||||||
|
$args[] = "-bufsize " . escapeshellarg($this->params['bufsize'] ?? $this->params['maxrate']);
|
||||||
|
}
|
||||||
|
return implode(' ', $args);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildFramerateArgs(): string
|
||||||
|
{
|
||||||
|
$fps = (float)($this->params['fps'] ?? 30);
|
||||||
|
return "-r {$fps}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'type' => $this->type,
|
||||||
|
'label' => $this->label,
|
||||||
|
'params' => $this->params,
|
||||||
|
'enabled' => $this->enabled,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fromArray(array $data): self
|
||||||
|
{
|
||||||
|
$stage = new self(
|
||||||
|
$data['type'],
|
||||||
|
$data['params'] ?? [],
|
||||||
|
$data['label'] ?? '',
|
||||||
|
$data['enabled'] ?? true
|
||||||
|
);
|
||||||
|
if (isset($data['id'])) {
|
||||||
|
// Use reflection to set the id for deserialization
|
||||||
|
$ref = new \ReflectionProperty($stage, 'id');
|
||||||
|
$ref->setValue($stage, $data['id']);
|
||||||
|
}
|
||||||
|
return $stage;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace VideoConverter\Process;
|
||||||
|
|
||||||
|
class FFmpegProcess
|
||||||
|
{
|
||||||
|
private string $command;
|
||||||
|
private ?int $pid = null;
|
||||||
|
private string $logFile;
|
||||||
|
private string $progressFile;
|
||||||
|
private float $duration = 0;
|
||||||
|
private string $status = 'pending';
|
||||||
|
private array $outputLines = [];
|
||||||
|
|
||||||
|
public function __construct(string $command, string $jobId)
|
||||||
|
{
|
||||||
|
$config = require __DIR__ . '/../../config/app.php';
|
||||||
|
$this->command = $command;
|
||||||
|
$logDir = $config['storage']['logs'];
|
||||||
|
if (!is_dir($logDir)) mkdir($logDir, 0755, true);
|
||||||
|
$this->logFile = $logDir . "/ffmpeg_{$jobId}.log";
|
||||||
|
$this->progressFile = $logDir . "/progress_{$jobId}.txt";
|
||||||
|
}
|
||||||
|
|
||||||
|
public function start(): bool
|
||||||
|
{
|
||||||
|
$cmd = $this->command
|
||||||
|
. " -progress " . escapeshellarg($this->progressFile)
|
||||||
|
. " -stats_period 0.5"
|
||||||
|
. " 2>" . escapeshellarg($this->logFile)
|
||||||
|
. " & echo $!";
|
||||||
|
|
||||||
|
$output = [];
|
||||||
|
exec($cmd, $output);
|
||||||
|
$this->pid = (int)($output[0] ?? 0);
|
||||||
|
|
||||||
|
if ($this->pid > 0) {
|
||||||
|
$this->status = 'running';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$this->status = 'error';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stop(): void
|
||||||
|
{
|
||||||
|
if ($this->pid && $this->isRunning()) {
|
||||||
|
posix_kill($this->pid, SIGTERM);
|
||||||
|
usleep(500000);
|
||||||
|
if ($this->isRunning()) {
|
||||||
|
posix_kill($this->pid, SIGKILL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->status = 'stopped';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pause(): void
|
||||||
|
{
|
||||||
|
if ($this->pid && $this->isRunning()) {
|
||||||
|
posix_kill($this->pid, SIGSTOP);
|
||||||
|
$this->status = 'paused';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resume(): void
|
||||||
|
{
|
||||||
|
if ($this->pid) {
|
||||||
|
posix_kill($this->pid, SIGCONT);
|
||||||
|
$this->status = 'running';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isRunning(): bool
|
||||||
|
{
|
||||||
|
if (!$this->pid) return false;
|
||||||
|
return posix_kill($this->pid, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getProgress(): array
|
||||||
|
{
|
||||||
|
$progress = [
|
||||||
|
'percent' => 0,
|
||||||
|
'frame' => 0,
|
||||||
|
'fps' => 0,
|
||||||
|
'speed' => '0x',
|
||||||
|
'time' => '00:00:00.00',
|
||||||
|
'bitrate' => '0kbits/s',
|
||||||
|
'size' => '0kB',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!file_exists($this->progressFile)) return $progress;
|
||||||
|
|
||||||
|
$content = file_get_contents($this->progressFile);
|
||||||
|
$lines = explode("\n", $content);
|
||||||
|
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
$line = trim($line);
|
||||||
|
if (str_contains($line, '=')) {
|
||||||
|
[$key, $value] = explode('=', $line, 2);
|
||||||
|
$key = trim($key);
|
||||||
|
$value = trim($value);
|
||||||
|
|
||||||
|
switch ($key) {
|
||||||
|
case 'frame': $progress['frame'] = (int)$value; break;
|
||||||
|
case 'fps': $progress['fps'] = (float)$value; break;
|
||||||
|
case 'speed': $progress['speed'] = $value; break;
|
||||||
|
case 'out_time': $progress['time'] = $value; break;
|
||||||
|
case 'total_size': $progress['size'] = $this->formatBytes((int)$value); break;
|
||||||
|
case 'bitrate': $progress['bitrate'] = $value; break;
|
||||||
|
case 'progress':
|
||||||
|
if ($value === 'end') $progress['percent'] = 100;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->duration > 0 && $progress['percent'] < 100) {
|
||||||
|
$currentTime = $this->timeToSeconds($progress['time']);
|
||||||
|
$progress['percent'] = min(99, round(($currentTime / $this->duration) * 100, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $progress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLog(int $lines = 50): string
|
||||||
|
{
|
||||||
|
if (!file_exists($this->logFile)) return '';
|
||||||
|
$all = file($this->logFile);
|
||||||
|
return implode('', array_slice($all, -$lines));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDuration(float $duration): void
|
||||||
|
{
|
||||||
|
$this->duration = $duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPid(): ?int { return $this->pid; }
|
||||||
|
public function getStatus(): string { return $this->status; }
|
||||||
|
public function getCommand(): string { return $this->command; }
|
||||||
|
|
||||||
|
private function timeToSeconds(string $time): float
|
||||||
|
{
|
||||||
|
$parts = explode(':', $time);
|
||||||
|
if (count($parts) !== 3) return 0;
|
||||||
|
return (int)$parts[0] * 3600 + (int)$parts[1] * 60 + (float)$parts[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatBytes(int $bytes): string
|
||||||
|
{
|
||||||
|
$units = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
$i = 0;
|
||||||
|
$size = (float)$bytes;
|
||||||
|
while ($size >= 1024 && $i < count($units) - 1) {
|
||||||
|
$size /= 1024;
|
||||||
|
$i++;
|
||||||
|
}
|
||||||
|
return round($size, 1) . $units[$i];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace VideoConverter\Process;
|
||||||
|
|
||||||
|
class MediaProbe
|
||||||
|
{
|
||||||
|
private string $ffprobe;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$config = require __DIR__ . '/../../config/app.php';
|
||||||
|
$this->ffprobe = $config['ffmpeg']['ffprobe'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function analyze(string $filePath): array
|
||||||
|
{
|
||||||
|
$cmd = sprintf(
|
||||||
|
'%s -v quiet -print_format json -show_format -show_streams %s',
|
||||||
|
$this->ffprobe,
|
||||||
|
escapeshellarg($filePath)
|
||||||
|
);
|
||||||
|
|
||||||
|
$output = shell_exec($cmd);
|
||||||
|
$data = json_decode($output ?: '{}', true);
|
||||||
|
|
||||||
|
if (!$data) {
|
||||||
|
return ['error' => 'Could not analyze file'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->parseProbeData($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDuration(string $filePath): float
|
||||||
|
{
|
||||||
|
$info = $this->analyze($filePath);
|
||||||
|
return (float)($info['duration'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getThumbnail(string $filePath, string $outputPath, string $time = '00:00:01'): bool
|
||||||
|
{
|
||||||
|
$config = require __DIR__ . '/../../config/app.php';
|
||||||
|
$cmd = sprintf(
|
||||||
|
'%s -y -i %s -ss %s -vframes 1 -vf scale=320:-1 %s 2>/dev/null',
|
||||||
|
$config['ffmpeg']['binary'],
|
||||||
|
escapeshellarg($filePath),
|
||||||
|
escapeshellarg($time),
|
||||||
|
escapeshellarg($outputPath)
|
||||||
|
);
|
||||||
|
exec($cmd, $output, $exitCode);
|
||||||
|
return $exitCode === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseProbeData(array $data): array
|
||||||
|
{
|
||||||
|
$result = [
|
||||||
|
'format' => $data['format']['format_long_name'] ?? 'Unknown',
|
||||||
|
'format_name' => $data['format']['format_name'] ?? '',
|
||||||
|
'duration' => (float)($data['format']['duration'] ?? 0),
|
||||||
|
'size' => (int)($data['format']['size'] ?? 0),
|
||||||
|
'bitrate' => (int)($data['format']['bit_rate'] ?? 0),
|
||||||
|
'streams' => [],
|
||||||
|
'video' => null,
|
||||||
|
'audio' => null,
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach (($data['streams'] ?? []) as $stream) {
|
||||||
|
$type = $stream['codec_type'] ?? '';
|
||||||
|
$info = [
|
||||||
|
'index' => $stream['index'],
|
||||||
|
'type' => $type,
|
||||||
|
'codec' => $stream['codec_name'] ?? 'unknown',
|
||||||
|
'codec_long' => $stream['codec_long_name'] ?? '',
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($type === 'video') {
|
||||||
|
$info['width'] = (int)($stream['width'] ?? 0);
|
||||||
|
$info['height'] = (int)($stream['height'] ?? 0);
|
||||||
|
$info['fps'] = $this->parseFps($stream['r_frame_rate'] ?? '0/1');
|
||||||
|
$info['pix_fmt'] = $stream['pix_fmt'] ?? '';
|
||||||
|
$info['bitrate'] = (int)($stream['bit_rate'] ?? 0);
|
||||||
|
$info['profile'] = $stream['profile'] ?? '';
|
||||||
|
$info['level'] = $stream['level'] ?? '';
|
||||||
|
if (!$result['video']) $result['video'] = $info;
|
||||||
|
} elseif ($type === 'audio') {
|
||||||
|
$info['sample_rate'] = (int)($stream['sample_rate'] ?? 0);
|
||||||
|
$info['channels'] = (int)($stream['channels'] ?? 0);
|
||||||
|
$info['channel_layout'] = $stream['channel_layout'] ?? '';
|
||||||
|
$info['bitrate'] = (int)($stream['bit_rate'] ?? 0);
|
||||||
|
if (!$result['audio']) $result['audio'] = $info;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result['streams'][] = $info;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseFps(string $frac): float
|
||||||
|
{
|
||||||
|
$parts = explode('/', $frac);
|
||||||
|
if (count($parts) === 2 && (int)$parts[1] > 0) {
|
||||||
|
return round((int)$parts[0] / (int)$parts[1], 2);
|
||||||
|
}
|
||||||
|
return (float)$frac;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace VideoConverter\Queue;
|
||||||
|
|
||||||
|
class JobQueue
|
||||||
|
{
|
||||||
|
private string $queueFile;
|
||||||
|
private array $queue = [];
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->queueFile = __DIR__ . '/../../storage/temp/queue.json';
|
||||||
|
$this->load();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function load(): void
|
||||||
|
{
|
||||||
|
if (file_exists($this->queueFile)) {
|
||||||
|
$this->queue = json_decode(file_get_contents($this->queueFile), true) ?: [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function save(): void
|
||||||
|
{
|
||||||
|
$dir = dirname($this->queueFile);
|
||||||
|
if (!is_dir($dir)) mkdir($dir, 0755, true);
|
||||||
|
file_put_contents($this->queueFile, json_encode($this->queue, JSON_PRETTY_PRINT));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function enqueue(array $job): string
|
||||||
|
{
|
||||||
|
$id = bin2hex(random_bytes(8));
|
||||||
|
$job['queue_id'] = $id;
|
||||||
|
$job['queued_at'] = date('c');
|
||||||
|
$job['queue_status'] = 'waiting';
|
||||||
|
$job['priority'] = $job['priority'] ?? 5;
|
||||||
|
|
||||||
|
$this->queue[] = $job;
|
||||||
|
|
||||||
|
// Sort by priority (lower = higher priority)
|
||||||
|
usort($this->queue, fn($a, $b) => ($a['priority'] ?? 5) <=> ($b['priority'] ?? 5));
|
||||||
|
|
||||||
|
$this->save();
|
||||||
|
return $id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function dequeue(): ?array
|
||||||
|
{
|
||||||
|
foreach ($this->queue as &$job) {
|
||||||
|
if ($job['queue_status'] === 'waiting') {
|
||||||
|
$job['queue_status'] = 'processing';
|
||||||
|
$job['started_at'] = date('c');
|
||||||
|
$this->save();
|
||||||
|
return $job;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function complete(string $queueId, array $result = []): void
|
||||||
|
{
|
||||||
|
foreach ($this->queue as &$job) {
|
||||||
|
if ($job['queue_id'] === $queueId) {
|
||||||
|
$job['queue_status'] = 'completed';
|
||||||
|
$job['completed_at'] = date('c');
|
||||||
|
$job['result'] = $result;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fail(string $queueId, string $error): void
|
||||||
|
{
|
||||||
|
foreach ($this->queue as &$job) {
|
||||||
|
if ($job['queue_id'] === $queueId) {
|
||||||
|
$job['queue_status'] = 'failed';
|
||||||
|
$job['failed_at'] = date('c');
|
||||||
|
$job['error'] = $error;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getQueue(): array { return $this->queue; }
|
||||||
|
|
||||||
|
public function getWaiting(): array
|
||||||
|
{
|
||||||
|
return array_values(array_filter($this->queue, fn($j) => $j['queue_status'] === 'waiting'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getProcessing(): array
|
||||||
|
{
|
||||||
|
return array_values(array_filter($this->queue, fn($j) => $j['queue_status'] === 'processing'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clear(string $status = 'completed'): int
|
||||||
|
{
|
||||||
|
$before = count($this->queue);
|
||||||
|
$this->queue = array_values(array_filter($this->queue, fn($j) => $j['queue_status'] !== $status));
|
||||||
|
$this->save();
|
||||||
|
return $before - count($this->queue);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStats(): array
|
||||||
|
{
|
||||||
|
$stats = ['waiting' => 0, 'processing' => 0, 'completed' => 0, 'failed' => 0];
|
||||||
|
foreach ($this->queue as $job) {
|
||||||
|
$status = $job['queue_status'] ?? 'waiting';
|
||||||
|
$stats[$status] = ($stats[$status] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
return $stats;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace VideoConverter\Stream;
|
||||||
|
|
||||||
|
use VideoConverter\Process\FFmpegProcess;
|
||||||
|
|
||||||
|
class StreamManager
|
||||||
|
{
|
||||||
|
private array $activeStreams = [];
|
||||||
|
private string $stateFile;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->stateFile = __DIR__ . '/../../storage/temp/streams.json';
|
||||||
|
$this->load();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function load(): void
|
||||||
|
{
|
||||||
|
if (file_exists($this->stateFile)) {
|
||||||
|
$this->activeStreams = json_decode(file_get_contents($this->stateFile), true) ?: [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function save(): void
|
||||||
|
{
|
||||||
|
$dir = dirname($this->stateFile);
|
||||||
|
if (!is_dir($dir)) mkdir($dir, 0755, true);
|
||||||
|
file_put_contents($this->stateFile, json_encode($this->activeStreams, JSON_PRETTY_PRINT));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function startStream(array $params): array
|
||||||
|
{
|
||||||
|
$id = bin2hex(random_bytes(8));
|
||||||
|
$config = require __DIR__ . '/../../config/app.php';
|
||||||
|
|
||||||
|
$inputUrl = $params['input_url'] ?? '';
|
||||||
|
$outputFormat = $params['output_format'] ?? 'mp4';
|
||||||
|
$resolution = $params['resolution'] ?? null;
|
||||||
|
$preset = $params['preset'] ?? 'fast';
|
||||||
|
|
||||||
|
$formatConfig = $config['formats']['video'][$outputFormat] ?? $config['formats']['video']['mp4'];
|
||||||
|
$presetConfig = $config['presets'][$preset] ?? $config['presets']['fast'];
|
||||||
|
|
||||||
|
$outputDir = $config['storage']['outputs'];
|
||||||
|
$outputFile = "{$outputDir}/stream_{$id}.{$formatConfig['ext']}";
|
||||||
|
|
||||||
|
$cmd = $config['ffmpeg']['binary'] . " -y";
|
||||||
|
|
||||||
|
// Input
|
||||||
|
if (str_starts_with($inputUrl, 'rtmp://') || str_starts_with($inputUrl, 'rtsp://')) {
|
||||||
|
$cmd .= " -re";
|
||||||
|
}
|
||||||
|
$cmd .= " -i " . escapeshellarg($inputUrl);
|
||||||
|
|
||||||
|
// Video codec
|
||||||
|
$cmd .= " -c:v " . escapeshellarg($formatConfig['codec']);
|
||||||
|
$cmd .= " -preset " . escapeshellarg($presetConfig['preset']);
|
||||||
|
$cmd .= " -crf " . (int)$presetConfig['crf'];
|
||||||
|
|
||||||
|
// Resolution
|
||||||
|
if ($resolution && isset($config['resolutions'][$resolution])) {
|
||||||
|
$res = $config['resolutions'][$resolution];
|
||||||
|
$cmd .= " -vf scale={$res['width']}:{$res['height']}";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio
|
||||||
|
$audioCodec = $params['audio_codec'] ?? 'aac';
|
||||||
|
$audioBitrate = $params['audio_bitrate'] ?? '128k';
|
||||||
|
$cmd .= " -c:a " . escapeshellarg($audioCodec);
|
||||||
|
$cmd .= " -b:a " . escapeshellarg($audioBitrate);
|
||||||
|
|
||||||
|
$cmd .= " " . escapeshellarg($outputFile);
|
||||||
|
|
||||||
|
$process = new FFmpegProcess($cmd, $id);
|
||||||
|
|
||||||
|
$stream = [
|
||||||
|
'id' => $id,
|
||||||
|
'input_url' => $inputUrl,
|
||||||
|
'output_file' => $outputFile,
|
||||||
|
'output_format' => $outputFormat,
|
||||||
|
'resolution' => $resolution,
|
||||||
|
'preset' => $preset,
|
||||||
|
'status' => 'starting',
|
||||||
|
'pid' => null,
|
||||||
|
'command' => $cmd,
|
||||||
|
'started_at' => date('c'),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($process->start()) {
|
||||||
|
$stream['status'] = 'running';
|
||||||
|
$stream['pid'] = $process->getPid();
|
||||||
|
} else {
|
||||||
|
$stream['status'] = 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->activeStreams[$id] = $stream;
|
||||||
|
$this->save();
|
||||||
|
|
||||||
|
return $stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stopStream(string $id): bool
|
||||||
|
{
|
||||||
|
if (!isset($this->activeStreams[$id])) return false;
|
||||||
|
|
||||||
|
$stream = $this->activeStreams[$id];
|
||||||
|
if ($stream['pid']) {
|
||||||
|
posix_kill($stream['pid'], SIGTERM);
|
||||||
|
usleep(500000);
|
||||||
|
if (posix_kill($stream['pid'], 0)) {
|
||||||
|
posix_kill($stream['pid'], SIGKILL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->activeStreams[$id]['status'] = 'stopped';
|
||||||
|
$this->activeStreams[$id]['stopped_at'] = date('c');
|
||||||
|
$this->save();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function switchFormat(string $id, string $newFormat, ?string $resolution = null): array
|
||||||
|
{
|
||||||
|
if (!isset($this->activeStreams[$id])) {
|
||||||
|
return ['error' => 'Stream not found'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$oldStream = $this->activeStreams[$id];
|
||||||
|
$this->stopStream($id);
|
||||||
|
|
||||||
|
// Start new stream with same input but different output format
|
||||||
|
return $this->startStream([
|
||||||
|
'input_url' => $oldStream['input_url'],
|
||||||
|
'output_format' => $newFormat,
|
||||||
|
'resolution' => $resolution ?? $oldStream['resolution'],
|
||||||
|
'preset' => $oldStream['preset'],
|
||||||
|
'audio_codec' => 'aac',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStream(string $id): ?array
|
||||||
|
{
|
||||||
|
$this->refreshStatus($id);
|
||||||
|
return $this->activeStreams[$id] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAllStreams(): array
|
||||||
|
{
|
||||||
|
foreach (array_keys($this->activeStreams) as $id) {
|
||||||
|
$this->refreshStatus($id);
|
||||||
|
}
|
||||||
|
return array_values($this->activeStreams);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getActiveStreams(): array
|
||||||
|
{
|
||||||
|
return array_values(array_filter($this->getAllStreams(), fn($s) => $s['status'] === 'running'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function refreshStatus(string $id): void
|
||||||
|
{
|
||||||
|
if (!isset($this->activeStreams[$id])) return;
|
||||||
|
$stream = &$this->activeStreams[$id];
|
||||||
|
|
||||||
|
if ($stream['status'] === 'running' && $stream['pid']) {
|
||||||
|
if (!posix_kill($stream['pid'], 0)) {
|
||||||
|
$stream['status'] = 'completed';
|
||||||
|
$stream['completed_at'] = date('c');
|
||||||
|
$this->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteStream(string $id): bool
|
||||||
|
{
|
||||||
|
if (isset($this->activeStreams[$id])) {
|
||||||
|
if ($this->activeStreams[$id]['status'] === 'running') {
|
||||||
|
$this->stopStream($id);
|
||||||
|
}
|
||||||
|
unset($this->activeStreams[$id]);
|
||||||
|
$this->save();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStats(): array
|
||||||
|
{
|
||||||
|
$all = $this->getAllStreams();
|
||||||
|
return [
|
||||||
|
'total' => count($all),
|
||||||
|
'running' => count(array_filter($all, fn($s) => $s['status'] === 'running')),
|
||||||
|
'completed' => count(array_filter($all, fn($s) => $s['status'] === 'completed')),
|
||||||
|
'errors' => count(array_filter($all, fn($s) => $s['status'] === 'error')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace VideoConverter\WebSocket;
|
||||||
|
|
||||||
|
use Ratchet\MessageComponentInterface;
|
||||||
|
use Ratchet\ConnectionInterface;
|
||||||
|
use VideoConverter\Format\FormatConverter;
|
||||||
|
use VideoConverter\Stream\StreamManager;
|
||||||
|
use VideoConverter\Pipeline\PipelineManager;
|
||||||
|
use VideoConverter\Queue\JobQueue;
|
||||||
|
|
||||||
|
class StatusServer implements MessageComponentInterface
|
||||||
|
{
|
||||||
|
protected \SplObjectStorage $clients;
|
||||||
|
private FormatConverter $converter;
|
||||||
|
private StreamManager $streamManager;
|
||||||
|
private PipelineManager $pipelineManager;
|
||||||
|
private JobQueue $queue;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->clients = new \SplObjectStorage();
|
||||||
|
$this->converter = new FormatConverter();
|
||||||
|
$this->streamManager = new StreamManager();
|
||||||
|
$this->pipelineManager = new PipelineManager();
|
||||||
|
$this->queue = new JobQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onOpen(ConnectionInterface $conn): void
|
||||||
|
{
|
||||||
|
$this->clients->attach($conn);
|
||||||
|
$conn->send(json_encode([
|
||||||
|
'type' => 'connected',
|
||||||
|
'message' => 'Connected to Video Converter Suite',
|
||||||
|
'client_id' => spl_object_id($conn),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onMessage(ConnectionInterface $from, $msg): void
|
||||||
|
{
|
||||||
|
$data = json_decode($msg, true);
|
||||||
|
if (!$data || !isset($data['action'])) return;
|
||||||
|
|
||||||
|
$response = match ($data['action']) {
|
||||||
|
'get_status' => $this->getFullStatus(),
|
||||||
|
'get_jobs' => ['type' => 'jobs', 'data' => $this->converter->getAllJobs()],
|
||||||
|
'get_streams' => ['type' => 'streams', 'data' => $this->streamManager->getAllStreams()],
|
||||||
|
'get_pipelines' => ['type' => 'pipelines', 'data' => $this->pipelineManager->toArray()],
|
||||||
|
'get_queue' => ['type' => 'queue', 'data' => $this->queue->getQueue()],
|
||||||
|
'get_progress' => $this->getJobProgress($data['job_id'] ?? ''),
|
||||||
|
'start_stream' => $this->handleStartStream($data),
|
||||||
|
'stop_stream' => $this->handleStopStream($data['stream_id'] ?? ''),
|
||||||
|
'switch_format' => $this->handleSwitchFormat($data),
|
||||||
|
default => ['type' => 'error', 'message' => 'Unknown action'],
|
||||||
|
};
|
||||||
|
|
||||||
|
$from->send(json_encode($response));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onClose(ConnectionInterface $conn): void
|
||||||
|
{
|
||||||
|
$this->clients->detach($conn);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onError(ConnectionInterface $conn, \Exception $e): void
|
||||||
|
{
|
||||||
|
$conn->send(json_encode([
|
||||||
|
'type' => 'error',
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
]));
|
||||||
|
$conn->close();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function broadcastStatus(): void
|
||||||
|
{
|
||||||
|
$status = $this->getFullStatus();
|
||||||
|
$json = json_encode($status);
|
||||||
|
|
||||||
|
foreach ($this->clients as $client) {
|
||||||
|
$client->send($json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getFullStatus(): array
|
||||||
|
{
|
||||||
|
// Reload state
|
||||||
|
$this->converter = new FormatConverter();
|
||||||
|
$this->streamManager = new StreamManager();
|
||||||
|
$this->pipelineManager = new PipelineManager();
|
||||||
|
$this->queue = new JobQueue();
|
||||||
|
|
||||||
|
$jobs = $this->converter->getAllJobs();
|
||||||
|
$runningJobs = array_filter($jobs, fn($j) => $j['status'] === 'running');
|
||||||
|
|
||||||
|
$progressData = [];
|
||||||
|
foreach ($runningJobs as $job) {
|
||||||
|
$progressData[$job['id']] = $this->converter->getProgress($job['id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'type' => 'status',
|
||||||
|
'timestamp' => date('c'),
|
||||||
|
'system' => $this->getSystemStats(),
|
||||||
|
'jobs' => $jobs,
|
||||||
|
'progress' => $progressData,
|
||||||
|
'streams' => $this->streamManager->getAllStreams(),
|
||||||
|
'pipelines' => $this->pipelineManager->toArray(),
|
||||||
|
'queue' => $this->queue->getStats(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getJobProgress(string $jobId): array
|
||||||
|
{
|
||||||
|
$this->converter = new FormatConverter();
|
||||||
|
return [
|
||||||
|
'type' => 'progress',
|
||||||
|
'job_id' => $jobId,
|
||||||
|
'data' => $this->converter->getProgress($jobId),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handleStartStream(array $data): array
|
||||||
|
{
|
||||||
|
$this->streamManager = new StreamManager();
|
||||||
|
$result = $this->streamManager->startStream($data);
|
||||||
|
return ['type' => 'stream_started', 'data' => $result];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handleStopStream(string $streamId): array
|
||||||
|
{
|
||||||
|
$this->streamManager = new StreamManager();
|
||||||
|
$success = $this->streamManager->stopStream($streamId);
|
||||||
|
return ['type' => 'stream_stopped', 'success' => $success, 'stream_id' => $streamId];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handleSwitchFormat(array $data): array
|
||||||
|
{
|
||||||
|
$this->streamManager = new StreamManager();
|
||||||
|
$result = $this->streamManager->switchFormat(
|
||||||
|
$data['stream_id'] ?? '',
|
||||||
|
$data['format'] ?? 'mp4',
|
||||||
|
$data['resolution'] ?? null
|
||||||
|
);
|
||||||
|
return ['type' => 'format_switched', 'data' => $result];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getSystemStats(): array
|
||||||
|
{
|
||||||
|
$load = sys_getloadavg();
|
||||||
|
$memInfo = $this->getMemoryInfo();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'cpu_load' => $load[0] ?? 0,
|
||||||
|
'memory_used' => $memInfo['used'] ?? 0,
|
||||||
|
'memory_total' => $memInfo['total'] ?? 0,
|
||||||
|
'memory_percent' => $memInfo['percent'] ?? 0,
|
||||||
|
'disk_free' => disk_free_space('/'),
|
||||||
|
'disk_total' => disk_total_space('/'),
|
||||||
|
'uptime' => (int)(file_exists('/proc/uptime')
|
||||||
|
? (float)explode(' ', file_get_contents('/proc/uptime'))[0]
|
||||||
|
: 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getMemoryInfo(): array
|
||||||
|
{
|
||||||
|
if (!file_exists('/proc/meminfo')) {
|
||||||
|
return ['total' => 0, 'used' => 0, 'percent' => 0];
|
||||||
|
}
|
||||||
|
$content = file_get_contents('/proc/meminfo');
|
||||||
|
preg_match('/MemTotal:\s+(\d+)/', $content, $total);
|
||||||
|
preg_match('/MemAvailable:\s+(\d+)/', $content, $available);
|
||||||
|
$t = (int)($total[1] ?? 0) * 1024;
|
||||||
|
$a = (int)($available[1] ?? 0) * 1024;
|
||||||
|
$u = $t - $a;
|
||||||
|
return [
|
||||||
|
'total' => $t,
|
||||||
|
'used' => $u,
|
||||||
|
'percent' => $t > 0 ? round(($u / $t) * 100, 1) : 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,447 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title><?= $config['app_name'] ?> - Control Panel</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;600;700;900&family=JetBrains+Mono:wght@300;400;500;600;700&family=Rajdhani:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="/css/controlpanel.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- ====== TOP BAR ====== -->
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="topbar-logo">
|
||||||
|
<div class="reactor-icon">☢</div>
|
||||||
|
<div>
|
||||||
|
<div class="topbar-title">Video Converter Suite</div>
|
||||||
|
<div class="topbar-subtitle">Pipeline Control System v<?= $config['version'] ?></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="topbar-status">
|
||||||
|
<div class="status-indicator">
|
||||||
|
<span class="status-dot" id="systemStatusDot"></span>
|
||||||
|
<span id="systemStatusText">SYSTEM ONLINE</span>
|
||||||
|
</div>
|
||||||
|
<div class="status-indicator">
|
||||||
|
<span>JOBS:</span>
|
||||||
|
<span id="activeJobCount" style="color: var(--accent-cyan)">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="clock" id="systemClock">00:00:00</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ====== NAVIGATION ====== -->
|
||||||
|
<div class="nav-bar">
|
||||||
|
<button class="nav-tab active" data-page="dashboard">Dashboard</button>
|
||||||
|
<button class="nav-tab" data-page="converter">Konverter</button>
|
||||||
|
<button class="nav-tab" data-page="streams">Live Streams</button>
|
||||||
|
<button class="nav-tab" data-page="pipelines">Pipelines</button>
|
||||||
|
<button class="nav-tab" data-page="queue">Warteschlange</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ====== PAGES ====== -->
|
||||||
|
<div class="main-container">
|
||||||
|
|
||||||
|
<!-- ==================== DASHBOARD PAGE ==================== -->
|
||||||
|
<div id="page-dashboard" class="page-content">
|
||||||
|
<!-- System Gauges -->
|
||||||
|
<div class="panel-row cols-1">
|
||||||
|
<div class="module">
|
||||||
|
<div class="module-header">
|
||||||
|
<div class="module-title"><span class="icon">⚙</span> SYSTEM MONITOR</div>
|
||||||
|
<button class="btn btn-icon" onclick="refreshStatus()" data-tooltip="Refresh">↻</button>
|
||||||
|
</div>
|
||||||
|
<div class="module-body">
|
||||||
|
<div class="gauge-grid">
|
||||||
|
<div class="gauge">
|
||||||
|
<div class="gauge-label">CPU Load</div>
|
||||||
|
<div class="gauge-value" id="gaugeCpu">0.0</div>
|
||||||
|
<div class="gauge-unit">Load Avg</div>
|
||||||
|
<div class="gauge-bar"><div class="gauge-bar-fill" id="gaugeCpuBar" style="width:0%"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="gauge">
|
||||||
|
<div class="gauge-label">Memory</div>
|
||||||
|
<div class="gauge-value" id="gaugeMem">0</div>
|
||||||
|
<div class="gauge-unit">% Used</div>
|
||||||
|
<div class="gauge-bar"><div class="gauge-bar-fill" id="gaugeMemBar" style="width:0%"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="gauge">
|
||||||
|
<div class="gauge-label">Disk</div>
|
||||||
|
<div class="gauge-value" id="gaugeDisk">0</div>
|
||||||
|
<div class="gauge-unit">GB Free</div>
|
||||||
|
<div class="gauge-bar"><div class="gauge-bar-fill" id="gaugeDiskBar" style="width:0%"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="gauge">
|
||||||
|
<div class="gauge-label">Active Jobs</div>
|
||||||
|
<div class="gauge-value" id="gaugeJobs">0</div>
|
||||||
|
<div class="gauge-unit">Running</div>
|
||||||
|
<div class="gauge-bar"><div class="gauge-bar-fill" id="gaugeJobsBar" style="width:0%"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Active Jobs & Log -->
|
||||||
|
<div class="panel-row cols-2-1">
|
||||||
|
<div class="module">
|
||||||
|
<div class="module-header">
|
||||||
|
<div class="module-title"><span class="icon">▶</span> AKTIVE JOBS</div>
|
||||||
|
<button class="btn" onclick="refreshJobs()">Aktualisieren</button>
|
||||||
|
</div>
|
||||||
|
<div class="module-body">
|
||||||
|
<div class="job-list" id="jobList">
|
||||||
|
<div style="text-align:center; color: var(--text-dim); padding: 20px;">
|
||||||
|
Keine aktiven Jobs
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="module">
|
||||||
|
<div class="module-header">
|
||||||
|
<div class="module-title"><span class="icon">☷</span> SYSTEM LOG</div>
|
||||||
|
<button class="btn btn-icon" onclick="clearLog()">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="module-body">
|
||||||
|
<div class="log-console" id="logConsole">
|
||||||
|
<div class="log-line info"><span class="log-time">[INIT]</span> Video Converter Suite gestartet</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ==================== CONVERTER PAGE ==================== -->
|
||||||
|
<div id="page-converter" class="page-content" style="display:none">
|
||||||
|
<div class="panel-row cols-2">
|
||||||
|
<!-- Upload & Input -->
|
||||||
|
<div class="module">
|
||||||
|
<div class="module-header">
|
||||||
|
<div class="module-title"><span class="icon">⇪</span> EINGANG / UPLOAD</div>
|
||||||
|
</div>
|
||||||
|
<div class="module-body">
|
||||||
|
<div class="upload-zone" id="uploadZone" onclick="document.getElementById('fileInput').click()">
|
||||||
|
<div class="upload-icon">📁</div>
|
||||||
|
<div class="upload-text">Datei hierher ziehen oder klicken</div>
|
||||||
|
<div class="upload-hint">Alle Video- und Audio-Formate / max. 5 GB</div>
|
||||||
|
<input type="file" id="fileInput" accept="video/*,audio/*" style="display:none" onchange="handleFileSelect(event)">
|
||||||
|
</div>
|
||||||
|
<div id="uploadedFileInfo" style="display:none; margin-top:12px;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Format Switchboard -->
|
||||||
|
<div class="module">
|
||||||
|
<div class="module-header">
|
||||||
|
<div class="module-title"><span class="icon">⚙</span> AUSGANGSFORMAT</div>
|
||||||
|
</div>
|
||||||
|
<div class="module-body">
|
||||||
|
<div class="form-label" style="margin-bottom:8px">Video-Formate</div>
|
||||||
|
<div class="format-matrix" id="videoFormatMatrix">
|
||||||
|
<?php foreach ($config['formats']['video'] as $key => $fmt): ?>
|
||||||
|
<div class="format-switch <?= $key === 'mp4' ? 'selected' : '' ?>" data-format="<?= $key ?>" onclick="selectFormat('<?= $key ?>')">
|
||||||
|
<div class="format-name"><?= strtoupper($key) ?></div>
|
||||||
|
<div class="format-desc"><?= $fmt['codec'] ?></div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<div class="form-label" style="margin:12px 0 8px">Audio-Formate</div>
|
||||||
|
<div class="format-matrix" id="audioFormatMatrix">
|
||||||
|
<?php foreach ($config['formats']['audio'] as $key => $fmt): ?>
|
||||||
|
<div class="format-switch" data-format="<?= $key ?>" onclick="selectFormat('<?= $key ?>')">
|
||||||
|
<div class="format-name"><?= strtoupper($key) ?></div>
|
||||||
|
<div class="format-desc"><?= $fmt['codec'] ?></div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Settings Row -->
|
||||||
|
<div class="panel-row cols-3">
|
||||||
|
<!-- Preset -->
|
||||||
|
<div class="module">
|
||||||
|
<div class="module-header">
|
||||||
|
<div class="module-title"><span class="icon">★</span> PRESET</div>
|
||||||
|
</div>
|
||||||
|
<div class="module-body">
|
||||||
|
<div class="switch-panel" id="presetPanel">
|
||||||
|
<?php foreach ($config['presets'] as $key => $p): ?>
|
||||||
|
<div class="switch-unit <?= $key === 'balanced' ? 'active' : '' ?>" data-preset="<?= $key ?>" onclick="selectPreset('<?= $key ?>')">
|
||||||
|
<div class="switch-led"></div>
|
||||||
|
<div class="switch-label"><?= ucfirst($key) ?></div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Resolution -->
|
||||||
|
<div class="module">
|
||||||
|
<div class="module-header">
|
||||||
|
<div class="module-title"><span class="icon">▢</span> AUFLOESUNG</div>
|
||||||
|
</div>
|
||||||
|
<div class="module-body">
|
||||||
|
<div class="switch-panel" id="resolutionPanel">
|
||||||
|
<div class="switch-unit active" data-resolution="original" onclick="selectResolution('original')">
|
||||||
|
<div class="switch-led"></div>
|
||||||
|
<div class="switch-label">Original</div>
|
||||||
|
</div>
|
||||||
|
<?php foreach ($config['resolutions'] as $key => $res): ?>
|
||||||
|
<div class="switch-unit" data-resolution="<?= $key ?>" onclick="selectResolution('<?= $key ?>')">
|
||||||
|
<div class="switch-led"></div>
|
||||||
|
<div class="switch-label"><?= $key ?></div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Controls -->
|
||||||
|
<div class="module">
|
||||||
|
<div class="module-header">
|
||||||
|
<div class="module-title"><span class="icon">▷</span> STEUERUNG</div>
|
||||||
|
</div>
|
||||||
|
<div class="module-body" style="display:flex; flex-direction:column; gap:12px; align-items:center; justify-content:center; min-height:140px;">
|
||||||
|
<button class="btn btn-primary btn-large" id="btnStartConvert" onclick="startConversion()" disabled>
|
||||||
|
▶ KONVERTIERUNG STARTEN
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-emergency" id="btnStopAll" onclick="stopAllJobs()" style="display:none">
|
||||||
|
■ NOTAUS - ALLE STOPPEN
|
||||||
|
</button>
|
||||||
|
<div id="conversionStatus" style="font-size:11px; color:var(--text-dim); text-align:center;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ==================== STREAMS PAGE ==================== -->
|
||||||
|
<div id="page-streams" class="page-content" style="display:none">
|
||||||
|
<div class="panel-row cols-1">
|
||||||
|
<div class="module">
|
||||||
|
<div class="module-header">
|
||||||
|
<div class="module-title"><span class="icon">📡</span> LIVE STREAM STEUERUNG</div>
|
||||||
|
<button class="btn btn-primary" onclick="openStreamModal()">+ Neuer Stream</button>
|
||||||
|
</div>
|
||||||
|
<div class="module-body">
|
||||||
|
<div class="stream-matrix" id="streamMatrix">
|
||||||
|
<div style="text-align:center; color:var(--text-dim); padding:40px; grid-column: 1/-1;">
|
||||||
|
Keine aktiven Streams. Klicke "Neuer Stream" um zu beginnen.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stream Format Switchboard -->
|
||||||
|
<div class="panel-row cols-1">
|
||||||
|
<div class="module">
|
||||||
|
<div class="module-header">
|
||||||
|
<div class="module-title"><span class="icon">☢</span> FORMAT-UMSCHALTER (LIVE)</div>
|
||||||
|
<div style="font-size:10px; color:var(--accent-yellow)">⚠ Format-Wechsel unterbricht den Stream kurz</div>
|
||||||
|
</div>
|
||||||
|
<div class="module-body">
|
||||||
|
<div style="display:grid; grid-template-columns: 200px 1fr; gap: 16px; align-items:start;">
|
||||||
|
<div>
|
||||||
|
<div class="form-label">Aktiver Stream</div>
|
||||||
|
<select class="form-select" id="activeStreamSelect" onchange="selectActiveStream(this.value)">
|
||||||
|
<option value="">-- Stream wählen --</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="form-label">Zielformat wählen (klick = sofort umschalten)</div>
|
||||||
|
<div class="format-matrix" id="streamFormatSwitchboard">
|
||||||
|
<?php foreach ($config['formats']['video'] as $key => $fmt): ?>
|
||||||
|
<div class="format-switch" data-stream-format="<?= $key ?>" onclick="switchStreamFormat('<?= $key ?>')">
|
||||||
|
<div class="format-name"><?= strtoupper($key) ?></div>
|
||||||
|
<div class="format-desc"><?= $fmt['codec'] ?></div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ==================== PIPELINES PAGE ==================== -->
|
||||||
|
<div id="page-pipelines" class="page-content" style="display:none">
|
||||||
|
<div class="panel-row cols-1">
|
||||||
|
<div class="module">
|
||||||
|
<div class="module-header">
|
||||||
|
<div class="module-title"><span class="icon">☰</span> PIPELINE DESIGNER</div>
|
||||||
|
<div style="display:flex; gap:8px;">
|
||||||
|
<button class="btn btn-primary" onclick="createPipeline()">+ Neue Pipeline</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="module-body">
|
||||||
|
<!-- Pipeline List -->
|
||||||
|
<div id="pipelineList" style="margin-bottom:16px;"></div>
|
||||||
|
|
||||||
|
<!-- Pipeline Editor -->
|
||||||
|
<div id="pipelineEditor" style="display:none;">
|
||||||
|
<div class="form-label" style="margin-bottom:8px;">PIPELINE FLOW - Drag & Drop zum Umordnen</div>
|
||||||
|
<div class="pipeline-canvas">
|
||||||
|
<div class="pipeline-flow" id="pipelineFlow">
|
||||||
|
<!-- Input node (always present) -->
|
||||||
|
<div class="pipeline-node active">
|
||||||
|
<div class="node-type">Input</div>
|
||||||
|
<div class="node-name">Source</div>
|
||||||
|
<div class="node-status"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stage Palette -->
|
||||||
|
<div class="form-label" style="margin:12px 0 8px;">VERFUEGBARE STUFEN - Klick zum Hinzufügen</div>
|
||||||
|
<div class="switch-panel" id="stagePalette">
|
||||||
|
<div class="switch-unit" onclick="addPipelineStage('transcode')">
|
||||||
|
<div class="switch-led"></div>
|
||||||
|
<div class="switch-label">Transcode</div>
|
||||||
|
</div>
|
||||||
|
<div class="switch-unit" onclick="addPipelineStage('scale')">
|
||||||
|
<div class="switch-led"></div>
|
||||||
|
<div class="switch-label">Scale</div>
|
||||||
|
</div>
|
||||||
|
<div class="switch-unit" onclick="addPipelineStage('filter')">
|
||||||
|
<div class="switch-led"></div>
|
||||||
|
<div class="switch-label">Filter</div>
|
||||||
|
</div>
|
||||||
|
<div class="switch-unit" onclick="addPipelineStage('audio')">
|
||||||
|
<div class="switch-led"></div>
|
||||||
|
<div class="switch-label">Audio</div>
|
||||||
|
</div>
|
||||||
|
<div class="switch-unit" onclick="addPipelineStage('bitrate')">
|
||||||
|
<div class="switch-led"></div>
|
||||||
|
<div class="switch-label">Bitrate</div>
|
||||||
|
</div>
|
||||||
|
<div class="switch-unit" onclick="addPipelineStage('framerate')">
|
||||||
|
<div class="switch-led"></div>
|
||||||
|
<div class="switch-label">FPS</div>
|
||||||
|
</div>
|
||||||
|
<div class="switch-unit" onclick="addPipelineStage('trim')">
|
||||||
|
<div class="switch-led"></div>
|
||||||
|
<div class="switch-label">Trim</div>
|
||||||
|
</div>
|
||||||
|
<div class="switch-unit" onclick="addPipelineStage('deinterlace')">
|
||||||
|
<div class="switch-led"></div>
|
||||||
|
<div class="switch-label">Deinterlace</div>
|
||||||
|
</div>
|
||||||
|
<div class="switch-unit" onclick="addPipelineStage('denoise')">
|
||||||
|
<div class="switch-led"></div>
|
||||||
|
<div class="switch-label">Denoise</div>
|
||||||
|
</div>
|
||||||
|
<div class="switch-unit" onclick="addPipelineStage('stabilize')">
|
||||||
|
<div class="switch-led"></div>
|
||||||
|
<div class="switch-label">Stabilize</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ==================== QUEUE PAGE ==================== -->
|
||||||
|
<div id="page-queue" class="page-content" style="display:none">
|
||||||
|
<div class="panel-row cols-3-1">
|
||||||
|
<div class="module">
|
||||||
|
<div class="module-header">
|
||||||
|
<div class="module-title"><span class="icon">☰</span> WARTESCHLANGE</div>
|
||||||
|
<div style="display:flex; gap:8px;">
|
||||||
|
<button class="btn" onclick="refreshQueue()">Aktualisieren</button>
|
||||||
|
<button class="btn btn-danger" onclick="clearQueue()">Queue leeren</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="module-body">
|
||||||
|
<div class="job-list" id="queueList">
|
||||||
|
<div style="text-align:center; color: var(--text-dim); padding: 20px;">
|
||||||
|
Warteschlange ist leer
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="module">
|
||||||
|
<div class="module-header">
|
||||||
|
<div class="module-title">STATISTIK</div>
|
||||||
|
</div>
|
||||||
|
<div class="module-body">
|
||||||
|
<div style="display:flex; flex-direction:column; gap:12px;">
|
||||||
|
<div class="gauge">
|
||||||
|
<div class="gauge-label">Wartend</div>
|
||||||
|
<div class="gauge-value" id="queueWaiting">0</div>
|
||||||
|
</div>
|
||||||
|
<div class="gauge">
|
||||||
|
<div class="gauge-label">Verarbeitet</div>
|
||||||
|
<div class="gauge-value" id="queueProcessing">0</div>
|
||||||
|
</div>
|
||||||
|
<div class="gauge">
|
||||||
|
<div class="gauge-label">Abgeschlossen</div>
|
||||||
|
<div class="gauge-value" id="queueCompleted" style="color:var(--accent-green)">0</div>
|
||||||
|
</div>
|
||||||
|
<div class="gauge">
|
||||||
|
<div class="gauge-label">Fehlgeschlagen</div>
|
||||||
|
<div class="gauge-value" id="queueFailed" style="color:var(--accent-red)">0</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ====== STREAM MODAL ====== -->
|
||||||
|
<div class="modal-overlay" id="streamModal">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Neuen Stream starten</h3>
|
||||||
|
<button class="modal-close" onclick="closeStreamModal()">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Stream-URL (RTMP, RTSP, HTTP, Datei)</label>
|
||||||
|
<input class="form-input" type="text" id="streamInputUrl" placeholder="rtmp://server/live/stream oder /pfad/zur/datei.mp4">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Ausgangsformat</label>
|
||||||
|
<select class="form-select" id="streamOutputFormat">
|
||||||
|
<?php foreach ($config['formats']['video'] as $key => $fmt): ?>
|
||||||
|
<option value="<?= $key ?>"><?= strtoupper($key) ?> (<?= $fmt['codec'] ?>)</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Aufloesung</label>
|
||||||
|
<select class="form-select" id="streamResolution">
|
||||||
|
<option value="">Original</option>
|
||||||
|
<?php foreach ($config['resolutions'] as $key => $res): ?>
|
||||||
|
<option value="<?= $key ?>"><?= $res['label'] ?> (<?= $res['width'] ?>x<?= $res['height'] ?>)</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Preset</label>
|
||||||
|
<select class="form-select" id="streamPreset">
|
||||||
|
<?php foreach ($config['presets'] as $key => $p): ?>
|
||||||
|
<option value="<?= $key ?>" <?= $key === 'fast' ? 'selected' : '' ?>><?= ucfirst($key) ?> (CRF <?= $p['crf'] ?>)</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn" onclick="closeStreamModal()">Abbrechen</button>
|
||||||
|
<button class="btn btn-primary" onclick="startNewStream()">▶ Stream starten</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ====== NOTIFICATION CONTAINER ====== -->
|
||||||
|
<div id="notification" class="notification"></div>
|
||||||
|
|
||||||
|
<script src="/js/controlpanel.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user