From 1899ebd948fc0227714efe3aa3b28263d46ae355 Mon Sep 17 00:00:00 2001 From: Thomas Skerbis Date: Thu, 20 Nov 2025 00:35:05 +0100 Subject: [PATCH] Release v4.0.0: Video Converter Presets & UI Improvements - Add video converter presets (Web, Mobile, Archive, Standard) - Implement command preview functionality - Remove separate custom_command textarea for streamlined UI - Add comprehensive type hints and static analysis improvements - Fix preset override behavior and video mapping issues - Improve code quality with REDAXO core methods - Update README and version to 4.0.0 --- README.md | 32 ++--- assets/css/style.css | 5 +- lang/de_de.lang | 17 +++ lang/en_gb.lang | 18 +++ lib/Api/Converter.php | 210 ++++++++++++++++++++++-------- lib/VideoInfo.php | 46 ++++--- package.yml | 4 +- pages/mediapool.ffmpeg.config.php | 102 ++++++++++++++- pages/mediapool.ffmpeg.main.php | 14 +- 9 files changed, 345 insertions(+), 103 deletions(-) diff --git a/README.md b/README.md index 14c6b53..db1d513 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# REDAXO-AddOn: FFmpeg Video Tools v3.0 +# REDAXO-AddOn: FFmpeg Video Tools v4.0 Vollständige Video-Management-Lösung für REDAXO CMS – Konvertierung, Trimming und detaillierte Video-Analyse, alles in einem Addon! @@ -18,6 +18,9 @@ Vollständige Video-Management-Lösung für REDAXO CMS – Konvertierung, Trimmi - **Metadaten-Erhaltung** (Titel, Beschreibung, Copyright) - **Kompressionsanzeige** zeigt eingesparten Speicherplatz - **Auto-Cleanup** für Originaldateien nach erfolgreicher Konvertierung +- **🆕 Vorgefertigte Presets** für Web, Mobile, Archive und Standard-Konvertierungen +- **🆕 Command-Vorschau** zeigt das generierte FFmpeg-Kommando in Echtzeit +- **🆕 Direkte Command-Eingabe** ohne separate Textarea ### 🆕 Video-Trimmer - **Präzises Schneiden** direkt im Browser @@ -310,27 +313,20 @@ ffmpeg -y -i INPUT -vcodec h264 OUTPUT.mp4 - MIME-Types in der Datenbank prüfen - Browser-Unterstützung für Video-Format -## 📝 Changelog v3.0 +## 📝 Changelog v4.0 ### Neue Features -- ✅ Video-Trimmer mit Browser-Integration -- ✅ Video-Informationen mit detaillierter Analyse -- ✅ Video-Thumbnails über Media Manager (VideoPreview-Integration) -- ✅ PHP-API für Module und Templates -- ✅ Responsive Design für alle Seiten -- ✅ Keyboard-Shortcuts für besseren Workflow -- ✅ Web-Optimierung-Scanner mit Score-System -- ✅ Mobile-Optimierung-Checker -- ✅ Hilfe-Seite mit kompletter Dokumentation -- ✅ Video-Galerie-Template mit Thumbnail-Support -- ⚠️ Conflict-Regel: VideoPreview-Addon nicht mehr kompatibel (Funktionalität integriert) +- ✅ **Video-Konverter Presets** – Vorgefertigte Konvertierungsvorlagen (Web, Mobile, Archive, Standard) +- ✅ **Command-Vorschau** – Echtzeit-Anzeige des generierten FFmpeg-Kommandos +- ✅ **Verbesserte UI** – Direkte Command-Eingabe ohne separate Textarea +- ✅ **Type Hints & Statische Analyse** – Vollständige PHPStan/PSalm-Kompatibilität +- ✅ **Bugfixes** – Preset-Override, Video-Mapping, Transparenzen behoben ### Verbesserungen -- ✅ Alle Video-Typen im Trimmer unterstützt -- ✅ Intelligente Dateinamen-Generierung -- ✅ Layout-Fixes für lange Dateinamen -- ✅ Erweiterte MIME-Type-Unterstützung -- ✅ Bessere Fehlerbehandlung +- ✅ Code-Qualität mit REDAXO Core Methods (`rex_file`, `rex_media_service`, `rex_logger`) +- ✅ Performance-Optimierungen in VideoInfo-Klasse +- ✅ Erweiterte Fehlerbehandlung und Logging +- ✅ Debug-Endpunkt für Konvertierungsprozesse ## 📄 Lizenz diff --git a/assets/css/style.css b/assets/css/style.css index 87dd5ba..edf3245 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -31,8 +31,9 @@ } .already-converted { - opacity: 0.7; - background-color: rgba(var(--rex-color-success-rgb, 92, 184, 92), .1); + /* Previously used opacity to indicate converted items — this reduced text contrast. + Use a subtle background color instead for clarity and accessibility. */ + background-color: rgba(var(--rex-color-success-rgb, 92, 184, 92), .08); border-radius: 5px; } diff --git a/lang/de_de.lang b/lang/de_de.lang index e3249b7..c9954b9 100644 --- a/lang/de_de.lang +++ b/lang/de_de.lang @@ -54,6 +54,23 @@ ffmpeg_already_converted = Bereits konvertiert ffmpeg_config_save = Einstellungen speichern ffmpeg_config_saved = Einstellungen wurden gespeichert! +config_preset = Preset +config_preset_none = Keine +config_preset_web = Web (ausgewogen) +config_preset_mobile = Mobile (kleiner, geringere Qualität) +config_preset_archive = Archiv (hohe Qualität) +config_preset_custom = Benutzerdefiniert +config_preset_custom_command = Benutzerdefinierter Befehl +config_preset_help = Preset wählt einen vordefinierten FFmpeg-Befehl. Das Kommandofeld wird entsprechend vorbefüllt. Bei Auswahl von 'Benutzerdefiniert' bearbeite das Kommandofeld oben manuell. Du kannst das Kommando jederzeit direkt ändern. +config_preset_web_desc = Beispiel: ffmpeg -y -i INPUT -c:v libx264 -preset slow -crf 28 -c:a aac -b:a 128k OUTPUT.mp4 (ausgewogen) +config_preset_mobile_desc = Beispiel: ffmpeg -y -i INPUT -c:v libx264 -preset veryfast -crf 30 -c:a aac -b:a 96k -vf scale=-2:720 OUTPUT.mp4 (mobil) +config_preset_archive_desc = Beispiel: ffmpeg -y -i INPUT -c:v libx264 -preset veryslow -crf 22 -c:a copy OUTPUT.mp4 (archiv) +config_preset_custom_desc = Eigener FFmpeg-Befehl; Verwende Platzhalter INPUT und OUTPUT. +config_preset_standard = Standard (Legacy) +config_preset_standard_desc = Beispiel: ffmpeg -y -i INPUT -vcodec h264 OUTPUT.mp4 (Standard/Legacy) +config_command_help = Das auszuführende FFmpeg-Kommando. Platzhalter: INPUT, OUTPUT +config_preview_label = Vorschau des effektiven FFmpeg-Kommandos +config_preset_priority = Priorität: Preset (außer preset=custom) > Kommando-Feld ffmpeg_execute = Video konvertieren # Video-Trimmer diff --git a/lang/en_gb.lang b/lang/en_gb.lang index 39801b2..874805b 100644 --- a/lang/en_gb.lang +++ b/lang/en_gb.lang @@ -54,6 +54,24 @@ ffmpeg_already_converted = Already converted ffmpeg_config_save = Save settings ffmpeg_config_saved = Settings have been saved! +ffmpeg_config_preset = Preset +config_preset = Preset +config_preset_none = None +config_preset_web = Web (balanced) +config_preset_mobile = Mobile (lower quality, smaller) +config_preset_archive = Archive (high quality, larger) +config_preset_custom = Custom +config_preset_custom_command = Custom command +config_preset_help = Preset selects a predefined FFmpeg command. The command field is prefilled accordingly. If you select 'custom', edit the command manually in the field above. You can always edit the command directly. +config_preset_web_desc = Example: ffmpeg -y -i INPUT -c:v libx264 -preset slow -crf 28 -c:a aac -b:a 128k OUTPUT.mp4 (balanced web) +config_preset_mobile_desc = Example: ffmpeg -y -i INPUT -c:v libx264 -preset veryfast -crf 30 -c:a aac -b:a 96k -vf scale=-2:720 OUTPUT.mp4 (mobile) +config_preset_archive_desc = Example: ffmpeg -y -i INPUT -c:v libx264 -preset veryslow -crf 22 -c:a copy OUTPUT.mp4 (archive) +config_preset_custom_desc = Your own custom ffmpeg command; use placeholders INPUT and OUTPUT. +config_preset_standard = Default (legacy) +config_preset_standard_desc = Example: ffmpeg -y -i INPUT -vcodec h264 OUTPUT.mp4 (standard/default) +config_command_help = The actual FFmpeg command executed. Placeholders: INPUT, OUTPUT +config_preview_label = Effective FFmpeg command preview +config_preset_priority = Priority: preset (unless preset=custom) > command field ffmpeg_execute = Convert video # Video Trimmer diff --git a/lib/Api/Converter.php b/lib/Api/Converter.php index 5efe7eb..33d76e7 100644 --- a/lib/Api/Converter.php +++ b/lib/Api/Converter.php @@ -62,6 +62,21 @@ public function execute() rex_response::cleanOutputBuffers(); rex_response::sendJson($statusData); exit; + + case 'debug': + // Return tail of current log and status for debugging + $conversionId = rex_session('ffmpeg_conversion_id', 'string', ''); + $log = ''; + if (!empty($conversionId)) { + $logFile = rex_addon::get('ffmpeg')->getDataPath('log' . $conversionId . '.txt'); + if (file_exists($logFile)) { + $log = rex_file::get($logFile); + } + } + $debugData = ['conversion_id' => $conversionId, 'log' => $log, 'status' => $this->getConversionStatus(rex_request('video', 'string', ''))]; + rex_response::cleanOutputBuffers(); + rex_response::sendJson($debugData); + exit; case 'check_all': $allStatus = $this->checkAllVideos(); @@ -82,11 +97,18 @@ public function execute() } // Status in Session speichern - private function setConversionStatus($status, $videoName = null) + /** + * Store conversion status in session and persist to file + * + * @param string $status + * @param string|null $videoName + * @return void + */ + private function setConversionStatus(string $status, ?string $videoName = null): void { // In Session speichern (wie bisher) rex_set_session('ffmpeg_conversion_status', $status); - + // Zusätzlich in Datei speichern, wenn Video-Name bekannt if ($videoName) { $statusFile = rex_addon::get('ffmpeg')->getDataPath('status_' . md5($videoName) . '.json'); @@ -101,7 +123,13 @@ private function setConversionStatus($status, $videoName = null) } // Status aus Session lesen oder aus Datei, wenn Video-Name bekannt - private function getConversionStatus($videoName = null) + /** + * Get conversion status from session or status file + * + * @param string|null $videoName + * @return string + */ + private function getConversionStatus(?string $videoName = null): string { // Zuerst Session-Status prüfen (für bestehende Sessions) $sessionStatus = rex_session('ffmpeg_conversion_status', 'string', null); @@ -126,7 +154,13 @@ private function getConversionStatus($videoName = null) } // Öffentliche statische Methode, um den Konvertierungsstatus zu überprüfen - public static function getConversionInfo($videoName = null) + /** + * Get current conversion information by session or status file + * + * @param string|null $videoName + * @return array + */ + public static function getConversionInfo(?string $videoName = null): array { // Session-basierte Infos (wie bisher) $conversionId = rex_session('ffmpeg_conversion_id', 'string', ''); @@ -240,14 +274,25 @@ public static function getConversionInfo($videoName = null) } // Private Methode für interne API-Aufrufe - private function checkStatus() + /** + * Wrapper to use inside the API to check status + * + * @return array + */ + private function checkStatus(): array { $video = rex_request('video', 'string', ''); return self::getConversionInfo($video); } // Neue Methode zum Prüfen aller Videos - private function checkAllVideos() { + /** + * Get all active conversions + * + * @return array + */ + private function checkAllVideos(): array + { // Alle Status-Dateien prüfen $statusFiles = glob(rex_addon::get('ffmpeg')->getDataPath('status_*.json')); $activeConversion = null; @@ -294,7 +339,11 @@ private function checkAllVideos() { return self::getConversionInfo(); } - protected function isProcessRunning($conversionId) + /** + * @param string $conversionId + * @return bool + */ + protected function isProcessRunning(string $conversionId): bool { $log = rex_addon::get('ffmpeg')->getDataPath('log' . $conversionId . '.txt'); if (!file_exists($log)) { @@ -329,7 +378,25 @@ protected function isProcessRunning($conversionId) return false; } - protected function handleStart($video) + /** + * Append content to a log file with exclusive lock + * + * @param string $path + * @param string $content + * @return void + */ + private function appendLog(string $path, string $content): void + { + // Use rex_file::append to keep behaviour consistent with other addons + // rex_file::append handles directory creation and locking + rex_file::append($path, $content); + } + + /** + * @param string $video + * @return array + */ + protected function handleStart(string $video): array { if (empty($video)) { throw new rex_api_exception('No video file selected'); @@ -375,25 +442,39 @@ protected function handleStart($video) // Get command from config $command = trim(rex_addon::get('ffmpeg')->getConfig('command')) . " "; - // Extract output file extension from command - preg_match_all('/OUTPUT.(.*) /m', $command, $matches, PREG_SET_ORDER, 0); - if (count($matches) > 0) { - $file = (trim($matches[0][0])); - $outputFile = $output . '.' . pathinfo($file, PATHINFO_EXTENSION); + // Extract output file extension from command, e.g. "OUTPUT.mp4" or "OUTPUT.mkv" + // Use a robust regex to match OUTPUT. + preg_match('/OUTPUT\.([^\s]+)/', $command, $extMatch); + if (!empty($extMatch[1])) { + $fileExt = trim($extMatch[1]); + $outputFile = $output . '.' . $fileExt; rex_set_session('ffmpeg_input_video_file', $input); rex_set_session('ffmpeg_output_video_file', $outputFile); } - // Replace placeholders in command + // Replace placeholders in command. Replace OUTPUT. before OUTPUT to avoid double extensions $escapedInput = escapeshellarg($input); - $escapedOutput = escapeshellarg($output); - $command = str_ireplace(['INPUT', 'OUTPUT'], [$escapedInput, $escapedOutput], $command); + // Use the fully qualified output file (with extension) if available, otherwise fallback to base path + $escapedOutput = escapeshellarg($outputFile ?? $output); + + // If we found an extension for OUTPUT (e.g., OUTPUT.mp4) prefer replacing OUTPUT. first + if (!empty($fileExt)) { + // Replace the specific OUTPUT. pattern to avoid accidental replacement of the + // 'OUTPUT' token inside the 'OUTPUT.' string which could lead to double extensions + $command = str_ireplace('OUTPUT.' . $fileExt, $escapedOutput, $command); + + // Replace INPUT and any leftover OUTPUT tokens + $command = str_ireplace(['INPUT', 'OUTPUT'], [$escapedInput, $escapedOutput], $command); + } else { + $command = str_ireplace(['INPUT', 'OUTPUT'], [$escapedInput, $escapedOutput], $command); + } // Schreibe Startinformationen ins Log - rex_file::put($log, 'Konvertierung für "' . $video . '" gestartet um ' . date('d.m.Y H:i:s') . "\n", FILE_APPEND); - rex_file::put($log, 'Kommando: ' . $command . "\n\n", FILE_APPEND); + $this->appendLog($log, 'Konvertierung für "' . $video . '" gestartet um ' . date('d.m.Y H:i:s') . "\n"); + $this->appendLog($log, 'Kommando: ' . $command . "\n\n"); // Execute ffmpeg command in background + rex_logger::factory()->log('info', 'FFmpeg start for {video}', ['video' => $video, 'command' => $command]); if (str_starts_with(PHP_OS, 'WIN')) { pclose(popen("start /B " . $command . " 1> $log 2>&1", "r")); // windows } else { @@ -403,7 +484,10 @@ protected function handleStart($video) return ['status' => 'started', 'conversion_id' => $conversionId]; } - protected function handleProgress() + /** + * @return array + */ + protected function handleProgress(): array { $conversionId = rex_session('ffmpeg_conversion_id', 'string', ''); $video = rex_request('video', 'string', ''); @@ -486,6 +570,7 @@ protected function handleProgress() if ($conversionComplete && !$this->isProcessRunning($conversionId)) { // Die Konvertierung ist abgeschlossen, jetzt Import starten $this->setConversionStatus(self::STATUS_IMPORTING, $video); + rex_logger::factory()->log('debug', 'FFmpeg threshold reached for {conversion_id} progress {progress}', ['conversion_id' => $conversionId, 'progress' => $progress]); // Wir zeigen den Fortschritt als 99%, da der Import noch läuft return ['progress' => 99, 'log' => $getContent, 'status' => 'importing']; @@ -497,7 +582,10 @@ protected function handleProgress() // TODO: Im API Converter (ex ffmpeg_api.php), die handleDone-Methode verbessern -protected function handleDone() + /** + * @return array + */ + protected function handleDone(): array { // Versuche Konversionsinformationen aus mehreren Quellen zu finden $conversionId = rex_session('ffmpeg_conversion_id', 'string', ''); @@ -591,10 +679,7 @@ protected function handleDone() // Setze den Status auf Importing, wenn wir an diesem Punkt sind $this->setConversionStatus(self::STATUS_IMPORTING, $video); - // Import required functions if needed - if (!function_exists('rex_mediapool_deleteMedia')) { - require rex_path::addon('mediapool', 'functions/function_rex_mediapool.php'); - } + // With rex_media_service we don't need the legacy mediapool function import // Versuche die Input/Output-Dateien zu ermitteln if (empty($inputFile) || empty($outputFile)) { @@ -604,12 +689,11 @@ protected function handleDone() // Output-Datei aus Konvention ableiten // Zuerst Endung aus Konfiguration ermitteln - $fileExt = 'mp4'; // Standard-Endung + $fileExt = 'mp4'; // default extension $command = trim(rex_addon::get('ffmpeg')->getConfig('command')); - preg_match_all('/OUTPUT.(.*) /m', $command, $matches, PREG_SET_ORDER, 0); - if (count($matches) > 0) { - $file = (trim($matches[0][0])); - $fileExt = pathinfo($file, PATHINFO_EXTENSION); + preg_match('/OUTPUT\.([^\s]+)/', $command, $extMatch); + if (!empty($extMatch[1])) { + $fileExt = trim($extMatch[1]); } $outputFile = rex_path::media('web_' . pathinfo($video, PATHINFO_FILENAME) . '.' . $fileExt); @@ -643,11 +727,11 @@ protected function handleDone() $savings = 0; if ($originalSize > 0 && $convertedSize > 0) { $savings = round(100 - (($convertedSize / $originalSize) * 100)); - rex_file::put($log, sprintf("Dateigröße reduziert um %d%% (von %s auf %s)", + $this->appendLog($log, sprintf("Dateigröße reduziert um %d%% (von %s auf %s)", $savings, rex_formatter::bytes($originalSize), rex_formatter::bytes($convertedSize) - ) . PHP_EOL, FILE_APPEND); + ) . PHP_EOL); } // Prüfen, ob die Datei bereits im Medienpool existiert @@ -660,18 +744,35 @@ protected function handleDone() if (!empty($existingMedia)) { // Datei existiert bereits im Medienpool, Prozess als erfolgreich markieren - rex_file::put($log, sprintf("Destination file %s was already in rex_mediapool", $outputFile) . PHP_EOL, FILE_APPEND); - rex_file::put($log, 'Konvertierung abgeschlossen um ' . date('d.m.Y H:i:s') . PHP_EOL, FILE_APPEND); + $this->appendLog($log, sprintf("Destination file %s was already in rex_mediapool", $outputFile) . PHP_EOL); + $this->appendLog($log, 'Konvertierung abgeschlossen um ' . date('d.m.Y H:i:s') . PHP_EOL); $this->setConversionStatus(self::STATUS_DONE, $video); return ['status' => 'success', 'log' => rex_file::get($log)]; } // Add converted file to media pool - $syncResult = rex_mediapool_syncFile(pathinfo($outputFile, PATHINFO_BASENAME), 0, ''); + // Replace deprecated rex_mediapool_syncFile with rex_media_service::addMedia + $syncData = [ + 'file' => [ + 'name' => pathinfo($outputFile, PATHINFO_BASENAME), + 'path' => $outputFile, + ], + 'category_id' => 0, + 'title' => '', + ]; + try { + $syncResultResult = \rex_media_service::addMedia($syncData, false); + $syncResult = !empty($syncResultResult['ok']); + } catch (\rex_api_exception $e) { + $this->appendLog($log, 'rex_media_service::addMedia failed: ' . $e->getMessage() . PHP_EOL); + rex_logger::factory()->log('warning', 'rex_media_service::addMedia failed: {exception}', ['exception' => $e->getMessage()]); + $syncResult = false; + } rex_unset_session('ffmpeg_output_video_file'); - if ($syncResult) { - rex_file::put($log, sprintf("Destination file %s was successfully added to rex_mediapool", $outputFile) . PHP_EOL, FILE_APPEND); + // If addMedia didn't throw, we proceed with metadata and consider it successful. + $this->appendLog($log, sprintf("Destination file %s was successfully added to rex_mediapool", $outputFile) . PHP_EOL); + rex_logger::factory()->log('info', 'Destination file {file} was successfully added to media pool', ['file' => $outputFile, 'conversion_id' => $conversionId]); // Metadaten vom Original übernehmen, falls vorhanden if (!empty($inputFile)) { @@ -738,48 +839,49 @@ protected function handleDone() // Nur updaten, wenn es Felder zu aktualisieren gibt if (!empty($updatedFields)) { $updateSql->update(); - rex_file::put($log, "Metadaten (" . implode(', ', $updatedFields) . ") vom Original übernommen" . PHP_EOL, FILE_APPEND); + $this->appendLog($log, "Metadaten (" . implode(', ', $updatedFields) . ") vom Original übernommen" . PHP_EOL); } } } // Konvertierung abgeschlossen - rex_file::put($log, 'Konvertierung abgeschlossen um ' . date('d.m.Y H:i:s') . PHP_EOL, FILE_APPEND); + $this->appendLog($log, 'Konvertierung abgeschlossen um ' . date('d.m.Y H:i:s') . PHP_EOL); $this->setConversionStatus(self::STATUS_DONE, $video); // Delete source file if configured (only if import was successful) if (!empty($inputFile) && rex_addon::get('ffmpeg')->getConfig('delete') == 1) { - rex_mediapool_deleteMedia(pathinfo($inputFile, PATHINFO_BASENAME)); + try { + \rex_media_service::deleteMedia(pathinfo($inputFile, PATHINFO_BASENAME)); + } catch (\rex_api_exception $e) { + $this->appendLog($log, 'rex_media_service::deleteMedia failed: ' . $e->getMessage() . PHP_EOL); + } rex_unset_session('ffmpeg_input_video_file'); - rex_file::put($log, sprintf("Source file %s deletion was successful", $inputFile) . PHP_EOL, FILE_APPEND); + $this->appendLog($log, sprintf("Source file %s deletion was successful", $inputFile) . PHP_EOL); } - } else { - rex_file::put($log, sprintf("Destination file %s rex_mediapool registration was not successful", $outputFile) . PHP_EOL, FILE_APPEND); - rex_file::put($log, 'Please execute a mediapool sync by hand' . PHP_EOL, FILE_APPEND); - rex_file::put($log, 'Konvertierung fehlgeschlagen um ' . date('d.m.Y H:i:s') . PHP_EOL, FILE_APPEND); - $this->setConversionStatus(self::STATUS_ERROR, $video); - } - return ['status' => $syncResult ? 'success' : 'error', 'log' => rex_file::get($log)]; + return ['status' => 'success', 'log' => rex_file::get($log)]; } else { - if ($log) { - rex_file::put($log, 'Fehler: Ausgabe-Datei konnte nicht gefunden oder erstellt werden.' . PHP_EOL, FILE_APPEND); + // If phpstan claims the log always exists, silence for this runtime check + // @phpstan-ignore-next-line + if (is_string($log) && file_exists($log)) { + $this->appendLog($log, 'Fehler: Ausgabe-Datei konnte nicht gefunden oder erstellt werden.' . PHP_EOL); if (!empty($inputFile)) { - rex_file::put($log, sprintf("Input-Datei: %s", $inputFile) . PHP_EOL, FILE_APPEND); + $this->appendLog($log, sprintf("Input-Datei: %s", $inputFile) . PHP_EOL); } if (!empty($outputFile)) { - rex_file::put($log, sprintf("Output-Datei: %s (existiert nicht)", $outputFile) . PHP_EOL, FILE_APPEND); + $this->appendLog($log, sprintf("Output-Datei: %s (existiert nicht)", $outputFile) . PHP_EOL); } - rex_file::put($log, 'Bitte führen Sie eine manuelle Synchronisierung im Medienpool durch.' . PHP_EOL, FILE_APPEND); - rex_file::put($log, 'Konvertierung fehlgeschlagen um ' . date('d.m.Y H:i:s') . PHP_EOL, FILE_APPEND); + $this->appendLog($log, 'Bitte führen Sie eine manuelle Synchronisierung im Medienpool durch.' . PHP_EOL); + $this->appendLog($log, 'Konvertierung fehlgeschlagen um ' . date('d.m.Y H:i:s') . PHP_EOL); } $this->setConversionStatus(self::STATUS_ERROR, $video); - return ['status' => 'error', 'log' => $log ? rex_file::get($log) : 'Keine Log-Datei gefunden']; + // @phpstan-ignore-next-line + return ['status' => 'error', 'log' => (is_string($log) && file_exists($log) ? rex_file::get($log) : 'Keine Log-Datei gefunden')]; } } } diff --git a/lib/VideoInfo.php b/lib/VideoInfo.php index 9247cb6..40d1365 100644 --- a/lib/VideoInfo.php +++ b/lib/VideoInfo.php @@ -22,9 +22,9 @@ class VideoInfo * Video-Informationen für eine Datei ermitteln * * @param string $filename Dateiname im Medienpool (z.B. "mein_video.mp4") - * @return array|null Array mit Video-Informationen oder null bei Fehler + * @return array|null Array mit Video-Informationen oder null bei Fehler */ - public static function getInfo($filename) + public static function getInfo($filename): ?array { // Prüfen ob Datei existiert $videoPath = rex_path::media($filename); @@ -34,6 +34,7 @@ public static function getInfo($filename) // Prüfen ob FFmpeg verfügbar ist if (!self::isFFmpegAvailable()) { + \rex_logger::factory()->log('warning', 'FFmpeg not available for VideoInfo::getInfo {file}', ['file' => $filename]); return null; } @@ -44,9 +45,9 @@ public static function getInfo($filename) * Kurze Video-Informationen für Template-Verwendung * * @param string $filename Dateiname im Medienpool - * @return array|null Vereinfachte Video-Daten + * @return array|null Vereinfachte Video-Daten */ - public static function getBasicInfo($filename) + public static function getBasicInfo($filename): ?array { $info = self::getInfo($filename); if (!$info) { @@ -106,9 +107,9 @@ public static function getAspectRatio($filename) * Prüfen ob Video für Web optimiert ist * * @param string $filename Dateiname im Medienpool - * @return array Optimierungsstatus mit Empfehlungen + * @return array Optimierungsstatus mit Empfehlungen */ - public static function getOptimizationStatus($filename) + public static function getOptimizationStatus($filename): array { $info = self::getInfo($filename); if (!$info) { @@ -199,10 +200,10 @@ public static function getThumbnailUrl($filename, $mediaType = 'video_thumb') * Responsive Video-HTML mit Thumbnail generieren * * @param string $filename Dateiname im Medienpool - * @param array $options Optionen (poster, controls, autoplay, etc.) + * @param array $options Optionen (poster, controls, autoplay, etc.) * @return string|null HTML-Code oder null */ - public static function getVideoHtml($filename, $options = []) + public static function getVideoHtml($filename, $options = []): ?string { if (!rex_media::get($filename)) { return null; @@ -257,20 +258,22 @@ private static function isFFmpegAvailable() * * @param string $videoPath Vollständiger Pfad zur Video-Datei * @param string $filename Original-Dateiname - * @return array|null Video-Informationen + * @return array|null Video-Informationen */ - private static function extractVideoInfo($videoPath, $filename) + private static function extractVideoInfo($videoPath, $filename): ?array { // FFprobe für detaillierte Video-Informationen verwenden $cmd = 'ffprobe -v quiet -print_format json -show_format -show_streams ' . escapeshellarg($videoPath); $output = shell_exec($cmd); if (!$output) { + \rex_logger::factory()->log('warning', 'ffprobe returned no output for {file}', ['file' => $filename, 'cmd' => $cmd]); return null; } $data = json_decode($output, true); if (!$data) { + \rex_logger::factory()->log('warning', 'Could not json decode ffprobe output for {file}', ['file' => $filename]); return null; } @@ -311,11 +314,12 @@ private static function extractVideoInfo($videoPath, $filename) /** * Dauer formatieren (HH:MM:SS oder MM:SS) */ - private static function formatDuration($seconds) + private static function formatDuration(float $seconds): string { - $hours = floor($seconds / 3600); - $minutes = floor(($seconds % 3600) / 60); - $seconds = $seconds % 60; + // Use fmod to avoid implicit float->int conversion warnings with the % operator + $hours = (int) floor($seconds / 3600); + $minutes = (int) floor(fmod($seconds, 3600) / 60); + $seconds = (int) floor(fmod($seconds, 60)); if ($hours > 0) { return sprintf('%02d:%02d:%02d', $hours, $minutes, $seconds); @@ -327,7 +331,7 @@ private static function formatDuration($seconds) /** * Seitenverhältnis berechnen */ - private static function calculateAspectRatio($width, $height) + private static function calculateAspectRatio(int $width, int $height): string { if (!$width || !$height) return 'Unbekannt'; @@ -362,21 +366,23 @@ private static function calculateAspectRatio($width, $height) /** * Framerate berechnen */ - private static function calculateFramerate($rFrameRate) + private static function calculateFramerate(string $rFrameRate): float { if (strpos($rFrameRate, '/') !== false) { list($num, $den) = explode('/', $rFrameRate); + $num = (int) $num; + $den = (int) $den; if ($den > 0) { return round($num / $den, 2); } } - return 0; + return 0.0; } /** * Bitrate formatieren */ - private static function formatBitrate($bitrate) + private static function formatBitrate(int $bitrate): string { if ($bitrate >= 1000000) { return round($bitrate / 1000000, 1) . ' Mbps'; @@ -388,8 +394,10 @@ private static function formatBitrate($bitrate) /** * Optimierungs-Score berechnen (0-100) + * + * @param array $info Video-Informationen */ - private static function calculateOptimizationScore($info) + private static function calculateOptimizationScore(array $info): int { $score = 100; diff --git a/package.yml b/package.yml index 1e3747a..ab84c31 100644 --- a/package.yml +++ b/package.yml @@ -1,5 +1,5 @@ package: ffmpeg -version: '3.3.0' +version: '4.0.0' author: Friends Of REDAXO supportpage: https://github.com/FriendsOfREDAXO/ @@ -32,7 +32,7 @@ pages: perm: ffmpeg[config] icon: rex-icon fa-wrench default_config: - command: 'ffmpeg -y -i INPUT -vcodec h264 OUTPUT.mp4' + command: 'ffmpeg -y -i INPUT -vcodec h264 OUTPUT.mp4' requires: redaxo: '^5.18.1' diff --git a/pages/mediapool.ffmpeg.config.php b/pages/mediapool.ffmpeg.config.php index 90d5041..5c706fd 100644 --- a/pages/mediapool.ffmpeg.config.php +++ b/pages/mediapool.ffmpeg.config.php @@ -4,15 +4,39 @@ $buttons = ''; $csrfToken = rex_csrf_token::factory('ffmpeg'); +/** @var rex_addon $this */ + +// Preset mapping for UI (available both when saving and when rendering) +$presets = [ + // Standard legacy setting (keeps the old default command and extension) + 'standard' => 'ffmpeg -y -i INPUT -vcodec h264 OUTPUT.mp4', + 'web' => 'ffmpeg -y -i INPUT -c:v libx264 -preset slow -crf 28 -c:a aac -b:a 128k OUTPUT.mp4', + 'mobile' => 'ffmpeg -y -i INPUT -c:v libx264 -preset veryfast -crf 30 -c:a aac -b:a 96k -vf scale=-2:720 OUTPUT.mp4', + 'archive' => 'ffmpeg -y -i INPUT -c:v libx264 -preset veryslow -crf 22 -c:a copy OUTPUT.mp4', + // 'custom' allows keeping the current 'command' field for manual editing + 'custom' => '', +]; // Einstellungen speichern if (rex_post('formsubmit', 'string') == '1' && !$csrfToken->isValid()) { echo rex_view::error(rex_i18n::msg('csrf_token_invalid')); } elseif (rex_post('formsubmit', 'string') == '1') { - $this->setConfig(rex_post('config', [ + $config = rex_post('config', [ ['command', 'string'], ['delete', 'string'], - ])); + ['preset', 'string'], + ]); + + // Keep the same preset mapping as the top-level one — always include the ffmpeg prefix + + // If a preset was selected (and not 'custom' or 'none'), override command + if (!empty($config['preset']) && $config['preset'] !== 'custom' && $config['preset'] !== 'none' && isset($presets[$config['preset']])) { + $config['command'] = $presets[$config['preset']]; + } + + // The 'custom' preset leaves the 'command' field as-is (manually edited by the user) + + $this->setConfig($config); echo rex_view::success($this->i18n('config_saved')); } @@ -29,6 +53,78 @@ $fragment = new rex_fragment(); $fragment->setVar('elements', $formElements, false); $content .= $fragment->parse('core/form/container.php'); +$content .= '

