use yii_app\records\Files;
/**
- * Ð\9cаÑ\81Ñ\81оваÑ\8f конвеÑ\80Ñ\82аÑ\86иÑ\8f MOV â\86\92 MP4 длÑ\8f Ñ\81Ñ\83Ñ\89еÑ\81Ñ\82вÑ\83Ñ\8eÑ\89иÑ\85 Ñ\84айлов.
+ * Ð\9aонвеÑ\80Ñ\82аÑ\86иÑ\8f MOV â\86\92 MP4 длÑ\8f Ñ\84айлов, пÑ\80опÑ\83Ñ\89еннÑ\8bÑ\85 оÑ\87еÑ\80едÑ\8cÑ\8e.
*
- * По умолчанию конвертирует только файлы младше 1 месяца (старые удаляются через write-offs-attachments).
+ * Новые файлы конвертируются автоматически через ConvertVideoToMp4Job при загрузке.
+ * Эта команда — страховка для файлов, которые очередь не обработала.
+ *
+ * По умолчанию берёт файлы за последние 2 дня (--days=2), но не новее 2 часов (--min-age=2),
+ * чтобы не мешать очереди, которая ещё не успела обработать только что загруженные файлы.
*
* Запуск:
- * php yii convert-video/run — конвертация MOV младше 1 месяца
- * php yii convert-video/run --days=7 — только за последние 7 дней
- * php yii convert-video/run --days=0 — все файлы без фильтра по дате
- * php yii convert-video/run --limit=50 â\80\94 конвеÑ\80Ñ\82аÑ\86иÑ\8f пеÑ\80вÑ\8bÑ\85 50
+ * php yii convert-video/run — файлы от 2 ч до 2 дней назад (для cron)
+ * php yii convert-video/run --days=0 — все файлы (для разовой массовой конвертации)
+ * php yii convert-video/run --min-age=0 — включая только что загруженные
+ * php yii convert-video/run --limit=50 â\80\94 не более 50 Ñ\84айлов за запÑ\83Ñ\81к
* php yii convert-video/run --dry-run — только показать что будет конвертировано
* php yii convert-video/status — статистика MOV/MP4
*
- * Рекомендуется запускать ночью через cron с nice:
- * 0 2 * * * nice -n 19 php /var/www/erp24/yii convert-video/run --limit=200 >> /var/log/convert-video.log 2>&1
+ * Cron (каждый час — страховка для очереди):
+ * 0 * * * * sudo -u www-data php /var/www/erp24/yii convert-video/run --limit=50 >> /var/log/convert-video.log 2>&1
*/
class ConvertVideoController extends Controller
{
/** @var int Пауза между файлами в секундах (снижает нагрузку) */
public int $sleep = 2;
- /** @var int Конвертировать файлы за последние N дней (0 = все, по умолчанию 10) */
- public int $days = 10;
+ /** @var int Конвертировать файлы за последние N дней (0 = все, по умолчанию 2) */
+ public int $days = 2;
+
+ /** @var int Пропускать файлы моложе N часов (очередь ещё не успела, по умолчанию 2) */
+ public int $minAge = 2;
public function options($actionID): array
{
'dryRun',
'sleep',
'days',
+ 'minAge',
]);
}
{
$borderDate = $this->days > 0 ? date('Y-m-d H:i:s', strtotime("-{$this->days} days")) : null;
- $movQuery = Files::find()->where(['like', 'url', '.mov']);
- $mp4Query = Files::find()->where(['like', 'url', '.mp4']);
- $aviQuery = Files::find()->where(['like', 'url', '.avi']);
+ $entity = \yii_app\records\WriteOffsProductsErp::WRITE_OFFS_VIDEO;
+
+ $movQuery = Files::find()->where(['entity' => $entity])->andWhere(['or', ['like', 'url', '.mov'], ['like', 'url', '.avi']]);
+ $mp4Query = Files::find()->where(['entity' => $entity])->andWhere(['like', 'url', '.mp4']);
if ($borderDate) {
$movQuery->andWhere(['>=', 'created_at', $borderDate]);
$mp4Query->andWhere(['>=', 'created_at', $borderDate]);
- $aviQuery->andWhere(['>=', 'created_at', $borderDate]);
}
$movCount = $movQuery->count();
$mp4Count = $mp4Query->count();
- $aviCount = $aviQuery->count();
- $this->stdout("=== Статистика видеофайлов ===\n");
+ $this->stdout("=== Статистика видео списаний ===\n");
+ $this->stdout("entity: {$entity}\n");
if ($borderDate) {
$this->stdout("Фильтр: за последние {$this->days} дней (с {$borderDate})\n");
} else {
$this->stdout("Фильтр: все файлы\n");
}
- $this->stdout("MOV: {$movCount}\n");
- $this->stdout("MP4: {$mp4Count}\n");
- $this->stdout("AVI: {$aviCount}\n");
- $this->stdout("Всего к конвертации: " . ($movCount + $aviCount) . "\n");
+ $this->stdout("MOV (к конвертации): {$movCount}\n");
+ $this->stdout("MP4 (уже готово): {$mp4Count}\n");
return ExitCode::OK;
}
}
$borderDate = $this->days > 0 ? date('Y-m-d H:i:s', strtotime("-{$this->days} days")) : null;
+ // Пропускаем файлы моложе minAge часов — очередь ещё может их обрабатывать
+ $maxDate = $this->minAge > 0 ? date('Y-m-d H:i:s', strtotime("-{$this->minAge} hours")) : null;
$query = Files::find()
+ ->andWhere(['entity' => \yii_app\records\WriteOffsProductsErp::WRITE_OFFS_VIDEO])
->andWhere(['or', ['like', 'url', '.mov'], ['like', 'url', '.avi']])
->orderBy(['id' => SORT_DESC]);
if ($borderDate) {
$query->andWhere(['>=', 'created_at', $borderDate]);
+ }
+ if ($maxDate) {
+ $query->andWhere(['<=', 'created_at', $maxDate]);
+ }
+
+ if ($borderDate && $maxDate) {
+ $this->stdout("Фильтр: от {$this->minAge}ч до {$this->days}д назад ({$maxDate} — {$borderDate})\n");
+ } elseif ($borderDate) {
$this->stdout("Фильтр: за последние {$this->days} дней (с {$borderDate})\n");
+ } elseif ($maxDate) {
+ $this->stdout("Фильтр: все файлы старше {$this->minAge}ч (до {$maxDate})\n");
} else {
$this->stdout("Фильтр: все файлы\n");
}
continue;
}
- // Конвертация (вывод в лог-файл, чтобы не переполнять буфер exec)
- $logFile = Yii::getAlias('@runtime') . '/ffmpeg_last.log';
+ // Конвертация через proc_open (избегаем зависание PHP на буфере)
$cmd = sprintf(
- 'nice -n 19 timeout 1800 ffmpeg -y -i %s -c:v libx264 -preset fast -crf 23 -c:a aac -movflags +faststart %s >%s 2>&1',
+ 'nice -n 19 timeout 1800 ffmpeg -y -i %s -c:v libx264 -preset fast -crf 23 -c:a aac -movflags +faststart %s',
escapeshellarg($sourcePath),
- escapeshellarg($mp4Path),
- escapeshellarg($logFile)
+ escapeshellarg($mp4Path)
);
$startTime = microtime(true);
- $returnCode = 0;
- system($cmd, $returnCode);
+ $proc = proc_open($cmd, [
+ 0 => ['file', '/dev/null', 'r'],
+ 1 => ['file', '/dev/null', 'w'],
+ 2 => ['file', '/dev/null', 'w'],
+ ], $pipes);
+ $returnCode = proc_close($proc);
$elapsed = round(microtime(true) - $startTime, 1);
if ($returnCode !== 0 || !file_exists($mp4Path)) {
- $error = is_file($logFile) ? implode("\n", array_slice(file($logFile, FILE_IGNORE_NEW_LINES), -5)) : '';
$this->stderr(" — ОШИБКА ({$elapsed}s, code={$returnCode})\n");
- Yii::error("ConvertVideo: ошибка #{$file->id}: {$error}", 'video');
+ Yii::error("ConvertVideo: ошибка #{$file->id}, code={$returnCode}", 'video');
if (file_exists($mp4Path)) {
@unlink($mp4Path);
}
--- /dev/null
+<!DOCTYPE html>
+<html lang="ru">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>ConvertVideoController — логика конвертации MOV → MP4</title>
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://fonts.googleapis.com/css2?family=Syne:wght@400;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
+<style>
+ /* ============ THEME — Nord-inspired dark ============ */
+ :root {
+ --bg: #1a1f2e;
+ --surface: #222838;
+ --surface2: #2a3045;
+ --border: rgba(255,255,255,0.07);
+ --border-bright:rgba(255,255,255,0.14);
+ --text: #d8def0;
+ --text-dim: #7a86a8;
+
+ --primary: #88c0d0; /* Nord frost — process steps */
+ --primary-dim: rgba(136,192,208,0.10);
+ --green: #a3be8c; /* Nord aurora green — success */
+ --green-dim: rgba(163,190,140,0.12);
+ --yellow: #ebcb8b; /* Nord aurora yellow — decision / skip */
+ --yellow-dim: rgba(235,203,139,0.12);
+ --red: #bf616a; /* Nord aurora red — error */
+ --red-dim: rgba(191,97,106,0.12);
+ --purple: #b48ead; /* Nord aurora purple — queue / async */
+ --purple-dim: rgba(180,142,173,0.12);
+ --orange: #d08770; /* Nord aurora orange — ffmpeg / CLI */
+ --orange-dim: rgba(208,135,112,0.12);
+
+ --font-body: 'Syne', system-ui, sans-serif;
+ --font-mono: 'JetBrains Mono', 'SF Mono', Consolas, monospace;
+ }
+
+ @media (prefers-color-scheme: light) {
+ :root {
+ --bg: #f0f4ff;
+ --surface: #ffffff;
+ --surface2: #e8edf8;
+ --border: rgba(0,0,0,0.07);
+ --border-bright:rgba(0,0,0,0.14);
+ --text: #1a1f2e;
+ --text-dim: #5a6480;
+ --primary: #2e6a8a;
+ --primary-dim: rgba(46,106,138,0.08);
+ --green: #3a7040;
+ --green-dim: rgba(58,112,64,0.08);
+ --yellow: #8a6820;
+ --yellow-dim: rgba(138,104,32,0.08);
+ --red: #8a2020;
+ --red-dim: rgba(138,32,32,0.08);
+ --purple: #6a3a8a;
+ --purple-dim: rgba(106,58,138,0.08);
+ --orange: #8a4020;
+ --orange-dim: rgba(138,64,32,0.08);
+ }
+ }
+
+ * { margin: 0; padding: 0; box-sizing: border-box; }
+
+ body {
+ background-color: var(--bg);
+ background-image:
+ linear-gradient(rgba(255,255,255,0.015) 1px, transparent 1px),
+ linear-gradient(90deg, rgba(255,255,255,0.015) 1px, transparent 1px);
+ background-size: 32px 32px;
+ color: var(--text);
+ font-family: var(--font-body);
+ padding: 48px 40px 80px;
+ min-height: 100vh;
+ }
+
+ @keyframes fadeUp {
+ from { opacity: 0; transform: translateY(14px); }
+ to { opacity: 1; transform: translateY(0); }
+ }
+ @keyframes fadeScale {
+ from { opacity: 0; transform: scale(0.92); }
+ to { opacity: 1; transform: scale(1); }
+ }
+ .animate { animation: fadeUp 0.45s ease-out both; animation-delay: calc(var(--i, 0) * 0.07s); }
+ .animate-scale { animation: fadeScale 0.4s ease-out both; animation-delay: calc(var(--i, 0) * 0.07s); }
+ @media (prefers-reduced-motion: reduce) {
+ *, *::before, *::after { animation-duration: 0.01ms !important; animation-delay: 0ms !important; }
+ }
+
+ .container { max-width: 1100px; margin: 0 auto; }
+
+ /* ---- Header ---- */
+ .header { margin-bottom: 40px; }
+ h1 {
+ font-size: 42px; font-weight: 800; letter-spacing: -1.5px;
+ line-height: 1.1; margin-bottom: 8px;
+ }
+ h1 span { color: var(--primary); }
+ .subtitle {
+ font-family: var(--font-mono); font-size: 11px;
+ color: var(--text-dim); letter-spacing: 0.05em;
+ }
+ .badge {
+ display: inline-block;
+ background: var(--primary-dim); color: var(--primary);
+ border: 1px solid rgba(136,192,208,0.25);
+ font-family: var(--font-mono); font-size: 10px;
+ padding: 2px 8px; border-radius: 4px;
+ margin-left: 10px; vertical-align: middle;
+ font-weight: 500;
+ }
+
+ /* ---- Section headers ---- */
+ .section-label {
+ font-family: var(--font-mono); font-size: 10px; font-weight: 500;
+ letter-spacing: 0.12em; text-transform: uppercase;
+ color: var(--text-dim); margin-bottom: 14px;
+ display: flex; align-items: center; gap: 8px;
+ }
+ .section-label::after {
+ content: ''; flex: 1; height: 1px; background: var(--border);
+ }
+
+ /* ---- System overview ---- */
+ .system-flow {
+ display: flex; align-items: stretch; gap: 0;
+ margin-bottom: 40px; overflow-x: auto;
+ }
+ .sys-node {
+ flex: 1; min-width: 140px;
+ background: var(--surface); border: 1px solid var(--border);
+ border-radius: 10px; padding: 16px 14px;
+ position: relative;
+ }
+ .sys-node + .sys-node { margin-left: 0; }
+ .sys-arrow {
+ display: flex; align-items: center; padding: 0 6px;
+ color: var(--text-dim); font-size: 18px; flex-shrink: 0;
+ }
+ .sys-node-icon { font-size: 22px; margin-bottom: 6px; }
+ .sys-node-title {
+ font-size: 12px; font-weight: 700; margin-bottom: 4px;
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+ }
+ .sys-node-desc {
+ font-family: var(--font-mono); font-size: 10px;
+ color: var(--text-dim); line-height: 1.5;
+ }
+ .sys-node.primary { border-color: rgba(136,192,208,0.35); background: var(--primary-dim); }
+ .sys-node.purple { border-color: rgba(180,142,173,0.35); background: var(--purple-dim); }
+ .sys-node.orange { border-color: rgba(208,135,112,0.35); background: var(--orange-dim); }
+ .sys-node.green { border-color: rgba(163,190,140,0.35); background: var(--green-dim); }
+ .sys-node.red { border-color: rgba(191,97,106,0.35); background: var(--red-dim); }
+ .sys-node-label {
+ position: absolute; top: -9px; left: 12px;
+ font-family: var(--font-mono); font-size: 9px; font-weight: 600;
+ background: var(--bg); padding: 0 5px; color: var(--text-dim);
+ text-transform: uppercase; letter-spacing: 0.08em;
+ }
+
+ /* ---- Time window ---- */
+ .time-window {
+ background: var(--surface); border: 1px solid var(--border);
+ border-radius: 12px; padding: 22px 24px; margin-bottom: 40px;
+ }
+ .tw-title {
+ font-size: 13px; font-weight: 700; margin-bottom: 16px;
+ display: flex; align-items: center; gap: 8px;
+ }
+ .tw-timeline {
+ position: relative; height: 60px; margin: 0 0 12px;
+ }
+ .tw-line {
+ position: absolute; top: 28px; left: 0; right: 0;
+ height: 3px; background: var(--border-bright); border-radius: 2px;
+ }
+ .tw-zone {
+ position: absolute; top: 18px; height: 22px; border-radius: 3px;
+ display: flex; align-items: center; justify-content: center;
+ font-family: var(--font-mono); font-size: 10px; font-weight: 600;
+ }
+ .tw-zone.too-old { left: 0; width: 18%; background: var(--red-dim); color: var(--red); border: 1px solid rgba(191,97,106,0.3); }
+ .tw-zone.active { left: 18%; width: 52%; background: var(--green-dim); color: var(--green); border: 1px solid rgba(163,190,140,0.3); }
+ .tw-zone.queue { left: 70%; width: 22%; background: var(--yellow-dim);color: var(--yellow); border: 1px solid rgba(235,203,139,0.3); }
+ .tw-zone.now { right: 0; width: 8%; background: var(--primary-dim); color: var(--primary);border: 1px solid rgba(136,192,208,0.3); }
+ .tw-marker {
+ position: absolute; top: 8px;
+ font-family: var(--font-mono); font-size: 9px;
+ color: var(--text-dim); transform: translateX(-50%);
+ text-align: center; white-space: nowrap;
+ }
+ .tw-marker::before {
+ content: '▼'; display: block; font-size: 7px;
+ margin-bottom: 2px; color: var(--border-bright);
+ }
+ .tw-legend {
+ display: flex; gap: 16px; flex-wrap: wrap; margin-top: 4px;
+ }
+ .tw-legend-item {
+ display: flex; align-items: center; gap: 5px;
+ font-family: var(--font-mono); font-size: 10px; color: var(--text-dim);
+ }
+ .tw-dot { width: 8px; height: 8px; border-radius: 2px; }
+
+ /* ---- Mermaid wrap ---- */
+ .mermaid-wrap {
+ position: relative;
+ background: var(--surface); border: 1px solid var(--border);
+ border-radius: 12px; padding: 32px 24px;
+ overflow: auto; margin-bottom: 32px;
+ min-height: 200px;
+ }
+ .mermaid-wrap .mermaid {
+ display: flex; justify-content: center;
+ transition: transform 0.15s ease;
+ transform-origin: top center;
+ }
+ .zoom-controls {
+ position: absolute; top: 8px; right: 8px;
+ display: flex; gap: 2px; z-index: 10;
+ background: var(--surface2); border: 1px solid var(--border);
+ border-radius: 6px; padding: 2px;
+ }
+ .zoom-controls button {
+ width: 28px; height: 28px; border: none;
+ background: transparent; color: var(--text-dim);
+ font-family: var(--font-mono); font-size: 14px;
+ cursor: pointer; border-radius: 4px;
+ display: flex; align-items: center; justify-content: center;
+ transition: background 0.15s, color 0.15s;
+ }
+ .zoom-controls button:hover { background: var(--border); color: var(--text); }
+ .mermaid-wrap.is-zoomed { cursor: grab; }
+ .mermaid-wrap.is-panning { cursor: grabbing; user-select: none; }
+ .mermaid-wrap::-webkit-scrollbar { width: 5px; height: 5px; }
+ .mermaid-wrap::-webkit-scrollbar-track { background: transparent; }
+ .mermaid-wrap::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
+
+ /* ---- Params table ---- */
+ .params-grid {
+ display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+ gap: 10px; margin-bottom: 40px;
+ }
+ .param-card {
+ background: var(--surface); border: 1px solid var(--border);
+ border-radius: 10px; padding: 14px 16px;
+ transition: border-color 0.2s;
+ }
+ .param-card:hover { border-color: var(--border-bright); }
+ .param-name {
+ font-family: var(--font-mono); font-size: 12px; font-weight: 600;
+ color: var(--primary); margin-bottom: 4px;
+ }
+ .param-default {
+ font-family: var(--font-mono); font-size: 10px;
+ color: var(--yellow); margin-bottom: 6px;
+ }
+ .param-desc { font-size: 11px; color: var(--text-dim); line-height: 1.5; }
+
+ /* ---- Legend ---- */
+ .legend {
+ display: flex; gap: 16px; flex-wrap: wrap;
+ margin-top: 12px;
+ }
+ .legend-item {
+ display: flex; align-items: center; gap: 6px;
+ font-family: var(--font-mono); font-size: 10px; color: var(--text-dim);
+ }
+ .legend-swatch { width: 10px; height: 10px; border-radius: 3px; }
+
+ /* ---- Mermaid SVG overrides ---- */
+ .mermaid .nodeLabel { font-family: var(--font-mono) !important; font-size: 13px !important; }
+ .mermaid .edgeLabel { font-family: var(--font-mono) !important; font-size: 11px !important; }
+ .mermaid .node rect,
+ .mermaid .node circle,
+ .mermaid .node polygon { stroke-width: 1.5px !important; }
+
+ /* ---- Callout ---- */
+ .callout {
+ background: var(--surface); border: 1px solid var(--border);
+ border-left: 3px solid var(--primary); border-radius: 0 10px 10px 0;
+ padding: 14px 18px; font-size: 12px; line-height: 1.7;
+ color: var(--text-dim); margin-bottom: 24px;
+ }
+ .callout code {
+ font-family: var(--font-mono); font-size: 11px;
+ background: var(--primary-dim); color: var(--primary);
+ padding: 1px 5px; border-radius: 3px;
+ }
+ .callout strong { color: var(--text); }
+
+ @media (max-width: 768px) {
+ body { padding: 16px 14px 60px; }
+ h1 { font-size: 26px; }
+ .system-flow { flex-direction: column; }
+ .sys-arrow { transform: rotate(90deg); align-self: center; }
+ }
+</style>
+</head>
+<body>
+
+<div class="container">
+
+ <!-- Header -->
+ <div class="header animate" style="--i:0">
+ <h1><span>ConvertVideo</span>Controller<span class="badge">Yii2 Console</span></h1>
+ <p class="subtitle">erp24/commands/ConvertVideoController.php — конвертация MOV/AVI → MP4</p>
+ </div>
+
+ <!-- System Overview -->
+ <p class="section-label animate" style="--i:1">Место в системе</p>
+ <div class="system-flow animate" style="--i:2">
+
+ <div class="sys-node primary">
+ <div class="sys-node-label">upload</div>
+ <div class="sys-node-icon">📤</div>
+ <div class="sys-node-title">FileService</div>
+ <div class="sys-node-desc">saveFile()<br>MOV/AVI → push job</div>
+ </div>
+
+ <div class="sys-arrow">→</div>
+
+ <div class="sys-node purple">
+ <div class="sys-node-label">queue</div>
+ <div class="sys-node-icon">🐇</div>
+ <div class="sys-node-title">RabbitMQ</div>
+ <div class="sys-node-desc">ConvertVideoToMp4Job<br>telegram-queue</div>
+ </div>
+
+ <div class="sys-arrow">→</div>
+
+ <div class="sys-node orange">
+ <div class="sys-node-label">worker</div>
+ <div class="sys-node-icon">⚙️</div>
+ <div class="sys-node-title">supervisord</div>
+ <div class="sys-node-desc">3 процесса<br>TTR=600, attempts=3</div>
+ </div>
+
+ <div class="sys-arrow">→</div>
+
+ <div class="sys-node green">
+ <div class="sys-node-label">success</div>
+ <div class="sys-node-icon">✅</div>
+ <div class="sys-node-title">files.url → .mp4</div>
+ <div class="sys-node-desc">MOV удалён<br>сжатие ~3-4×</div>
+ </div>
+
+ <div class="sys-arrow" style="color:var(--red)">↘</div>
+
+ <div class="sys-node red">
+ <div class="sys-node-label">fallback</div>
+ <div class="sys-node-icon">⏰</div>
+ <div class="sys-node-title">ConvertVideoController</div>
+ <div class="sys-node-desc">cron каждый час<br>страховка очереди</div>
+ </div>
+
+ </div>
+
+ <!-- Time Window -->
+ <p class="section-label animate" style="--i:3">Временно́е окно выборки (--days=2, --min-age=2)</p>
+ <div class="time-window animate" style="--i:4">
+ <div class="tw-timeline">
+ <div class="tw-line"></div>
+ <div class="tw-zone too-old">слишком старые</div>
+ <div class="tw-zone active">✓ берём</div>
+ <div class="tw-zone queue">очередь</div>
+ <div class="tw-zone now">now</div>
+ <div class="tw-marker" style="left:18%">borderDate<br>2 дня назад</div>
+ <div class="tw-marker" style="left:70%">maxDate<br>2 часа назад</div>
+ </div>
+ <div class="tw-legend">
+ <div class="tw-legend-item"><div class="tw-dot" style="background:var(--red)"></div> > 2 дней — не трогаем (export-old удалит)</div>
+ <div class="tw-legend-item"><div class="tw-dot" style="background:var(--green)"></div> 2ч — 2дн назад — пропущены очередью, конвертируем</div>
+ <div class="tw-legend-item"><div class="tw-dot" style="background:var(--yellow)"></div> < 2 часов — очередь ещё работает, не мешаем</div>
+ </div>
+ </div>
+
+ <!-- Params -->
+ <p class="section-label animate" style="--i:5">Параметры</p>
+ <div class="params-grid animate" style="--i:6">
+ <div class="param-card">
+ <div class="param-name">--days</div>
+ <div class="param-default">по умолчанию: 2</div>
+ <div class="param-desc">Не брать файлы старше N дней. 0 = все файлы (для разовой массовой конвертации)</div>
+ </div>
+ <div class="param-card">
+ <div class="param-name">--min-age</div>
+ <div class="param-default">по умолчанию: 2</div>
+ <div class="param-desc">Пропускать файлы моложе N часов. Очередь ещё успеет их обработать</div>
+ </div>
+ <div class="param-card">
+ <div class="param-name">--limit</div>
+ <div class="param-default">по умолчанию: 0 (без лимита)</div>
+ <div class="param-desc">Максимум файлов за один запуск</div>
+ </div>
+ <div class="param-card">
+ <div class="param-name">--sleep</div>
+ <div class="param-default">по умолчанию: 2</div>
+ <div class="param-desc">Пауза в секундах между файлами. Снижает нагрузку на диск и CPU</div>
+ </div>
+ <div class="param-card">
+ <div class="param-name">--dry-run</div>
+ <div class="param-default">по умолчанию: false</div>
+ <div class="param-desc">Только показать что будет конвертировано. Ничего не меняет</div>
+ </div>
+ </div>
+
+ <!-- Main Flowchart -->
+ <p class="section-label animate" style="--i:7">Блок-схема actionRun()</p>
+ <div class="mermaid-wrap animate" style="--i:8">
+ <div class="zoom-controls">
+ <button onclick="zoomDiagram(this, 1.2)" title="Увеличить">+</button>
+ <button onclick="zoomDiagram(this, 0.8)" title="Уменьшить">−</button>
+ <button onclick="resetZoom(this)" title="Сбросить">↺</button>
+ </div>
+ <pre class="mermaid">
+flowchart TD
+ A([▶ actionRun]) --> B["exec('which ffmpeg')"]
+ B --> C{FFmpeg\nустановлен?}
+ C -->|нет| D([🛑 exit ERROR])
+ C -->|да| E["Вычислить временно́е окно\nborderDate = NOW - days\nmaxDate = NOW - minAge hours"]
+ E --> F["SQL: SELECT files\nWHERE url LIKE '%.mov'\n OR url LIKE '%.avi'\nAND created_at BETWEEN\n borderDate AND maxDate\nORDER BY id DESC LIMIT N"]
+ F --> G{Найдено\nфайлов?}
+ G -->|0| H([✅ exit OK\nНет файлов])
+ G -->|"> 0"| I["foreach files"]
+ I --> J{"file_exists\nsourcePath?"}
+ J -->|нет| K["⏭ ПРОПУЩЕН\nфайл не найден"]
+ J -->|да| L{"file_exists\nmp4Path?"}
+ L -->|да| M["⏭ ПРОПУЩЕН\nMP4 уже есть"]
+ M --> N{"files.url\n!= mp4Url?"}
+ N -->|да| O["✏️ files.url = mp4Url\nsave() — синхронизация БД"]
+ N -->|нет| P
+ O --> P
+ L -->|нет| Q{"--dry-run?"}
+ Q -->|да| R["📋 будет конвертирован\nbez изменений"]
+ Q -->|нет| S["proc_open:\nnice -n 19 timeout 1800\nffmpeg -y -i source.mov\n-c:v libx264 -preset fast\n-crf 23 -c:a aac\n-movflags +faststart\noutput.mp4\n\nstdin/stdout/stderr → /dev/null"]
+ S --> T{returnCode == 0\nAND mp4 exists?}
+ T -->|нет| U["❌ ОШИБКА\nunlink битого mp4\nYii::error(code=X)"]
+ T -->|да| V["✅ files.url = .mp4\nfile.save()"]
+ V --> W["unlink source.mov\nOK (Xs, A→B MB)"]
+ W --> P
+ U --> P
+ K --> P
+ R --> P
+ P["sleep(2s)\n⏸ пауза"] --> X{Ещё\nфайлы?}
+ X -->|да| I
+ X -->|нет| Y["=== Итоги ===\nКонвертировано: N\nПропущено: N\nОшибок: N"]
+ Y --> Z([✅ exit OK])
+
+ classDef process fill:#2a3045,stroke:#88c0d0,color:#d8def0
+ classDef decision fill:#2e2a1f,stroke:#ebcb8b,color:#ebcb8b
+ classDef success fill:#1f2e20,stroke:#a3be8c,color:#a3be8c
+ classDef error fill:#2e1f20,stroke:#bf616a,color:#bf616a
+ classDef skip fill:#252535,stroke:#b48ead,color:#b48ead
+ classDef terminal fill:#1a1f2e,stroke:#7a86a8,color:#d8def0
+
+ class A,Z,H,D terminal
+ class B,E,F,I,P,S,V process
+ class C,G,J,L,N,Q,T,X decision
+ class W,O success
+ class U error
+ class K,M,R skip
+ </pre>
+ </div>
+
+ <div class="legend animate" style="--i:9">
+ <div class="legend-item"><div class="legend-swatch" style="background:var(--primary)"></div> Процесс / действие</div>
+ <div class="legend-item"><div class="legend-swatch" style="background:var(--yellow)"></div> Решение / ветвление</div>
+ <div class="legend-item"><div class="legend-swatch" style="background:var(--green)"></div> Успех</div>
+ <div class="legend-item"><div class="legend-swatch" style="background:var(--red)"></div> Ошибка</div>
+ <div class="legend-item"><div class="legend-swatch" style="background:var(--purple)"></div> Пропуск</div>
+ </div>
+
+ <!-- FFmpeg command callout -->
+ <div class="callout animate" style="--i:10" style="margin-top:24px">
+ <strong>FFmpeg команда:</strong>
+ <code>nice -n 19 timeout 1800 ffmpeg -y -i source.mov -c:v libx264 -preset fast -crf 23 -c:a aac -movflags +faststart output.mp4</code><br><br>
+ <strong>nice -n 19</strong> — низкий приоритет CPU, не мешает пользователям |
+ <strong>timeout 1800</strong> — принудительный kill через 30 мин |
+ <strong>crf 23</strong> — баланс качество/размер |
+ <strong>+faststart</strong> — MP4 начинает играть до полной загрузки |
+ <strong>proc_open + /dev/null</strong> — PHP не зависает на буфере вывода FFmpeg
+ </div>
+
+ <!-- Cron -->
+ <div class="callout animate" style="--i:11">
+ <strong>Cron (каждый час, страховка очереди):</strong><br>
+ <code>0 * * * * sudo -u www-data php /var/www/erp24/yii convert-video/run --limit=50 >> /var/log/convert-video.log 2>&1</code>
+ </div>
+
+</div>
+
+<script type="module">
+ import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
+ import elkLayouts from 'https://cdn.jsdelivr.net/npm/@mermaid-js/layout-elk/dist/mermaid-layout-elk.esm.min.mjs';
+
+ mermaid.registerLayoutLoaders(elkLayouts);
+ mermaid.initialize({
+ startOnLoad: true,
+ theme: 'base',
+ look: 'classic',
+ layout: 'elk',
+ themeVariables: {
+ background: '#222838',
+ primaryColor: '#2a3045',
+ primaryBorderColor: '#88c0d0',
+ primaryTextColor: '#d8def0',
+ secondaryColor: '#2e2a1f',
+ secondaryBorderColor:'#ebcb8b',
+ secondaryTextColor: '#ebcb8b',
+ tertiaryColor: '#1f2e20',
+ tertiaryBorderColor: '#a3be8c',
+ tertiaryTextColor: '#a3be8c',
+ lineColor: '#4a5568',
+ edgeLabelBackground: '#222838',
+ fontSize: '13px',
+ fontFamily: "'JetBrains Mono', monospace",
+ }
+ });
+</script>
+
+<script>
+ function updateZoomState(wrap) {
+ var target = wrap.querySelector('.mermaid');
+ var zoom = parseFloat(target.dataset.zoom || '1');
+ wrap.classList.toggle('is-zoomed', zoom > 1);
+ }
+ function zoomDiagram(btn, factor) {
+ var wrap = btn.closest('.mermaid-wrap');
+ var target = wrap.querySelector('.mermaid');
+ var current = parseFloat(target.dataset.zoom || '1');
+ var next = Math.min(Math.max(current * factor, 0.3), 5);
+ target.dataset.zoom = next;
+ target.style.transform = 'scale(' + next + ')';
+ updateZoomState(wrap);
+ }
+ function resetZoom(btn) {
+ var wrap = btn.closest('.mermaid-wrap');
+ var target = wrap.querySelector('.mermaid');
+ target.dataset.zoom = '1';
+ target.style.transform = 'scale(1)';
+ updateZoomState(wrap);
+ }
+ document.querySelectorAll('.mermaid-wrap').forEach(function(wrap) {
+ wrap.addEventListener('wheel', function(e) {
+ if (!e.ctrlKey && !e.metaKey) return;
+ e.preventDefault();
+ var target = wrap.querySelector('.mermaid');
+ var current = parseFloat(target.dataset.zoom || '1');
+ var factor = e.deltaY < 0 ? 1.1 : 0.9;
+ var next = Math.min(Math.max(current * factor, 0.3), 5);
+ target.dataset.zoom = next;
+ target.style.transform = 'scale(' + next + ')';
+ updateZoomState(wrap);
+ }, { passive: false });
+ var startX, startY, scrollL, scrollT;
+ wrap.addEventListener('mousedown', function(e) {
+ if (e.target.closest('.zoom-controls')) return;
+ var target = wrap.querySelector('.mermaid');
+ if (parseFloat(target.dataset.zoom || '1') <= 1) return;
+ wrap.classList.add('is-panning');
+ startX = e.clientX; startY = e.clientY;
+ scrollL = wrap.scrollLeft; scrollT = wrap.scrollTop;
+ });
+ window.addEventListener('mousemove', function(e) {
+ if (!wrap.classList.contains('is-panning')) return;
+ wrap.scrollLeft = scrollL - (e.clientX - startX);
+ wrap.scrollTop = scrollT - (e.clientY - startY);
+ });
+ window.addEventListener('mouseup', function() {
+ wrap.classList.remove('is-panning');
+ });
+ });
+</script>
+</body>
+</html>