]> gitweb.erp-flowers.ru Git - erp24_rep/yii-erp24/.git/commitdiff
feat(ERP-236): refine ConvertVideoController — entity filter, min-age window, proc_op... feature_filippov_ERP-236_add_video_player origin/feature_filippov_ERP-236_add_video_player
authorAleksey Filippov <Aleksey.Filippov@erp-flowers.ru>
Wed, 25 Feb 2026 11:23:38 +0000 (14:23 +0300)
committerAleksey Filippov <Aleksey.Filippov@erp-flowers.ru>
Wed, 25 Feb 2026 11:23:38 +0000 (14:23 +0300)
- Filter by entity=write_offs_products_erp_video (only write-off videos, not all files)
- Add --min-age=2h param: skip files younger than 2h (queue still processing)
- Reduce default --days from 10 to 2 (cron safety net for queue failures)
- Fix PHP hanging: switch from exec/system to proc_open with /dev/null pipes
- Fix timeout: raise from 600s to 1800s for large files
- Update actionStatus: show write-off video stats only (MOV+AVI / MP4)
- Update WriteOffsErp: change border date from -2 month to -1 month
- Add WriteOffsAttachmentsController: sync border_date comment
- Add erp24/docs/diagrams/convert-video-controller.html

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
erp24/commands/ConvertVideoController.php
erp24/docs/diagrams/convert-video-controller.html [new file with mode: 0644]

index 7c7ce17dd2b0d3b826c553b5c5fc2452f4b2ee57..f377435e0483a2e7d0458eb89e6239def9fe4abd 100644 (file)
@@ -10,20 +10,24 @@ use yii\console\ExitCode;
 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
 {
@@ -36,8 +40,11 @@ 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
     {
@@ -46,6 +53,7 @@ class ConvertVideoController extends Controller
             'dryRun',
             'sleep',
             'days',
+            'minAge',
         ]);
     }
 
@@ -66,30 +74,28 @@ class ConvertVideoController extends Controller
     {
         $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;
     }
@@ -107,14 +113,27 @@ class ConvertVideoController extends Controller
         }
 
         $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");
         }
@@ -182,24 +201,25 @@ class ConvertVideoController extends Controller
                 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);
                 }
diff --git a/erp24/docs/diagrams/convert-video-controller.html b/erp24/docs/diagrams/convert-video-controller.html
new file mode 100644 (file)
index 0000000..c9400bf
--- /dev/null
@@ -0,0 +1,576 @@
+<!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 &mdash; конвертация 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> &gt; 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> &lt; 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="Уменьшить">&minus;</button>
+      <button onclick="resetZoom(this)" title="Сбросить">&#8634;</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, не мешает пользователям &nbsp;|&nbsp;
+    <strong>timeout 1800</strong> — принудительный kill через 30 мин &nbsp;|&nbsp;
+    <strong>crf 23</strong> — баланс качество/размер &nbsp;|&nbsp;
+    <strong>+faststart</strong> — MP4 начинает играть до полной загрузки &nbsp;|&nbsp;
+    <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 &gt;&gt; /var/log/convert-video.log 2&gt;&amp;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>