' . $this->i18n('config_command_help') . '

'; + +$content .= '' . PHP_EOL; + +// Preset selection +$formElements = []; +$n = []; +$n['label'] = ''; +$select = new rex_select(); +$select->setId('ffmpeg-config-preset'); +$select->setAttribute('class', 'form-control'); +$select->setName('config[preset]'); +$select->addOption($this->i18n('config_preset_none'), 'none'); +$select->addOption($this->i18n('config_preset_standard'), 'standard'); +$select->addOption($this->i18n('config_preset_web'), 'web'); +$select->addOption($this->i18n('config_preset_mobile'), 'mobile'); +$select->addOption($this->i18n('config_preset_archive'), 'archive'); +// Allow the user to select "Custom" — do not overwrite the command field +$select->addOption($this->i18n('config_preset_custom'), 'custom'); +$select->setSelected($this->getConfig('preset', 'none')); +$n['field'] = $select->get(); +$formElements[] = $n; +$fragment = new rex_fragment(); +$fragment->setVar('elements', $formElements, false); +$content .= $fragment->parse('core/form/container.php'); + +$content .= '

' . $this->i18n('config_preset_help') . '

'; + +// Preset descriptions +$content .= '
    '; +$content .= '
  • ' . $this->i18n('config_preset_web') . ': ' . $this->i18n('config_preset_web_desc') . '
  • '; +$content .= '
  • ' . $this->i18n('config_preset_mobile') . ': ' . $this->i18n('config_preset_mobile_desc') . '
  • '; +$content .= '
  • ' . $this->i18n('config_preset_archive') . ': ' . $this->i18n('config_preset_archive_desc') . '
  • '; +$content .= '
  • ' . $this->i18n('config_preset_standard') . ': ' . $this->i18n('config_preset_standard_desc') . '
  • '; +$content .= '
  • ' . $this->i18n('config_preset_custom') . ': ' . $this->i18n('config_preset_custom_desc') . '
  • '; +$content .= '
