From 265ad44e53fb5d50c20b35e84f4fc2e98daae61e Mon Sep 17 00:00:00 2001 From: Aleksey Filippov Date: Tue, 24 Feb 2026 17:09:59 +0300 Subject: [PATCH] =?utf8?q?feat(ERP-236):=20add=20console=20command=20for?= =?utf8?q?=20batch=20MOV=E2=86=92MP4=20conversion?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit php yii convert-video/status — show stats php yii convert-video/run — convert all php yii convert-video/run --limit=50 --dry-run — preview Co-Authored-By: Claude Opus 4.6 --- erp24/commands/ConvertVideoController.php | 200 ++++++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 erp24/commands/ConvertVideoController.php diff --git a/erp24/commands/ConvertVideoController.php b/erp24/commands/ConvertVideoController.php new file mode 100644 index 00000000..2724ce07 --- /dev/null +++ b/erp24/commands/ConvertVideoController.php @@ -0,0 +1,200 @@ +> /var/log/convert-video.log 2>&1 + */ +class ConvertVideoController extends Controller +{ + /** @var int Максимум файлов за один запуск (0 = без лимита) */ + public int $limit = 0; + + /** @var bool Только показать, не конвертировать */ + public bool $dryRun = false; + + /** @var int Пауза между файлами в секундах (снижает нагрузку) */ + public int $sleep = 2; + + public function options($actionID): array + { + return array_merge(parent::options($actionID), [ + 'limit', + 'dryRun', + 'sleep', + ]); + } + + public function optionAliases(): array + { + return [ + 'l' => 'limit', + 'n' => 'dryRun', + 's' => 'sleep', + ]; + } + + /** + * Показать статистику MOV/MP4 файлов в БД. + */ + public function actionStatus(): int + { + $movCount = Files::find()->where(['like', 'url', '.mov'])->count(); + $mp4Count = Files::find()->where(['like', 'url', '.mp4'])->count(); + $aviCount = Files::find()->where(['like', 'url', '.avi'])->count(); + + $this->stdout("=== Статистика видеофайлов ===\n"); + $this->stdout("MOV: {$movCount}\n"); + $this->stdout("MP4: {$mp4Count}\n"); + $this->stdout("AVI: {$aviCount}\n"); + $this->stdout("Всего к конвертации: " . ($movCount + $aviCount) . "\n"); + + return ExitCode::OK; + } + + /** + * Конвертация MOV/AVI → MP4 с throttling. + */ + public function actionRun(): int + { + // Проверка FFmpeg + exec('which ffmpeg 2>/dev/null', $output, $code); + if ($code !== 0) { + $this->stderr("FFmpeg не установлен!\n"); + return ExitCode::UNSPECIFIED_ERROR; + } + + $query = Files::find() + ->where(['like', 'url', '.mov']) + ->orWhere(['like', 'url', '.avi']) + ->orderBy(['id' => SORT_ASC]); + + if ($this->limit > 0) { + $query->limit($this->limit); + } + + /** @var Files[] $files */ + $files = $query->all(); + $total = count($files); + + if ($total === 0) { + $this->stdout("Нет файлов для конвертации.\n"); + return ExitCode::OK; + } + + $this->stdout("Найдено файлов: {$total}\n"); + if ($this->dryRun) { + $this->stdout("Режим dry-run: конвертация не будет выполнена\n\n"); + } + + $converted = 0; + $skipped = 0; + $errors = 0; + $basePath = Yii::getAlias('@app') . '/web'; + + foreach ($files as $i => $file) { + $num = $i + 1; + $sourcePath = $basePath . $file->url; + $mp4Url = preg_replace('/\.(mov|avi)$/i', '.mp4', $file->url); + $mp4Path = $basePath . $mp4Url; + + $this->stdout("[{$num}/{$total}] #{$file->id} {$file->url}"); + + // Файл уже не существует + if (!file_exists($sourcePath)) { + $this->stdout(" — ПРОПУЩЕН (файл не найден)\n"); + $skipped++; + continue; + } + + // MP4 уже есть + if (file_exists($mp4Path)) { + $this->stdout(" — ПРОПУЩЕН (MP4 уже существует)\n"); + // Обновить URL в БД если ещё не обновлён + if ($file->url !== $mp4Url) { + if (!$this->dryRun) { + $file->url = $mp4Url; + $file->save(false); + } + $this->stdout(" → URL обновлён в БД\n"); + } + $skipped++; + continue; + } + + $size = filesize($sourcePath); + $sizeMb = round($size / 1024 / 1024, 1); + $this->stdout(" ({$sizeMb} MB)"); + + if ($this->dryRun) { + $this->stdout(" — будет конвертирован\n"); + $converted++; + continue; + } + + // Конвертация + $cmd = sprintf( + 'nice -n 19 timeout 600 ffmpeg -y -i %s -c:v libx264 -preset fast -crf 23 -c:a aac -movflags +faststart %s 2>&1', + escapeshellarg($sourcePath), + escapeshellarg($mp4Path) + ); + + $startTime = microtime(true); + exec($cmd, $cmdOutput, $returnCode); + $elapsed = round(microtime(true) - $startTime, 1); + + if ($returnCode !== 0 || !file_exists($mp4Path)) { + $error = implode("\n", array_slice($cmdOutput, -5)); + $this->stderr(" — ОШИБКА ({$elapsed}s, code={$returnCode})\n"); + Yii::error("ConvertVideo: ошибка #{$file->id}: {$error}", 'video'); + if (file_exists($mp4Path)) { + @unlink($mp4Path); + } + $errors++; + } else { + // Обновить URL в БД + $file->url = $mp4Url; + if ($file->save(false)) { + // Удалить оригинал + @unlink($sourcePath); + $mp4Size = round(filesize($mp4Path) / 1024 / 1024, 1); + $this->stdout(" — OK ({$elapsed}s, {$sizeMb}→{$mp4Size} MB)\n"); + $converted++; + } else { + $this->stderr(" — ОШИБКА сохранения БД\n"); + $errors++; + } + } + + $cmdOutput = []; + + // Пауза между файлами + if ($this->sleep > 0 && $i < $total - 1) { + sleep($this->sleep); + } + } + + $this->stdout("\n=== Итоги ===\n"); + $this->stdout("Конвертировано: {$converted}\n"); + $this->stdout("Пропущено: {$skipped}\n"); + $this->stdout("Ошибок: {$errors}\n"); + + return $errors > 0 ? ExitCode::UNSPECIFIED_ERROR : ExitCode::OK; + } +} -- 2.39.5