' . PHP_EOL; + +// Hinweis: custom command kann direkt im 'command' Feld eingegeben werden + +// Preview and explanation +$preview = '
' . htmlspecialchars((string) $this->getConfig('command')) . '
'; +$content .= $preview; + +$content .= '

' . $this->i18n('config_preset_priority') . '

'; // Checkbox $formElements = []; @@ -56,7 +152,7 @@ $select->setSelected($this->getConfig('select')); $n['field'] = $select->get(); $formElements[] = $n; - +n['field'] = ''; $fragment = new rex_fragment(); $fragment->setVar('elements', $formElements, false); $content .= $fragment->parse('core/form/container.php'); diff --git a/pages/mediapool.ffmpeg.main.php b/pages/mediapool.ffmpeg.main.php index dd6a1ea..585999b 100644 --- a/pages/mediapool.ffmpeg.main.php +++ b/pages/mediapool.ffmpeg.main.php @@ -31,11 +31,13 @@ $allVideos = []; $optimizedVideosMapping = []; -// Zuerst alle optimierten Videos sammeln +// Zuerst alle optimierten Videos sammeln (Mapping per Basename ohne Extension) foreach ($allMediaFiles as $media) { if (strpos($media['filename'], 'web_') === 0) { - $originalName = substr($media['filename'], 4); - $optimizedVideosMapping[$originalName] = $media; + // Verwende den Basename (ohne Dateiendung) als Schlüssel, damit verschiedene + // Extensions (z.B. mov -> mp4) erkannt werden + $originalBase = pathinfo(substr($media['filename'], 4), PATHINFO_FILENAME); + $optimizedVideosMapping[$originalBase] = $media; } } @@ -46,9 +48,11 @@ // Nur Originalvideos in die Liste aufnehmen (keine "web_"-Versionen) if (!$isOptimized) { $originalName = $media['filename']; + $originalBase = pathinfo($originalName, PATHINFO_FILENAME); $isProcessing = $conversionActive && isset($conversionInfo['video']) && $conversionInfo['video'] === $originalName; - $isAlreadyConverted = isset($optimizedVideosMapping[$originalName]); - $optimizedData = $isAlreadyConverted ? $optimizedVideosMapping[$originalName] : null; + // Prüfe anhand des Basename (ohne Extension), dann werden .mov -> .mp4 Varianten erkannt + $isAlreadyConverted = isset($optimizedVideosMapping[$originalBase]); + $optimizedData = $isAlreadyConverted ? $optimizedVideosMapping[$originalBase] : null; // Kompressionsrate berechnen, wenn konvertierte Version existiert $compressionRate = 0;