Track Muxer: различия между версиями

Материал из wolfram
Перейти к навигации Перейти к поиску
Новая страница: «пока что пусто»
 
Импорт технической статьи об алгоритмах conform (зрение/звук)
Строка 1: Строка 1:
пока что пусто
<div style="border-left:5px solid #aaa; background:#f6f6f6; padding:6px 12px; margin:10px 0;">
'''Назначение документа.''' Полное инженерное описание двух алгоритмов выравнивания, на которых стоит модуль <code>conform</code>: '''анализатора зрения''' (строит карту таймлайна по видеоряду) и '''аудио-доводки band/muq''' (правит остаточный сдвиг звука поверх зрения). Документ самодостаточен: математика, геометрия, рекурренты, точные константы и их обоснования, схемы пайплайнов и разбор реальных диагностических графиков. Читатель — инженер, который при желании сможет воспроизвести метод.
 
'''Конвенция знаков (сквозная).''' Сдвиг измеряем в ''кадрах рефа''; '''правее (дубль отстаёт) = +''', левее (спешит) = −. Один кадр = '''41.708 мс''' (23.976 fps, <code>FRAME</code> в <code>anchor/params.py</code>). Сетка анализа звука <code>T = arange(2.5, 1400, 0.5)</code> с, шаг <code>STEP = 0.5</code> с.
 
'''Источники.''' Всё, что ниже, сверено с боевым кодом <code>src/track_muxer/conform/**</code> и отчётами <code>doc/reports/**</code>. Ключевые файлы перечислены в Приложении Б.
</div>
 
-----
 
<span id="0-постановка-задачи-и-общая-архитектура"></span>
== 0. Постановка задачи и общая архитектура ==
 
<span id="01-что-мы-выравниваем"></span>
=== 0.1. Что мы выравниваем ===
 
Дан '''эпизод сериала''' (или отдельный фильм) в виде набора файлов разных озвучек (студий). Все они содержат '''один и тот же видеоряд''' (с точностью до монтажных отличий: разные заставки, вырезы, вставки, иногда другой fps), но '''разные аудиодорожки'''. Нужно для каждой пары <code>(референс, дубль)</code> так преобразовать аудио дубля во времени, чтобы оно '''синхронно''' легло на таймлайн референса — и потом смуксировать все дорожки в один MKV.
 
Главная трудность — это '''не «найти один сдвиг»'''. Сдвиг переменный по всей длине:
 
{| class="wikitable"
|-
! Источник рассинхрона
! Как выглядит на карте <code>(t_реф → t_дубль)</code>
|-
| Постоянный сдвиг (priming, encoder delay)
| вертикальный параллельный перенос
|-
| Дрейф скорости (разный fps, PAL/NTSC)
| наклон кривой ≠ 1
|-
| Монтажный '''вырез''' в дубле (короче рефа)
| ступень вверх (разрыв)
|-
| Монтажная '''вставка''' в дубле (длиннее рефа)
| ступень вниз (разрыв)
|-
| Заставка/реклама в начале
| свободный левый конец
|}
 
И всё это — на контенте, где прямое применение обоих слоёв теряет чувствительность: видеоряд может идти «на двойках» (соседние кадры идентичны — анимация на 2 кадра, статичные планы), музыка повторяется (корреляция фиксируется на такт), у дубля чужой голос поверх общей музыки.
 
<div style="border:2px dashed #b8860b; background:#fffdf3; padding:6px 12px; margin:10px 0;">
🖼️ '''ИЗОБРАЖЕНИЕ 0.1 — «Проблема в одной картинке».''' Слева: две киноленты (реф и дубль) друг под другом, на дубле — вырезанный кусок (короче) и вставленная заставка в начале (длиннее), стрелки соответствия кадров между лентами расходятся (не параллельны). Справа: те же соответствия как точки в осях <code>t_реф</code>(X) / <code>t_дубль</code>(Y) — складываются в ломаную линию с наклоном и двумя вертикальными ступенями. Подпись: «Выравнивание = восстановить эту ломаную».
</div>
<span id="02-двухслойная-архитектура-зрение-строит-карту-звук-доводит"></span>
=== 0.2. Двухслойная архитектура: зрение строит карту, звук доводит ===
 
Фундаментальное решение проекта (отчёт <code>BREAKTHROUGH_VIDEO_SYNC.md</code>): '''видеоряд — общий инвариант''' всех озвучек, поэтому грубую и структурную карту строим по видео, а аудио используем лишь как '''тонкую доводку остатка''', который видео физически не видит (аудио источника бывает смещено относительно своего же видео).
 
{{#mermaid:
flowchart TB
    subgraph IN[Вход]
        REF[Референс mp4/mkv]
        DUB[Дубль mp4/mkv]
    end
 
    subgraph VIS["СЛОЙ 1 · ЗРЕНИЕ (видео-карта)"]
        SRM[SRM-отпечатки кадров<br/>features.py]
        CO[Грубый проход · LIS<br/>kernel/coarse.py]
        DD[Drop-DTW в полосе<br/>kernel/dropdtw + band_align]
        LV[Разбор правок<br/>level_decide / creep_drop]
        PW[Укладка ломаной + детект резов<br/>vision_detect.py]
        SRM --> CO --> DD --> LV --> PW
    end
 
    subgraph RES[Ресэмпл аудио дубля]
        MAP[Карта t_реф → t_дубль]
        SIL1[Тишина в монтажных резах]
        MAP --> SIL1
    end
 
    subgraph AUD["СЛОЙ 2 · ЗВУК (доводка)"]
        OFF0[Грубая off0 · band ±2.5с]
        FOL[band ±0.7с следит за off0]
        DET[Детект ступеней · detect.py]
        DRIFT[Кривая дрейфа + потолок скорости]
        WARP[Варп по кускам + тишина]
        OFF0 --> FOL --> DET --> DRIFT --> WARP
    end
 
    FILL[Заполнение тишины синхронным рефом<br/>fill_silence]
    PLOTS[Единый график зрение+звук<br/>plots_unified.py]
    OUT[Выровненный WAV → mux]
 
    REF --> SRM
    DUB --> SRM
    PW --> MAP
    SIL1 --> OFF0
    WARP --> FILL --> PLOTS --> OUT
}}
Почему именно так, а не «всё по аудио» или «всё по видео»:
 
* '''Только аудио''' упирается в потолок: 5 разных аудио-методов (PHAT, MFCC, 2D-спектр) дают ''одни и те же промахи'' на повторяющемся контенте — это не алгоритмическая, а ''информационная'' нехватка в одном аудиоканале (<code>BREAKTHROUGH_VIDEO_SYNC.md §2</code>).
* '''Только видео''' не видит сдвиг аудио относительно видео внутри самого источника (AV-sync delta, студийный сдвиг M&amp;E) — его видит лишь аудио.
* Поэтому: '''видео даёт структуру''' (где вырез, где вставка, какой дрейф), '''аудио доводит''' субкадровый остаток там, где видео слепо по физике (двойки) или где аудио смещено относительно картинки.
 
<span id="03-сквозные-величины-и-обозначения"></span>
=== 0.3. Сквозные величины и обозначения ===
 
{| class="wikitable"
|-
! Обозначение
! Смысл
! Значение / где
|-
| <code>FRAME</code>
| мс на кадр
| 41.708 (≈23.976 fps)
|-
| <code>T</code>
| сетка анализа звука
| <code>arange(2.5, 1400, 0.5)</code> с
|-
| <code>STEP</code>
| шаг сетки
| 0.5 с
|-
| <code>o[t]</code>
| измеренный сдвиг в точке t
| кадры рефа
|-
| <code>w[t]</code>
| уверенность измерения
| норм. по медиане
|-
| <code>pred[j]</code>
| для кадра дубля j — кадр рефа (или −1)
| целое
|-
| cos
| мера схожести двух кадров
| <math display="inline">\in[-1,1]</math>, на практике <math display="inline">[0,1]</math>
|}
 
Дальше — два алгоритма по слоям.
 
 
-----
 
<span id="часть-i-зрение--построитель-видео-карты"></span>
= Часть I. Зрение — построитель видео-карты =
 
Файлы: <code>features.py</code>, <code>kernel/coarse.py</code>, <code>kernel/dropdtw.py</code>, <code>kernel/band_align.py</code>, <code>vision_detect.py</code>, разбор правок в <code>align.py</code>. Это '''единственный''' построитель видео-карты в conform (прежний «скат» <code>_build_map_linear</code> удалён 2026-06-18).
 
<a name="1-геометрия-задачи"></a>
 
<span id="1-геометрия-задачи-карта-как-монотонная-кусочно-линейная-кривая"></span>
== 1. Геометрия задачи: карта как монотонная кусочно-линейная кривая ==
 
Всё зрение сводится к одной геометрической цели — построить функцию
 
<math display="block">
\tau:\; t_{\text{реф}} \;\longmapsto\; t_{\text{дубль}},
</math>
 
которая каждому моменту таймлайна референса сопоставляет момент в дубле, откуда брать звук. У этой функции жёсткая физическая структура:
 
* '''кусочно-линейная''' — внутри монтажного блока скорость постоянна (дрейф = наклон);
* '''монотонно неубывающая''' — время не идёт вспять (контент не переставляется);
* '''с вертикальными разрывами на монтажных стыках''' — вырез/вставка = ступень.
 
<pre> t_дубль
  ▲
  │                                        ╱ (наклон ≈ fps_реф/fps_дубль)
  │                                  ╱╱╱
  │            вставка ↓        ╱╱╱
  │                        ┌────╱          ← разрыв вниз: в дубле лишний кусок
  │                        ┊
  │                  ╱╱╱╱╱┘
  │              ╱╱╱╱
  │  вырез ↑  ╱
  │        ┌──┘                            ← разрыв вверх: в дубле кусок вырезан
  │      ╱╱╱┊
  │  ╱╱╱╱  ┊
  └──┴──────┴──────────────────────────────►  t_реф</pre>
Задача зрения — '''восстановить эту ломаную из зашумлённых измерений соответствия кадров'''. Шум двух видов: (а) ложные соответствия (двойки, лого, тёмные сцены), (б) пропуски (вырезы/вставки рвут непрерывность). Поэтому метод строится в три эшелона надёжности: грубая монотонная цепочка (§3) → точное выравнивание с пропусками (§4) → робастная ломаная поверх (§6).
 
<a name="2-srm-отпечаток-кадра"></a>
 
<span id="2-srm-отпечаток-кадра"></span>
== 2. SRM-отпечаток кадра ==
 
Чтобы сравнивать кадры рефа и дубля, каждый кадр сворачивается в вектор-отпечаток так, чтобы '''схожесть контента''' была устойчива к различиям источника (BD vs Web, разный кодек, яркость, логотип студии). Наивный путь — сравнивать пиксели яркости — ломается на первом же логотипе и перепаде гаммы. Решение — '''SRM (Spatial Rich Model): отпечаток не яркости, а остаточного высокочастотного сигнала''' (текстуры/краёв).
 
<div style="border-left:5px solid #3a86ff; background:#eef3ff; padding:6px 12px; margin:10px 0;">
'''Отступление: что такое «отпечаток кадра» и SRM.''' Отпечаток (дескриптор) — это короткий числовой слепок картинки: вектор чисел, по которому два кадра можно сравнить, не сличая их попиксельно. Хороший отпечаток одинаков у одного и того же кадра в разном качестве и не сбивается от логотипа или яркости. '''SRM (Spatial Rich Model)''' — приём из цифровой криминалистики изображений: вместо самой картинки берут её ''высокочастотный остаток'' — то, что остаётся после вычитания плавных перепадов яркости (контуры, мелкая текстура). Именно остаток устойчив к перекодированию и смене источника.
</div>
<span id="21-конвейер-построения-featurespybuild_srm"></span>
=== 2.1. Конвейер построения (<code>features.py::build_srm</code>) ===
 
{{#mermaid:
flowchart LR
    A[Кадр ffmpeg<br/>-vsync 0] --> B[Серый 128×72]
    B --> C1[Свёртка KB<br/>лапласиан]
    B --> C2[Свёртка D1<br/>градиент x]
    C1 --> D1[clip ±3]
    C2 --> D2[clip ±3]
    D1 --> E1[L2-норма канала]
    D2 --> E2[L2-норма канала]
    E1 --> F[concat × 1/√2<br/>float16, dim=18432]
    E2 --> F
}}
Пошагово. Кадр приводится к серому <math display="inline">g</math> размера <math display="inline">128\times72</math> (<code>GW×GH</code>).
 
<div style="border-left:5px solid #3a86ff; background:#eef3ff; padding:6px 12px; margin:10px 0;">
'''Простыми словами: свёртка ядром.''' Свёртка — это проход маленькой матрицей-окном по всем пикселям: в каждой точке берётся взвешенная сумма соседних пикселей. Подбором весов окно настраивают на нужный признак — перепад яркости, край, текстуру. Сумма весов, равная нулю, означает, что окно «не замечает» ровной заливки и реагирует только на изменения.
</div>
Здесь два фиксированных ядра свёртки:
 
<math display="block">
K_B=\begin{pmatrix}-1&2&-1\\ 2&-4&2\\ -1&2&-1\end{pmatrix},
\qquad
D_1=\begin{pmatrix}0&0&0\\ 0&-1&1\\ 0&0&0\end{pmatrix}.
</math>
 
* <math display="inline">K_B</math> — дискретный оператор второго порядка (лапласиан-подобный, сумма коэффициентов <math display="inline">=0</math>). Он '''зануляет постоянную и линейную составляющую''' яркости и реагирует на ''кривизну'' — текстуру, контуры, мелкие детали. Именно эта высокочастотная «подпись» одинакова у одного и того же кадра в разных рипах.
* <math display="inline">D_1</math> — первая горизонтальная разность (<math display="inline">\partial/\partial x</math>), ловит вертикальные края и направление градиента.
 
Свёртка идёт с краевым режимом <code>reflect</code>: <math display="inline">r_K = (g * K_B)</math>, <math display="inline">r_D = (g * D_1)</math>. Далее — обрезка выбросов и нормировка каждого канала по отдельности:
 
<math display="block">
\tilde r_K=\frac{\operatorname{clip}(r_K,-3,3)}{\lVert\operatorname{clip}(r_K,-3,3)\rVert_2+\varepsilon},
\qquad
\tilde r_D=\frac{\operatorname{clip}(r_D,-3,3)}{\lVert\operatorname{clip}(r_D,-3,3)\rVert_2+\varepsilon}.
</math>
 
Обрезка <math display="inline">\pm3</math> давит редкие яркие выбросы (блики, субтитры), L2-нормировка убирает зависимость от общего контраста кадра. Итоговый вектор кадра — конкатенация обоих каналов, масштабированная на <math display="inline">1/\sqrt2</math>:
 
<math display="block">
v=\tfrac{1}{\sqrt2}\,[\,\tilde r_K \;\Vert\; \tilde r_D\,]\in\mathbb{R}^{18432},\qquad
18432 = 2\cdot128\cdot72 .
</math>
 
<span id="22-почему-cos--v_1cdot-v_2-напрямую"></span>
=== 2.2. Почему <code>cos = v_1\cdot v_2</code> напрямую ===
 
<div style="border-left:5px solid #3a86ff; background:#eef3ff; padding:6px 12px; margin:10px 0;">
'''Простыми словами: косинусная близость.''' Если каждый кадр представить стрелкой (вектором) в многомерном пространстве, то мерой их похожести служит угол между стрелками: сонаправленные (один контент) дают косинус ≈ 1, перпендикулярные (разный контент) ≈ 0. Косинус зависит только от направления, не от длины стрелок, — поэтому он устойчив к общей яркости и контрасту.
</div>
Множитель <math display="inline">1/\sqrt2</math> выбран так, что '''готовый вектор уже нормирован''': поскольку каждый канал имеет единичную норму,
 
<math display="block">
\lVert v\rVert_2^2=\tfrac12\big(\lVert\tilde r_K\rVert_2^2+\lVert\tilde r_D\rVert_2^2\big)=\tfrac12(1+1)=1.
</math>
 
Поэтому скалярное произведение двух отпечатков '''сразу равно косинусной близости''', и она же — среднее канальных косинусов:
 
<math display="block">
v_1\cdot v_2=\tfrac12\big(\cos_{K_B}+\cos_{D_1}\big)=\cos(v_1,v_2)\in[-1,1].
</math>
 
Это ключ к скорости всего зрения: матрица всех попарных схожестей — одно матричное умножение <math display="inline">G = C_s C_r^{\top}</math> (косинусы без отдельной нормировки), а стоимость для Drop-DTW — просто <math display="inline">C = 1 - \cos</math>.
 
<div style="border-left:5px solid #aaa; background:#f6f6f6; padding:6px 12px; margin:10px 0;">
'''Инженерные детали, ломающие бит-в-бит совпадение, если их нарушить''' (<code>features.py</code>): ffmpeg <code>-vsync 0</code> (passthrough — индекс кадра = позиция во времени), <code>float16</code>, порядок <code>clip → norm → concat</code>. Декод потоковый, при <code>low_mem</code> фичи пишутся в memmap, значения идентичны in-RAM пути.
</div>
<div style="border:2px dashed #b8860b; background:#fffdf3; padding:6px 12px; margin:10px 0;">
🖼️ '''ИЗОБРАЖЕНИЕ 2.1 — «Что видит SRM».''' Три колонки: (1) исходный кадр видео с логотипом студии в углу; (2) карта отклика <math display="inline">K_B</math> (контуры/текстура, лого почти не выделяется — оно гладкое); (3) карта отклика <math display="inline">D_1</math> (вертикальные края). Подпись: «Отпечаток строится по высокочастотному остатку → устойчив к яркости и логотипу».
</div>
<a name="3-грубый-проход"></a>
 
<span id="3-грубый-проход-матрица-косинусов-надёжные-якоря-lis"></span>
== 3. Грубый проход: матрица косинусов, надёжные якоря, LIS ==
 
Цель первого эшелона — получить '''грубую, но монотонную''' оценку сдвига <code>off(j)</code> для каждого кадра дубля <math display="inline">j</math>, чтобы потом узкая полоса Drop-DTW искала точное соответствие не во всём рефе, а в коридоре вокруг этой линии. Файл <code>kernel/coarse.py</code>.
 
<span id="31-надёжные-якоря-_anchors"></span>
=== 3.1. Надёжные якоря (<code>_anchors</code>) ===
 
Кадры прореживаются с шагом <math display="inline">K=8</math> (грубому проходу субкадровая точность не нужна). Строится матрица косинусов всех прореженных synth × ref:
 
<math display="block">
G = C_s\,C_r^{\top},\qquad G_{m,r}=\cos\big(v^{\text{syn}}_m, v^{\text{ref}}_r\big).
</math>
 
Для каждого кадра дубля <math display="inline">m</math> берётся лучший партнёр и его близость <math display="inline">\text{best}_m=\arg\max_r G_{m,r}</math>, <math display="inline">\text{bestv}_m=\max_r G_{m,r}</math>. Затем окрестность <math display="inline">\pm W</math> (<math display="inline">W=80</math> прореженных кадров) этого пика '''зануляется''', и берётся второй максимум <math display="inline">\text{second}_m</math>. Кадр становится '''надёжным якорем''' только если выполнены оба условия:
 
<math display="block">
\underbrace{\text{bestv}_m > V_{HI}}_{\text{пик высокий}}\quad\wedge\quad
\underbrace{\text{bestv}_m-\text{second}_m > C_{MIN}}_{\text{пик одинок (различим)}},\qquad
V_{HI}=0.60,\; C_{MIN}=0.12.
</math>
 
Первое условие отсекает мусорные сцены (тьма, переход), второе — '''двойки и повторы''': если в рефе есть второй почти такой же кадр, отрыв мал и кадр НЕ берётся в якоря. Это прямой ответ на главную проблему повторяющегося контента.
 
<span id="32-монотонная-цепочка-через-lis-lis_nd-coarse_robust"></span>
=== 3.2. Монотонная цепочка через LIS (<code>lis_nd</code>, <code>coarse_robust</code>) ===
 
<div style="border-left:5px solid #3a86ff; background:#eef3ff; padding:6px 12px; margin:10px 0;">
'''Отступление: зачем «длиннейшая неубывающая подпоследовательность» (LIS).''' Среди якорей есть верные и единичные ложные. Верные обязаны идти «по возрастанию»: чем позже кадр в дубле, тем позже его партнёр в рефе (время не отматывается назад). LIS — классический алгоритм, который из разбросанных точек вытаскивает самую длинную цепочку, идущую только вверх; всё, что в неё не уложилось, почти наверняка ложные пары. Так костяк соответствия очищается без порогов.
</div>
Надёжные якоря ещё могут содержать одиночные ложные пары. Истинное соответствие '''монотонно''' по рефу, поэтому из набора якорей <code>(synth_кадр → ref_кадр)</code> извлекается '''длиннейшая неубывающая по ref подпоследовательность''' (Longest Non-Decreasing Subsequence, <math display="inline">O(n\log n)</math> через хвостовые индексы + бинарный поиск). Всё, что не легло в монотонную цепочку, отбрасывается как шум. По выжившим якорям линейной интерполяцией строится <code>off(j)</code> для всех кадров:
 
<math display="block">
\text{off}(j)=\operatorname{interp}\big(j;\ \{a_k\},\ \{r_k-a_k\}\big).
</math>
 
<pre> ref                    LIS оставляет монотонный костяк, шум выпадает
  ▲        ✗(ложный)
  │  ●───●        ✗
  │  ╱      ●───●───●
  │ ●                  ●───●    ← возрастающая цепочка = реальное соответствие
  │╱            ✗
  └───────────────────────────►  synth (кадр дубля)</pre>
<span id="33-оконный-вариант-для-длинных-файлов-coarse_windowed"></span>
=== 3.3. Оконный вариант для длинных файлов (<code>coarse_windowed</code>) ===
 
Полная матрица <math display="inline">G</math> имеет размер <math display="inline">N_s\times N_r</math> — для серии это гигабайты. Оконный проход режет дубль на окна по <code>CWIN=8000</code> кадров с перекрытием <code>COV=2000</code>; '''seed-offset перетекает из окна в окно''', и в каждом окне ищется ref только в полосе <math display="inline">\pm</math><code>CSR=4000</code> вокруг seed. Память и время ограничены окном (не растут с длиной файла), а результат '''бит-в-бит совпал''' с полным проходом на 26 кейсах.
 
<a name="4-drop-dtw-выравнивание-с-пропусками"></a>
 
<span id="4-drop-dtw-выравнивание-с-пропусками"></span>
== 4. Drop-DTW: выравнивание с пропусками ==
 
Второй эшелон — точное соответствие кадров. Обычный DTW (Dynamic Time Warping) '''обязан''' сопоставить каждый кадр — он не умеет сказать «этого куска в рефе нет, это вставка». А у нас именно вырезы и вставки. Решение — '''Drop-DTW''': DTW, которому разрешено ''выбрасывать'' кадры с обеих сторон за фиксированный штраф. Это ровно как <code>diff</code> для текста или выравнивание последовательностей ДНК: ищем общий костяк, непарные куски помечаем как «вставка»/«удаление». Файл <code>kernel/dropdtw.py</code>.
 
<span id="41-стоимость-и-базовая-рекуррента-drop_dtw"></span>
=== 4.1. Стоимость и базовая рекуррента (<code>drop_dtw</code>) ===
 
Стоимость сопоставить кадр рефа <math display="inline">i</math> с кадром дубля <math display="inline">k</math>:
 
<math display="block">
C_{i,k}=1-\cos\!\big(v^{\text{ref}}_i,v^{\text{syn}}_k\big)\in[0,2].
</math>
 
<div style="border-left:5px solid #3a86ff; background:#eef3ff; padding:6px 12px; margin:10px 0;">
'''Простыми словами.''' Задача — перебрать все способы «растянуть / сжать / пропустить» кадры так, чтобы суммарная непохожесть сопоставленных кадров была минимальной, и сделать это не наивным перебором (астрономически долго), а накоплением лучшего ответа клетка за клеткой — это и есть динамическое программирование. Ниже — правило, по которому заполняется таблица лучших частичных ответов.
</div>
Динамика заполняет таблицу <math display="inline">D_{i,k}</math> — минимальную накопленную стоимость пути, пришедшего в клетку <math display="inline">(i,k)</math>. На каждом шаге доступны '''пять''' ходов (диагональ, два варпа, два выброса); старт — отдельная инициализация (свободный левый конец):
 
<math display="block">
D_{i,k}=\min\begin{cases}
D_{i-1,k-1}+C_{i,k} & \text{диагональ (1:1 совпадение)}\\
D_{i-1,k}+C_{i,k} & \text{вертикальный варп (ref тянется)}\\
D_{i,k-1}+C_{i,k} & \text{горизонтальный варп (дубль тянется)}\\
D_{i,k-1}+\text{DROP} & \text{выброс кадра дубля (вставка)}\\
D_{i-1,k}+\text{DROP} & \text{выброс кадра рефа (вырез)}\\
\end{cases}
</math>
 
с штрафом <math display="inline">\text{DROP}=0.20</math>. Backpointer <math display="inline">B_{i,k}</math> хранит, какой ход выбран, для восстановления пути. '''Свободные концы''':
 
* '''свободный старт''' — <code>D[i,0] = C[i,0]</code> для всех <math display="inline">i</math>: путь может начаться с любой строки рефа (дубль не обязан стартовать с самого начала рефа);
* '''свободный конец''' — обратный ход начинается с <code>argmin</code> последнего столбца: путь может закончиться на любой строке рефа.
 
Восстановление пути (<code>backtrack</code>) даёт три выхода: <code>pred[k]</code> (для кадра дубля <math display="inline">k</math> — его кадр рефа, либо <code>-1</code>), <code>drop_syn</code> (маска выброшенных кадров дубля = вставки), <code>drop_ref</code> (список выброшенных кадров рефа = вырезы).
 
<div style="border:2px dashed #b8860b; background:#fffdf3; padding:6px 12px; margin:10px 0;">
🖼️ '''ИЗОБРАЖЕНИЕ 4.1 — «Путь по матрице Drop-DTW».''' Тепловая карта стоимости <math display="inline">C_{i,k}</math> (тёмное = похоже), ось X = кадры дубля, ось Y = кадры рефа. Поверх — оптимальный путь: диагональные участки (совпадение), один горизонтальный «провал» (выброс кадров дубля = вставка) и один вертикальный «прыжок» (выброс кадров рефа = вырез). Сбоку — легенда шести ходов. Подпись: «Зелёная линия — карта; разрывы = монтажные стыки».
</div>
<span id="42-аффинный-штраф-острый-шов-вместо-размазни-drop_dtw_affine"></span>
=== 4.2. Аффинный штраф: острый шов вместо размазни (<code>drop_dtw_affine</code>) ===
 
Если вырез длинный, выбрасывать его «по одному кадру» дорого и нестабильно — путь норовит размазать выброс или убежать на логотипе. Аффинная (two-state) динамика берёт '''открытие''' прогона выброса дороже, чем его '''продолжение''' (как gap-open / gap-extend в биоинформатике):
 
* состояние <math display="inline">M</math> — «матч/варп» (платит <math display="inline">C</math>);
* состояние <math display="inline">D_r</math> — «внутри выброса рефа»: открыть из <math display="inline">M</math> за <code>OPEN=0.20</code>, продлить из <math display="inline">D_r</math> за <code>EXT=0.02</code>;
* выброс кадра дубля (вставка) — плоская цена <code>DSYN=0.30</code> внутри <math display="inline">M</math>.
 
<math display="block">
D_r[i,k]=\min\big(M[i-1,k]+\text{OPEN},\; D_r[i-1,k]+\text{EXT}\big).
</math>
 
Дёшево продлевать (0.02) → длинный вырез схлопывается в один чистый шов, а не в рваную лесенку. На ровных логотипных участках состояние <math display="inline">D_r</math> не открывается → путь не уходит в сторону.
 
<span id="43-guard-нельзя-выбросить-кадр-с-хорошим-матчем-drop_dtw_affine_guard"></span>
=== 4.3. Guard: нельзя выбросить кадр с хорошим матчем (<code>drop_dtw_affine_guard</code>) ===
 
Без защиты прогон выброса склонен захватывать хвосты совпадающих кадров (где <math display="inline">\cos=1</math>), раздувая вставку. Guard заранее считает для каждого кадра дубля <math display="inline">k</math> лучший достижимый матч по столбцу:
 
<math display="block">
\text{colmin}_k=\min_i C_{i,k}.
</math>
 
Выброс этого кадра (ход drop-syn) разрешён '''только если''' <math display="inline">\text{colmin}_k>\text{MATCH\_THR}</math> (<math display="inline">=0.30</math>); иначе цена выброса <math display="inline">=\infty</math> — кадр с хорошим матчем защищён от выбрасывания.
 
<span id="44-free-start-заставка-начала-выпадает-сама"></span>
=== 4.4. Free-start: заставка начала выпадает сама ===
 
Реклама/заставка в начале дубля не имеет пары в рефе. Free-start даёт '''свободный левый конец по дублю''': путь может стартовать с любой клетки, выбросив ведущие кадры <math display="inline">[0..k-1]</math> по цене вставки <math display="inline">k\cdot\text{DSYN}</math>. Штраф растёт с <math display="inline">k</math> — поэтому короткая заставка выпадает, но путь не «улетает» далеко.
 
<math display="block">
\text{старт в } (i,k):\quad \text{cost}=C_{i,k}+k\cdot\text{DSYN}.
</math>
 
<span id="45-следящая-полоса-band_align"></span>
=== 4.5. Следящая полоса (<code>band_align</code>) ===
 
Drop-DTW на полной матрице <math display="inline">N_s\times N_r</math> невозможен по памяти. Поэтому он считается в '''узкой полосе''' вокруг грубой линии <code>off</code> (§3): дубль режется на куски по <code>CHUNK=4000</code> кадров с перекрытием <code>OVERLAP=700</code>; для каждого куска ref-окно берётся как <math display="inline">[\min(\text{refk})-\text{MARG},\ \max(\text{refk})+\text{MARG}]</math>, полуширина <code>MARG=120</code> кадров (≈5 с — покрывает обычную правку). На стыках кусков для каждого кадра берётся ответ из того куска, где кадр '''дальше от края''' (надёжнее):
 
<pre> кусок A:  ├──────────────────┤
кусок B:              ├──────────────────┤
                      └ overlap ┘
кадр в overlap → берём из того куска, где он ГЛУБЖЕ (центр надёжнее краёв)</pre>
{{#mermaid:
flowchart LR
    OFF[Грубая off из coarse] --> WIN[Окно ref ±MARG=120<br/>вокруг линии]
    CHUNK[Кусок дубля 4000<br/>overlap 700] --> WIN
    WIN --> C["C = 1 − cos"]
    C --> DD[drop_dtw_affine_guard<br/>OPEN .20 / EXT .02 / DSYN .30<br/>MATCH_THR .30 / free_start]
    DD --> BT[backtrack → pred куска]
    BT --> MERGE[Склейка по «глубине» кадра]
}}
Итог слоя §4 — массив <code>pred</code> длины <math display="inline">N_s</math>: для каждого кадра дубля либо кадр рефа, либо <code>-1</code> (выброшен).
 
<a name="5-разбор-правок"></a>
 
<span id="5-разбор-правок-вырезы-вставки-налипания"></span>
== 5. Разбор правок: вырезы, вставки, налипания ==
 
<code>pred</code> сырой: в нём вперемешку настоящие монтажные правки и артефакты (слепые зоны зрения, налипания). Их разбирает '''единое физическое правило''' в <code>align.py</code>, а не набор порогов под контент.
 
<span id="51-правка-реальна-только-при-стойком-сдвиге-уровня-offset-_level_decide"></span>
=== 5.1. Правка реальна только при стойком сдвиге УРОВНЯ offset (<code>_level_decide</code>) ===
 
Ключевая идея: считаем поканальный offset на сопоставленных кадрах <math display="inline">\text{off}_{\text{asg}}=\text{pred}[\text{asg}]-\text{asg}</math>. Для каждого выброшенного блока (drop-syn) сравниваем '''медиану уровня offset до и после''' блока (окно <code>LEVEL_WIN_S=4</code> с):
 
<math display="block">
\Delta=\big|\operatorname{median}(\text{off}_{\text{после}})-\operatorname{median}(\text{off}_{\text{до}})\big|.
</math>
 
* <math display="inline">\Delta\le</math> <code>LEVEL_EDIT_S</code> (1 с) '''или''' блок короче <code>LEVEL_MIN_INS_S</code> (2 с) → '''слепая зона / дрожь''' → восстановить интерполяцией (заодно перекрывает парный пробел рефа). Ложное восстановление безвредно по построению.
* иначе → '''реальная вставка''' → оставить выброшенной.
 
Любой оставшийся пробел рефа при непрерывном дубле &gt; <code>CUT_MIN_S</code> (0.3 с) — это '''реальный вырез'''. Доказано: пробел рефа при непрерывном дубле всегда сдвигает уровень ⇒ слепых вырезов не остаётся.
 
<span id="52-фикс-пилы-вырез-только-при-устойчивом-сдвиге-а-не-при-выбросе-на-статике"></span>
=== 5.2. Фикс «пилы»: вырез только при устойчивом сдвиге, а не при выбросе на статике ===
 
На статике (длинный неподвижный план) зрение может дать ''временный'' выброс offset, который '''возвращается''' — это не вырез, а артефакт. Соседние пробелы склеиваются в кластер (<code>LEVEL_CUT_COALESCE_S=3</code> с); если в пределах <code>LEVEL_CUT_RECOVER_S</code> (8 с) уровень '''возвращается''' к доврезному — кластер восстанавливается (пила убрана), если держится — схлопывается в '''один''' интервал выреза (а не лесенку коротких тишин).
 
<span id="53-детектор-налипания-_creep_drop-кейс-case04"></span>
=== 5.3. Детектор «налипания» (<code>_creep_drop</code>, кейс case/04) ===
 
Особый дефект: вставка-повтор своего же контента с наложенным текстом присваивается Drop-DTW «ползучим ходом» вместо выброса. Сигнал кричащий: присвоенные кадры по содержимому '''чужие''' своим реф-партнёрам — <math display="inline">\cos\approx0.00\text{–}0.02</math> на много секунд подряд, тогда как честные матчи даже в тёмных сценах держат <math display="inline">\cos\ge0.22</math>. Механика: скользящая медиана <math display="inline">\cos</math> присвоенных кадров (окно ~1 с); связные зоны ниже <code>CREEP_COS=0.15</code> длиннее <code>CREEP_MIN_S=3</code> с → <code>pred=-1</code>. Дальнейшую судьбу решает <code>_level_decide</code> (§5.1) — ложное срабатывание безвредно.
 
<span id="54-возврат-краёв-_recover_edges"></span>
=== 5.4. Возврат краёв (<code>_recover_edges</code>) ===
 
Free-start мог выкинуть '''совпадающее''' начало/конец (общий опенинг при разном вступлении BD↔WEB). Берём выброшенный кусок дубля против непокрытого куска рефа, гоняем <code>band_align</code> на подзадаче и вписываем вернувшиеся матчи (где <math display="inline">\cos></math><code>EDGE_COS_MIN=0.5</code>).
 
<a name="6-укладка-ломаной"></a>
 
<span id="6-укладка-ломаной-ow--детект-ступеней--кусочно-линейная-карта"></span>
== 6. Укладка ломаной: (o,w) → детект ступеней → кусочно-линейная карта ==
 
Финал зрения (<code>vision_detect.py</code>) — превратить дискретный <code>pred</code> в '''гладкую кусочно-линейную карту''' с явными разрывами на резах. Это перенос сильного аудио-аппарата (<code>anchor/detect.py</code>) на выход зрения: зрение видит структуру сдвига точнее аудио, а прежний «скат» (<code>maximum.accumulate</code>) её подавлял.
 
<span id="61-из-pred-в-узлы-сетки-o-w-vision_ow"></span>
=== 6.1. Из <code>pred</code> в узлы сетки <code>(o, w)</code> (<code>vision_ow</code>) ===
 
Для сопоставленных кадров считаем время в рефе и сдвиг в кадрах:
 
<math display="block">
t^{\text{ref}}_k=\frac{\text{pred}[k]}{\text{fps}_{\text{ref}}},\qquad
\text{sh}_k=\Big(\frac{k}{\text{fps}_{\text{dub}}}-t^{\text{ref}}_k\Big)\cdot\text{fps}_{\text{ref}}.
</math>
 
На каждом узле сетки <math display="inline">t\in T</math> в окне <math display="inline">\pm</math><code>VIS_WIN</code> (0.3 с) берём '''взвешенную медиану''' сдвига (вес = <math display="inline">\cos</math>), и оцениваем уверенность как медиану <math display="inline">\cos</math>, умноженную на '''долю согласных''' (anchor-«agree») — кадров, чей сдвиг в пределах <code>tol_fr=2</code> кадра от медианы:
 
<math display="block">
o[t]=\operatorname{wmedian}\big(\text{sh},\ \cos\big),\qquad
w[t]=\operatorname{median}(\cos)\cdot\underbrace{\tfrac{1}{n}\#\{|\text{sh}-o|\le2\}}_{\text{agree}}.
</math>
 
Взвешенная медиана — робастная статистика без порогов: сортируем по значению, идём по кумулятивному весу до половины. Пустые узлы интерполируются (<math display="inline">w=0</math>), веса нормируются на медиану положительных.
 
<span id="62-детект-ступеней-dp-сегментация-ломаными-detectwarp_curve"></span>
=== 6.2. Детект ступеней: DP-сегментация ломаными (<code>detect.warp_curve</code>) ===
 
Кривую <code>o(t)</code> режем на сегменты, в каждом — '''одна взвешенная прямая'''; стыки сегментов = кандидаты в резы. Это DP по префиксным суммам.
 
<div style="border-left:5px solid #3a86ff; background:#eef3ff; padding:6px 12px; margin:10px 0;">
'''Отступление: робастная прямая (IRLS).''' Обычная прямая по методу наименьших квадратов легко уводится одной выбросной точкой. IRLS (итеративно перевзвешенный МНК) лечит это: проводим прямую, смотрим, кто далеко отклонился, уменьшаем таким точкам вес и проводим заново. Через пару итераций прямая опирается на основную массу точек и игнорирует выбросы.
</div>
'''Взвешенная прямая (<code>wfit</code>, IRLS).''' Для точек <math display="inline">(t,y)</math> с весами <math display="inline">w</math> оценка наклона <math display="inline">a</math> и сдвига <math display="inline">b</math> — обычная взвешенная регрессия, но в '''2 итерации с даунвейтом выбросов''' (Iteratively Reweighted Least Squares):
 
<math display="block">
a=\frac{\sum w\,(t-\bar t)(y-\bar y)}{\sum w\,(t-\bar t)^2},\quad b=\bar y-a\bar t,\quad
w\leftarrow w\cdot\exp\!\Big[-\big(\tfrac{r}{2.5\,s}\big)^2\Big],
</math>
 
где <math display="inline">r=y-(at+b)</math> — остаток, <math display="inline">s</math> — взвешенный СКО. Второй проход гасит выбросы → прямая не ведётся за единичными ложными узлами.
 
'''Сегментация (<code>_wsegment</code>).''' Стоимость сегмента <math display="inline">[i,j)</math> — взвешенная SSE наилучшей прямой (берётся за <math display="inline">O(1)</math> из шести префиксных сумм <math display="inline">\sum w,\sum wt,\sum wy,\sum wt^2,
\sum wty,\sum wy^2</math>). DP минимизирует суммарную ошибку плюс штраф <code>PEN</code> за каждый сегмент (чем больше PEN, тем меньше резов), при минимальной длине сегмента <code>msize</code>:
 
<math display="block">
\text{opt}[j]=\min_{i\le j-\text{msize}}\big(\text{opt}[i]+\text{SSE}(i,j)+\text{PEN}\big).
</math>
 
Наклон каждого сегмента ограничен потолком <math display="inline">\pm</math><code>SMAX</code> (физика дрейфа ≤~2%). Рез ставится на стыке, если разрыв прямых <math display="inline">\ge</math><code>MIN_FR</code> (3 кадра ≈ 120 мс).
 
<span id="63-фильтр-резов-устойчивость-уровня-на-большом-окне-video_cut_filter"></span>
=== 6.3. Фильтр резов: устойчивость уровня на большом окне (<code>video_cut_filter</code>) ===
 
DP-сегментация склонна давать избыточные стыки; настоящий рез отличается тем, что уровень offset '''устойчиво''' разный слева и справа на '''большом''' окне <math display="inline">\pm</math><code>VC_WIN_S</code> (80 с), и считать его надо по '''надёжным''' узлам. Берём взвешенную медиану уровня слева/справа (робастна к «холмику» в окне — холмик у слепой зоны зрения возвращается, и медиана его игнорирует):
 
<math display="block">
\text{рез реален}\iff \big|\operatorname{wmedian}(o_R,w^3)-\operatorname{wmedian}(o_L,w^3)\big|\ge\text{VC\_THR}.
</math>
 
Два важных нюанса:
 
* '''вес-гейт''' <code>VC_GATE=0.55</code>: в окне берутся только узлы с весом выше квантиля 0.55 (<code>_gmask</code>) — всплески у слепых зон отбрасываются;
* '''окно ограничено соседними резами''' (как <code>tail_filter</code> в аудио): на узком сегменте между двумя близкими резами окно <math display="inline">\pm80</math> с не должно перехлёстывать через соседний рез, иначе поймает чужой уровень и '''ложно подтвердит''' рез. Регрессия 0/36 дорожек.
 
<span id="64-кусочно-линейная-укладка-piecewise_lines"></span>
=== 6.4. Кусочно-линейная укладка (<code>piecewise_lines</code>) ===
 
Между резами кладём '''одну робастную прямую''' (<code>wfit</code>, наклон <math display="inline">\le</math><code>VIS_SMAX</code>=0.45 к/с), изломы только на резах. Это усредняет весь кусок и потому нечувствительно к мелкой ряби <code>o</code>. Итог — кривая сдвига на сетке T.
 
<span id="65-сборка-карты-build_map"></span>
=== 6.5. Сборка карты (<code>build_map</code>) ===
 
<math display="block">
\text{shift}(t)=\operatorname{interp}(t;\ T,\ \text{curve}),\qquad
\tau(t)=t+\text{shift}(t)\cdot\frac{\text{FRAME}}{1000}\ \text{[с]}.
</math>
 
На резах в <code>shift</code> ступень (разрыв) — её перекрывает '''тишина''' (вызывающий разносит контент на монтажном стыке). Между резами карта монотонна (наклон <math display="inline">\le</math><code>VIS_SMAX</code>). Выход: <code>(grid, tg_s, cuts, o, w, curve)</code> — <code>tg_s</code> идёт в ресэмпл аудио, <code>o/w/curve</code> — в графики, <code>cuts</code> — в тишину.
 
{{#mermaid:
flowchart TB
    PRED[pred / asg / cos] --> OW["vision_ow → (o, w) на сетке T"]
    OW --> WC[warp_curve: DP-сегментация ломаными]
    WC --> VCF[video_cut_filter:<br/>устойчивость уровня ±80с, вес-гейт,<br/>окно по соседним резам]
    OW --> PL[piecewise_lines:<br/>робастная прямая между резами]
    VCF --> PL
    PL --> MAP["build_map → tg_s + cuts"]
    MAP --> SIL[Тишина в резах]
    MAP --> RES[Ресэмпл аудио дубля на сетку рефа]
}}
'''Итог Части I.''' Зрение превращает два видеофайла в карту <math display="inline">\tau(t)</math> «время рефа → время дубля» — монотонную кусочно-линейную, с явными монтажными резами, устойчивую к двойкам, лого и слепым зонам. По этой карте аудио дубля ресэмплится на таймлайн рефа; в резах ставится тишина. Дальше — слой звука.
 
 
-----
 
<span id="часть-ii-звук--доводка-bandmuq-поверх-зрения"></span>
= Часть II. Звук — доводка band/muq поверх зрения =
 
Файлы: <code>anchor/apply.py</code> (оркестровка), <code>anchor/maps/band.py</code> (метрика), <code>anchor/detect.py</code> (общий аппарат детекта, тот же, что у зрения), <code>anchor/assemble.py</code>. Метод по умолчанию — '''band''' (DSP, 48 полос, без модели/лицензии); альтернатива '''muq''' (GPU-эмбеддинги, та же сборка).
 
<a name="7-зачем-аудио-после-видео"></a>
 
<span id="7-зачем-аудио-после-видео"></span>
== 7. Зачем аудио после видео ==
 
Зрение положило дубль по картинке. Но между '''видео и аудио внутри самого файла''' бывает сдвиг, которого на картинке не видно:
 
* '''AV-sync delta''' — разный AAC priming / encoder delay (в JoJo измерено стабильные +89 мс между файлами, <code>BREAKTHROUGH_VIDEO_SYNC.md §4.4</code>);
* '''студийный сдвиг M&amp;E''' — студия подвинула фон/музыку в миксе относительно своего видео;
* '''двойки''' — видео физически не различает соседние одинаковые кадры (предел ±83 мс), аудио снимает это ограничение.
 
Поэтому второй слой меряет '''остаточный''' сдвиг аудио дубля (уже лежащего по видео-карте) относительно аудио рефа и доводит его. Важно: аудио-слой работает '''после''' простановки тишины в монтажных резах — он должен видеть реф/тишину в вырезе (как боевой эталон), а не сырой несинхронный дубль, иначе на стыке зрение↔аудио появляется ложный краевой рез.
 
<a name="8-принцип-зрячих-свидетелей"></a>
 
<span id="8-принцип-зрячих-свидетелей"></span>
== 8. Принцип зрячих свидетелей ==
 
Отчёт <code>METHODOLOGY_audio_matching.md</code> фиксирует намертво: '''наивная корреляция звука слепа''', и это легко принять за «аудио измерить нельзя». Озвучка = чужой голос поверх той же музыки и эффектов (M&amp;E). Прямой NCC сырого сигнала ловит несовпадение голоса и возвращает шум. Лечится '''не другим инструментом, а изоляцией инварианта ПЕРЕД корреляцией'''. У каждого искажения дубляжа есть то, что оно не трогает:
 
{| class="wikitable"
|-
! Искажение дубляжа
! Что губит
! Инвариант (что не трогает)
! Как изолируем
|-
| Чужой голос диктора
| часть частотных полос
| чистые полосы M&amp;E
| '''48 лог-полос + взвеш. медиана'''
|-
| Другой мастеринг/тембр
| тонкую структуру волны
| динамику громкости
| '''лог-огибающая RMS'''
|-
| Повтор музыкального такта
| однозначность пика
| окна с одиночным пиком
| '''comb-veto'''
|}
 
Свидетели разной физики теряют чувствительность в '''разных''' местах → их сумма покрывает всю длину. Ниже подробно про основной свидетель боевого band — '''многополосную метрику''' (она же несёт оба первых инварианта: полосы против голоса, лог-энергия против мастеринга).
 
<div style="border:2px dashed #b8860b; background:#fffdf3; padding:6px 12px; margin:10px 0;">
🖼️ '''ИЗОБРАЖЕНИЕ 8.1 — «Почему полосы видят сквозь голос».''' Спектрограмма окна озвучки: горизонтальными лентами выделены 48 лог-полос; полосы, занятые голосом диктора (≈300–3000 Гц), подсвечены красным («низкий контраст, малый вес»), полосы чистой музыки/эффектов — зелёным («высокий контраст, голосуют»). Снизу — та же сцена в рефе. Подпись: «Взвешенная медиана по полосам игнорирует испорченные голосом полосы — без знания, где голос».
</div>
<a name="9-band-метрика-48-полос"></a>
 
<span id="9-band-метрика-48-полос"></span>
== 9. band-метрика: 48 полос ==
 
Файл <code>anchor/maps/band.py</code>. Рецепт <code>NB48</code>: <math display="inline">sr=16000</math>, <math display="inline">NB=48</math> лог-полос в диапазоне <math display="inline">50\text{–}14000</math> Гц, окно <code>win=5</code> с, STFT <code>nfft=2048</code>, <code>hop=256</code> (⇒ кадровый темп огибающей <math display="inline">\text{fps}=sr/hop=62.5</math> Гц), агрегация <code>wmedian</code>, качество <code>agree</code>.
 
<span id="91-полосовые-огибающие-_benv"></span>
=== 9.1. Полосовые огибающие (<code>_benv</code>) ===
 
<div style="border-left:5px solid #3a86ff; background:#eef3ff; padding:6px 12px; margin:10px 0;">
'''Отступление: STFT и полосы.''' STFT (кратковременное преобразование Фурье) режет звук на короткие кусочки и для каждого показывает, сколько в нём энергии на каждой частоте, — получается спектрограмма «время × частота». Мы группируем частоты в 48 логарифмических полос (как деления на эквалайзере) и следим за громкостью каждой полосы во времени — это и есть «огибающая полосы». Лог-шкала частот ближе к слуху: низкие частоты дробятся мельче, высокие — крупнее.
</div>
Для окна сигнала <math display="inline">x</math> берём STFT и мощность <math display="inline">P=|Z|^2</math>. Лог-частотная '''полосовая маска''' <math display="inline">BM\in\{0,1\}^{NB\times(nfft/2+1)}</math> (границы — <code>logspace(50,14000,49)</code>) суммирует мощность по полосам:
 
<math display="block">
E_{b,t}=\sum_f BM_{b,f}\,P_{f,t},\qquad
\tilde E_{b,t}=\log(1+E_{b,t}).
</math>
 
Каждая полоса z-нормируется по времени (убираем общий уровень и масштаб — это инвариант к мастерингу):
 
<math display="block">
\hat E_{b,t}=\frac{\tilde E_{b,t}-\operatorname{mean}_t \tilde E_b}{\operatorname{std}_t \tilde E_b+\varepsilon}.
</math>
 
<code>log1p</code> + z-score — это и есть «лог-огибающая громкости полосы»: динамика, общая у дубля и рефа, остаётся; тембр выкидывается.
 
<span id="92-по-полосный-лаг-через-кросс-корреляцию-_om_core"></span>
=== 9.2. По-полосный лаг через кросс-корреляцию (<code>_om_core</code>) ===
 
Для каждой полосы независимо считаем кросс-корреляцию огибающих рефа <math display="inline">\hat E^{r}_b</math> и дубля <math display="inline">\hat E^{d}_b</math> по времени (через FFT, знак <math display="inline">E_d\cdot\overline{E_r}</math> даёт «правее=+»):
 
<math display="block">
\text{cc}_{b}[\ell]=\big(\hat E^{d}_b \star \hat E^{r}_b\big)[\ell],\qquad
\ell\in[-M_f,\,M_f],\ \ M_f=\text{maxlag}\cdot\text{fps}.
</math>
 
Выраженность пика полосы (насколько он торчит над фоном):
 
<math display="block">
\text{prom}_b=\max_\ell \text{cc}_b[\ell]-\operatorname{median}_\ell \text{cc}_b[\ell].
</math>
 
<span id="93-агрегация-взвешенная-медиана-по-полосам-aggwmedian"></span>
=== 9.3. Агрегация: взвешенная медиана по полосам (<code>agg=&quot;wmedian&quot;</code>) ===
 
Вместо суммы корреляций (её подавляет голос) — '''по-полосный аргмакс-лаг''' и '''взвешенная медиана''' этих лагов по 48 полосам, вес = <math display="inline">\text{prom}_b^{\text{promp}}</math> (<math display="inline">\text{promp}=1</math>):
 
<math display="block">
\text{bsh}_b=\arg\max_\ell \text{cc}_b[\ell],\qquad
\hat\ell=\operatorname{wmedian}\big(\{\text{bsh}_b\},\ \{\text{prom}_b\}\big).
</math>
 
Медиана робастна: даже если голос испортил треть полос и они показывают «не туда», медиана держится за большинство чистых. Финальный сдвиг уточняется '''параболической интерполяцией''' вокруг <math display="inline">\hat\ell</math> по агрегированной поверхности и переводится в кадры:
 
<math display="block">
\text{off}=\frac{\hat\ell-\tfrac12\frac{y_{+}-y_{-}}{y_{+}-2y_0+y_{-}}}{\text{fps}}\cdot\frac{1000}{\text{FRAME}}\ \text{[кадры]}.
</math>
 
<span id="94-качество--согласие-полос-qualityagree"></span>
=== 9.4. Качество = согласие полос (<code>quality=&quot;agree&quot;</code>) ===
 
Уверенность узла — '''доля полос, чей собственный аргмакс-лаг согласен''' с итоговым сдвигом (в пределах <code>tol=2</code> кадра), взвешенная по выраженности:
 
<math display="block">
w=\frac{\sum_b \text{prom}_b\cdot\mathbf{1}\big[|\text{bsh}_b-\text{off}|\le 2\big]}{\sum_b \text{prom}_b+\varepsilon}.
</math>
 
Это самопроверка без внешней истины: если полосы единодушны — узлу можно верить; если разбрелись (повтор/тишина/голос везде) — <math display="inline">w</math> мал, и детект/укладка такой узел не слушают.
 
{{#mermaid:
flowchart LR
    X[Окно 5с ref и dub] --> ST[STFT 2048/256]
    ST --> BM[48 лог-полос<br/>log1p + z-score по времени]
    BM --> CC[По-полосная кросс-корр<br/>cc_b lag]
    CC --> PR[prom_b = max − median]
    CC --> BSH[bsh_b = argmax lag]
    BSH --> WM["wmedian по полосам (вес prom)"]
    PR --> WM
    WM --> OFF[off: сдвиг кадры + парабола]
    BSH --> AG["agree: доля согласных полос → w"]
    OFF --> AG
}}
<div style="border-left:5px solid #aaa; background:#f6f6f6; padding:6px 12px; margin:10px 0;">
'''muq как альтернатива.''' Метод <code>muq</code> заменяет полосовую огибающую на GPU-эмбеддинги музыкальной модели, но '''дальше всё то же''' — та же поверхность <code>(o,w)</code>, тот же детект <code>detect.py</code>, та же сборка <code>assemble.py</code>. band не требует новых зависимостей (только torch/numpy) и потому выбран дефолтом.
</div>
<a name="10-грубая-off0--следящий-band"></a>
 
<span id="10-грубая-off0--следящий-band"></span>
== 10. Грубая off0 + следящий band ==
 
Рабочее окно band узкое — <math display="inline">\pm</math><code>MAXLAG=0.7</code> с (точность ценой диапазона). Но сдвиг опенинга бывает крупнее. Поэтому, как и в зрении, две стадии: грубая «тропа» + точное слежение (<code>anchor/apply.py</code>).
 
<span id="101-грубая-off0-_coarse_off0"></span>
=== 10.1. Грубая off0 (<code>_coarse_off0</code>) ===
 
Та же band-метрика, но с '''широким''' окном лага <math display="inline">\pm</math><code>COARSE_LAG_S=2.5</code> с — она видит большой сдвиг. Робастность только статистикой, без порогов под контент:
 
# '''страж края''' — если аргмакс на границе <math display="inline">\pm2.5</math> с, это «нет пика», вес обнуляется;
# скользящая '''взвешенная медиана''' (окно <code>med_win_s=5</code> с) сдвига;
# '''гейт согласия соседей''' — узел стабилен, если <math display="inline">|o-\text{med}|\le</math><code>stab_tol=4</code> кадра;
# повторная медиана только по стабильным + интерполяция → непрерывная <code>off0(t)</code>.
 
<span id="102-слежение-bandbuild_arr-07с"></span>
=== 10.2. Слежение (<code>band.build_arr ±0.7с</code>) ===
 
Дубль предварительно варпится по <code>off0</code> (грубая коррекция), и band с '''узким''' окном <math display="inline">\pm0.7</math> с меряет '''остаток''' на пред-варпленном дубле. Полный сдвиг — сумма:
 
<math display="block">
o(t)=\text{off0}(t)+o_{\text{ост}}(t).
</math>
 
Так узкое точное окно никогда не «теряет» крупный сдвиг — он уже снят грубой тропой, а band доводит лишь малый остаток. (Урок <code>EXPERIMENTS_coarse_band_follow.md</code>: GCC в грубом проходе подавляется голосом, band-грубый с медианой по полосам — нет.)
 
<a name="11-детект-ступеней-и-кривая-дрейфа"></a>
 
<span id="11-детект-ступеней-и-кривая-дрейфа"></span>
== 11. Детект ступеней и кривая дрейфа ==
 
<span id="111-детект--тот-же-аппарат-что-у-зрения-detectdetect"></span>
=== 11.1. Детект — тот же аппарат, что у зрения (<code>detect.detect</code>) ===
 
<code>(o,w)</code> идёт в '''тот же''' <code>warp_curve</code> (DP-сегментация взвешенными ломаными, §6.2), затем:
 
* '''<code>tail_filter</code>''' — рез реален только при устойчивом сдвиге '''хвоста''': медиана уровня до/после на окне <math display="inline">\pm</math><code>win=25</code> с, ограниченном соседними резами, с вес-гейтом; величина реза = это смещение. Аналог <code>video_cut_filter</code> из зрения, но для аудио.
* '''<code>_edge_refine</code>''' — отдельно выделяет рез в первом/последнем сегменте (краевая зона, короче <code>msize</code>), где обычная сегментация его не выделит.
 
<span id="112-кривая-дрейфа-надараяуотсон--потолок-скорости-_smooth_drift_curve"></span>
=== 11.2. Кривая дрейфа: Надарая–Уотсон + потолок скорости (<code>_smooth_drift_curve</code>) ===
 
<div style="border-left:5px solid #3a86ff; background:#eef3ff; padding:6px 12px; margin:10px 0;">
'''Простыми словами: сглаживание Надарая–Уотсона.''' Это «скользящее среднее с приоритетом надёжным точкам»: значение кривой в каждый момент — среднее соседних измерений, но уверенные точки (большой вес <math display="inline">w</math>) тянут сильнее, а шумные почти не влияют. Ширина «гауссова окна» <math display="inline">\sigma</math> задаёт, насколько далёких соседей ещё учитывать.
</div>
Внутри куска между резами строим гладкую кривую, которая '''следит за горками''' (плавный дрейф) и '''стоит на шуме'''. Это ядерное сглаживание Надарая–Уотсона с весом <math display="inline">w^{\text{qpow}}</math> (<math display="inline">\text{qpow}=3</math>, гауссово ядро <math display="inline">\sigma=3</math> с):
 
<math display="block">
\text{sm}(t)=\frac{\big(o\cdot w^{3}\big)\!*\!G_\sigma}{w^{3}\!*\!G_\sigma+\varepsilon}.
</math>
 
Поверх — '''потолок скорости изменения сдвига''': между соседними узлами <math display="inline">|\Delta\text{sm}|</math> не больше <code>lim</code>, что соответствует ускорению <math display="inline">\le</math><code>max_pct_s</code> (1.25 %/с = <code>SMAX</code>):
 
<math display="block">
\text{lim}=\frac{\text{max\_pct\_s}}{100}\cdot\text{STEP}\cdot\frac{1000}{\text{FRAME}}\approx0.15\ \text{кадра/узел}.
</math>
 
Потолок не даёт кривой прыгнуть за единичными яркими выбросами в слепой зоне.
 
<span id="113-опора-после-реза-фикс-2026-06-18"></span>
=== 11.3. Опора после реза (фикс 2026-06-18) ===
 
Тонкость: в куске, '''который начинается с реза''', band теряет чувствительность на самом монтажном стыке (тусклые выбросы сразу за резом). Если вести потолок скорости от левого края куска, он занижает старт, и кривая ~35 с медленно вытягивается к плато. Идея пользователя: расходиться в '''обе стороны от самого уверенного якоря''' в первых <code>anchor_lookback_s=30</code> с куска (= начало плато):
 
<pre>  сдвиг
    ▲                  ● ● ● ● ● ● ●  ← плато (уверенные якоря)
    │      опора ↑ argmax w в первых 30с
    │    ╱  ╲        clamp влево к резу + вправо вдоль плато
    │  ✗ ╱    ╲
    │ слепой   
    │ старт     
    ┼──┊─────────────────────────────►  t
        рез</pre>
Слепой старт подтягивается '''к''' плато, а не плато к старту. Первый кусок (без реза слева) — по-старому: левый край = опора. Приёмка: 47/55 дорожек не тронуты, изменения = только исправления (память <code>project_band_postcut_anchor_fix</code>).
 
<a name="12-варп-тишина-в-резах-заполнение-рефом"></a>
 
<span id="12-варп-тишина-в-резах-заполнение-рефом"></span>
== 12. Варп, тишина в резах, заполнение рефом ==
 
<span id="121-кусочный-варп-_warp_piecewise"></span>
=== 12.1. Кусочный варп (<code>_warp_piecewise</code>) ===
 
Дубль ресэмплится по кускам между резами: внутри куска — гладкая <code>wcurve</code> (горки), на резе — резкий стык. Для выходного отсчёта <math display="inline">t</math> источник в дубле:
 
<math display="block">
\text{src}(t)=\Big(t+\text{wcurve}(t)\cdot\tfrac{\text{FRAME}}{1000}\Big)\cdot sr,\qquad
\text{out}[t]=\operatorname{interp}\big(\text{src}(t)\big).
</math>
 
<span id="122-тишина-в-резах"></span>
=== 12.2. Тишина в резах ===
 
Непрерывный варп на монтажном стыке «переиграл» бы звук дважды, поэтому в резах ставится тишина (контент разносится):
 
* <math display="inline">\Delta<0</math> (нехватка дубля) → тишина <math display="inline">|\Delta|</math> по центру реза;
* <math display="inline">\Delta>0</math> (лишнее вырезано) → узкий шов <math display="inline">\pm0.15</math> с.
 
<span id="123-заполнение-тишины-синхронным-рефом-_fill_silence_from_ref"></span>
=== 12.3. Заполнение тишины синхронным рефом (<code>_fill_silence_from_ref</code>) ===
 
Финальный проход — '''только после''' полного band/muq, когда дорожка уже синхронна рефу. Там, где озвучка молчит, а реф звучит, подставляем реф с кроссфейдом. Порог тишины измерен:
 
<math display="block">
\text{RMS}_{\text{dBFS}}=20\log_{10}\!\Big(\tfrac{\text{RMS}}{32768}+\varepsilon\Big),\qquad
\text{fill}\iff \text{RMS}_{\text{дубль}}<-90\ \text{дБ}\ \wedge\ \text{RMS}_{\text{реф}}\ge-90\ \text{дБ}.
</math>
 
Порог <math display="inline">-90</math> дБ — в центре измеренного плато настоящих дыр (ниже <math display="inline">-80</math>), далеко от обрыва тихого контента (<math display="inline">-40\ldots-60</math>), окно RMS 20 мс, мин. длина зоны 0.15 с, кроссфейд 30 мс. Где у обоих тишина — остаётся тишина; озвучка не теряется (трогаем только пустоты).
 
<div style="border-left:5px solid #e8a317; background:#fff9e6; padding:6px 12px; margin:10px 0;">
⚠ '''Запрет (намертво).''' <code>fill_cuts</code> — заливка вырезов рефом '''по видео, до''' анализа — запрещён навсегда: вставка в ещё несинхронную дорожку плодит мнимый рассинхрон. Разрешено только <code>fill_silence</code> — заполнение '''после''' выравнивания, по синхронной дорожке (память <code>feedback_no_audio_replacement_fill_cuts</code>).
</div>
<span id="124-кросс-модальный-детектор-студийного-av-десинка-detect_av_desync"></span>
=== 12.4. Кросс-модальный детектор студийного A/V-десинка (<code>detect_av_desync</code>) ===
 
Read-only диагностика (на wav не влияет). После укладки <code>out</code> уже на сетке рефа; широким PHAT-окном (<math display="inline">\pm2.5</math> с, полоса 50–13500 Гц) меряем остаточный лаг M&amp;E к реф-аудио по окнам. Если он '''большой и устойчивый''' (медиана велика, MAD мал) — аудио источника смещено относительно его же видео (студийный дефект, надёжно не чинится) → дорожка помечается '''красным'''. Паттерн валидирован: AniLibria ep01 kamennyj-okean медиана −40к / MAD 2.2к против ~0/~0 у 10 здоровых.
 
{{#mermaid:
flowchart TB
    SIL[Дубль на сетке рефа + тишина в резах] --> OFF0[_coarse_off0<br/>band ±2.5с, медиана по полосам]
    OFF0 --> PRE[Пред-варп по off0]
    PRE --> BAND["band ±0.7с → o_ост, w"]
    BAND --> SUM["o = off0 + o_ост"]
    SUM --> DET[detect.detect<br/>warp_curve + tail_filter + edge_refine]
    DET --> DRIFT[_smooth_drift_curve<br/>Надарая–Уотсон + потолок 1.25%/с + опора-после-реза]
    DRIFT --> WP[_warp_piecewise + тишина в резах]
    WP --> FILL[_fill_silence_from_ref −90дБ]
    FILL --> AV[detect_av_desync<br/>красный флаг студийного десинка]
}}
'''Итог Части II.''' Поверх видео-карты звук band/muq снимает остаточный сдвиг аудио, который видео не видит: многополосная метрика делает корреляцию зрячей сквозь голос и мастеринг, грубая off0 ловит крупный сдвиг, следящий band доводит остаток, общий аппарат <code>detect.py</code> ставит резы, кривая дрейфа с потолком скорости и опорой-после-реза кладёт гладко, тишина заполняется синхронным рефом. Дальше — как всё это читается на графиках.
 
 
-----
 
<span id="часть-iii-графики--диагноз-с-одного-взгляда"></span>
= Часть III. Графики — диагноз с одного взгляда =
 
Файл <code>anchor/plots_unified.py</code>. Проектная установка: алгоритмы доведены до состояния, где '''качество читается по графику, без ручной сверки в Premiere'''. Беглый взгляд на любой график даёт чёткий ответ «всё в порядке или нет». Поэтому график — не украшение, а главный инструмент приёмки и отладки.
 
<a name="13-зачем-графики"></a>
 
<span id="13-зачем-графики-и-что-на-них-видно"></span>
== 13. Зачем графики и что на них видно ==
 
Единый график строится '''на каждую пару''' и показывает оба слоя сразу: '''верхняя панель — зрение''' (видео-укладка, всегда), '''нижняя — звук''' (доводка band/muq, если включена), на общей оси времени с синхронным зумом и ховером. Один взгляд отвечает на четыре вопроса:
 
{| class="wikitable"
|-
! Вопрос
! Где смотреть
|-
| Держится ли укладка за '''надёжные''' данные?
| цвет точек (уверенность): линия должна идти по ярким, не по тусклым
|-
| Есть ли необъяснённый '''перекос/увод'''?
| отклонение линии от облака точек
|-
| Верно ли найдены '''монтажные резы'''?
| вертикальные пунктиры и их подписи (мс)
|-
| Согласны ли '''зрение и звук'''?
| сравнение двух панелей в одной зоне
|}
 
<a name="14-дрейф-форма"></a>
 
<span id="14-дрейф-форма-и-робастная-база-тейласена"></span>
== 14. Дрейф-форма и робастная база Тейла–Сена ==
 
Сырой сдвиг по всей серии может быть сотни кадров (крупный масштаб оси), и на нём не видно ни плато, ни ступеней в десяток кадров. Поэтому обе панели рисуются в '''дрейф-форме''' — вычитается робастная линейная база, и всё разворачивается вокруг нуля.
 
<div style="border-left:5px solid #3a86ff; background:#eef3ff; padding:6px 12px; margin:10px 0;">
'''Отступление: база Тейла–Сена.''' Чтобы провести «среднюю линию» через облако точек и не дать единичным выбросам её перекосить, берут наклон между каждой парой точек и выбирают медианный — половина пар «за», половина «против». Это устойчивее обычной прямой и не требует никаких порогов отбраковки.
</div>
База оценивается '''методом Тейла–Сена''' (<code>_baseline</code>) — он устойчив к выбросам, в отличие от МНК: на выборке точек (до 400) берутся все попарные наклоны, и базовый наклон — их медиана; сдвиг — медиана остатков:
 
<math display="block">
a=\operatorname{median}_{i<j}\frac{y_j-y_i}{t_j-t_i},\qquad
b=\operatorname{median}_k\big(y_k-a\,t_k\big).
</math>
 
База считается по '''телу''' дорожки (исключая голову/заставку: <math display="inline">t>\text{head}+5</math> с). Затем:
 
<math display="block">
\text{dev}_{\text{якорь}}=\text{shift}-(a\,t+b),\qquad
\text{dev}_{\text{кривая}}=\text{curve}-(a\,T+b),
</math>
 
а предел оси берётся как <math display="inline">\max(12,\ p_{99}(|\text{dev}|)+4)</math> кадров — авто-масштаб под реальный разброс. На резах в линию вставляется <code>NaN</code> (<code>_breaks</code>), чтобы она '''не соединяла''' куски через разрыв — ступень видна как обрыв, а не как вертикаль.
 
<div style="border:2px dashed #b8860b; background:#fffdf3; padding:6px 12px; margin:10px 0;">
🖼️ '''ИЗОБРАЖЕНИЕ 14.1 — «Сырой сдвиг vs дрейф-форма».''' Два графика друг над другом для одной дорожки: сверху сырой <code>shift(t)</code> (линия уходит на −600 кадров, мелкие детали не видны); снизу та же дорожка в дрейф-форме (база вычтена, всё колеблется в ±15 кадров вокруг 0, видны и плато, и ступенька реза). Подпись: «Дрейф-форма вытаскивает структуру, которую крупный масштаб прячет».
</div>
<a name="15-слои-и-разбор-кейсов"></a>
 
<span id="15-слои-цветокодирование-разбор-реальных-кейсов"></span>
== 15. Слои, цветокодирование, разбор реальных кейсов ==
 
<span id="151-слои-единого-графика"></span>
=== 15.1. Слои единого графика ===
 
'''Верхняя панель (ЗРЕНИЕ):'''
 
* точки якорей <math display="inline">(t_{\text{реф}},\ \text{dev}_{\text{якорь}})</math>, цвет = '''cos''' (viridis, 0…1) — надёжность каждого видео-якоря;
* оранжевая линия — '''карта зрения''' в дрейф-форме, с разрывами на резах;
* crimson-пунктиры — '''резы зрения''' с подписью величины (мс);
* заголовок несёт абсолютную медиану сдвига.
 
'''Нижняя панель (ЗВУК):'''
 
* точки <math display="inline">(T,\ o)</math>, цвет = '''w''' (cividis, 0…1.2) — уверенность band;
* серые точки — '''gcc-свидетель''' (<math display="inline">\pm2.5</math> с), справочно;
* зелёная линия — '''кривая band''' (дрейф), с разрывами на резах;
* purple-пунктиры — '''резы детекта''' аудио.
 
<div style="border-left:5px solid #e8a317; background:#fff9e6; padding:6px 12px; margin:10px 0;">
⚠ '''gcc — НЕ судья.''' Серая gcc-линия на контенте проекта (закадр+музыка) почти слепа и скачет ±40к = шум; её нельзя брать за подтверждение укладки. Судьи сходимости — сам band (48 полос) / muq + уши/глаза на наложенных дорожках (память <code>feedback_gcc_ncc_blind_judge</code>). gcc на графике — фон, не вердикт.
</div>
<span id="152-как-цвет-читается-мгновенно"></span>
=== 15.2. Как цвет читается мгновенно ===
 
Цвет точки = уверенность. Тёмные (фиолетовые/синие) — шумные, ярких (жёлтые/зелёные) — надёжные. '''Правильная линия идёт по ярким точкам.''' Если линия отклонилась, а рядом плотное облако ярких — это сигнал проблемы: либо увод укладки, либо линия последовала за тусклыми выбросами на краю. Это и есть «диагноз с одного взгляда».
 
<div style="border:2px dashed #b8860b; background:#fffdf3; padding:6px 12px; margin:10px 0;">
🖼️ '''ИЗОБРАЖЕНИЕ 15.1 — «Эталон единого графика (здоровая дорожка)».''' Реальный скрин двухпанельного графика: вверху зрение — плотное жёлтое облако якорей точно на 0, оранжевая линия плоская, резов нет; внизу звук — узкое облако вокруг 0, зелёная линия плоская. Подпись: «Так выглядит ОК: линии по ярким точкам, ноль отклонения, нет резов». ''(Скрин взять из <code>&lt;серия&gt;/_plots/&lt;stem&gt;__track.png</code> любой здоровой пары.)''
</div>
<div style="border:2px dashed #b8860b; background:#fffdf3; padding:6px 12px; margin:10px 0;">
🖼️ '''ИЗОБРАЖЕНИЕ 15.2 — «Перекос укладки (до фикса опоры-после-реза)».''' Скрин зоны 180–220 с AniDUB: после реза линия band провалилась вниз за парой тусклых (тёмных) якорей у края, хотя выше плотное плато ярких якорей на другом уровне. Рядом — тот же участок после фикса: линия держится за плато (опора = увереннейший якорь начала плато), слепой старт подтянут к плато. Подпись: «Линия не должна вестись за тусклыми единицами на краю».
</div>
<div style="border:2px dashed #b8860b; background:#fffdf3; padding:6px 12px; margin:10px 0;">
🖼️ '''ИЗОБРАЖЕНИЕ 15.3 — «Ложный рез из-за слепоты зрения (ep05 Amazing, 950–979 с)».''' Скрин зоны с двумя близкими резами: до фикса между ними узкий клин и лишний рез (окно проверки ±80 с перехлестнуло через соседний рез и поймало чужой уровень); после фикса (окно ограничено соседними резами) лишний рез убран. Подпись: «Окно проверки уровня ограничивается соседними резами — иначе чужой уровень ложно подтверждает рез».
</div>
<span id="153-носители-png-превью-и-интерактивный-html"></span>
=== 15.3. Носители: PNG-превью и интерактивный HTML ===
 
* '''PNG''' (<code>matplotlib</code>, Agg) — статичная миниатюра (1 или 2 панели) для быстрого взгляда в панели/отчёте.
* '''HTML''' (<code>plotly</code>, self-contained, открывается офлайн) — для детального разбора. Точки и линия в WebGL (<code>scattergl</code>), чтобы линия рисовалась поверх точек независимо от порядка; ховер <code>x unified</code>, '''зум любой зоны вручную'''. Отдельные PNG каждого реза не создаём — HTML позволяет приблизить любой стык (решение 2026-06-18).
 
Read-only по контракту: падение рендера не роняет conform (ловит вызывающий).
 
 
-----
 
<span id="приложения"></span>
= Приложения =
 
<a name="приложение-а-константы"></a>
 
<span id="приложение-а-константы-и-их-обоснования"></span>
== Приложение А. Константы и их обоснования ==
 
Все ключевые числа калиброваны не «на глаз», а по '''плато нечувствительности''': на свипе по диапазону результат (резы по датасету) не меняется — значит подстройки под контент нет.
 
<span id="зрение"></span>
=== Зрение ===
 
{| class="wikitable"
|-
! Константа
! Значение
! Файл
! Смысл / обоснование
|-
| <code>GW×GH</code>
| 128×72
| features
| размер серого кадра под SRM
|-
| clip <code>T</code>
| ±3
| features
| обрезка выбросов отклика фильтров
|-
| dim
| 18432
| features
| <math display="inline">2\cdot128\cdot72</math>, два канала
|-
| <code>K</code> (coarse)
| 8
| coarse
| прорежение грубого прохода
|-
| <code>W</code>
| 80
| coarse
| полуокно зануления при поиске 2-го пика
|-
| <code>VHI</code>
| 0.60
| coarse
| порог «пик высокий» (надёжный якорь)
|-
| <code>CMIN</code>
| 0.12
| coarse
| порог различимости (пик одинок → не двойка)
|-
| <code>DROP</code>
| 0.20
| dropdtw
| штраф выброса (базовый Drop-DTW)
|-
| <code>OPEN/EXT</code>
| 0.20 / 0.02
| dropdtw
| gap-open / gap-extend (острый шов)
|-
| <code>DSYN</code>
| 0.30
| dropdtw/align
| плоская цена выброса кадра дубля (вставка)
|-
| <code>MATCH_THR</code>
| 0.30
| dropdtw/align
| guard: кадр с матчем лучше этого нельзя выбросить
|-
| <code>MARG</code>
| 120
| band_align
| полуширина следящей полосы (≈5 с)
|-
| <code>CHUNK/OVERLAP</code>
| 4000 / 700
| band_align
| кусок Drop-DTW и перекрытие
|-
| <code>VIS_WIN</code>
| 0.3
| vision_detect
| окно агрегации якорей (плато 0.2–0.5)
|-
| <code>VIS_PEN</code>
| 800
| vision_detect
| штраф DP-сегментации (плато 200–3000)
|-
| <code>VIS_QPOW</code>
| 3.0
| vision_detect
| степень веса = (cos·agree)³ (плато 2–3)
|-
| <code>VIS_SMAX</code>
| 0.45
| vision_detect
| потолок наклона прямых, к/с (плато 0.3–0.6)
|-
| <code>VIS_MSIZE</code>
| 30
| vision_detect
| мин. длина сегмента, с (плато 8–60)
|-
| <code>VC_WIN_S</code>
| 80
| vision_detect
| окно проверки устойчивости уровня (плато 45–120)
|-
| <code>VC_GATE</code>
| 0.55
| vision_detect
| вес-гейт надёжных узлов (плато 0.3–0.8)
|-
| <code>VC_THR</code>
| 200 мс
| vision_detect
| порог величины реза (плато 60–400 мс)
|}
 
<span id="звук"></span>
=== Звук ===
 
{| class="wikitable"
|-
! Константа
! Значение
! Файл
! Смысл / обоснование
|-
| <code>sr</code>
| 16000
| band
| частота анализа
|-
| <code>NB</code>
| 48
| band
| число лог-полос (50–14000 Гц)
|-
| <code>nfft/hop</code>
| 2048 / 256
| band
| STFT (темп огибающей 62.5 Гц)
|-
| <code>win</code>
| 5 с
| band
| окно анализа
|-
| <code>MAXLAG</code>
| 0.7 с
| band
| рабочее окно лага (точность)
|-
| <code>COARSE_LAG_S</code>
| 2.5 с
| apply
| окно грубой off0 (видит сдвиг опенинга)
|-
| <code>tol</code>
| 2 кадра
| band
| допуск согласия полос (agree)
|-
| <code>FRAME</code>
| 41.708 мс
| params
| мс/кадр (23.976 fps)
|-
| <code>STEP</code>
| 0.5 с
| params
| шаг сетки T
|-
| <code>MIN_FR</code>
| 3 (120 мс)
| params
| порог величины реза
|-
| <code>PEN</code>
| 700
| params
| штраф DP-сегментации (детект)
|-
| <code>QPOW</code>
| 3.0
| params
| степень веса якоря
|-
| <code>SMAX</code>
| 0.3
| params
| макс. наклон сегмента (≤~2%)
|-
| <code>MSIZE_S</code>
| 20 с
| params
| мин. длина сегмента
|-
| <code>max_pct_s</code>
| 1.25 %/с
| apply
| потолок скорости кривой дрейфа
|-
| <code>anchor_lookback_s</code>
| 30 с
| apply
| окно поиска опоры-после-реза
|-
| <code>SIL_FILL_DB</code>
| −90 дБ
| align
| порог тишины озвучки (центр плато дыр)
|-
| <code>XFADE</code>
| 30 мс
| align
| кроссфейд заполнения рефом
|}
 
<div style="border-left:5px solid #e8a317; background:#fff9e6; padding:6px 12px; margin:10px 0;">
⏱ '''Правило прогонов''' (память <code>feedback_warn_long_runs</code>): любой тест ≤10 мин — параллелить (клипы <code>--workers 8</code>, конформы ×6 пар, синтетика <code>--workers 24</code>) или резать объём; дольше 10 мин — только с явного согласия пользователя, оценку времени — заранее.
</div>
<a name="приложение-б-карта-файлов"></a>
 
<span id="приложение-б-карта-файлов"></span>
== Приложение Б. Карта файлов ==
 
<pre>src/track_muxer/conform/
├── features.py              SRM-отпечатки кадров (KB/D1, L2, dim 18432)
├── kernel/
│  ├── coarse.py            грубый проход: G=cs·crᵀ, якоря, LIS, оконный
│  ├── dropdtw.py          Drop-DTW (base/affine/guard/free_start)
│  └── band_align.py        следящая полоса + склейка кусков
├── vision_detect.py        УКЛАДКА зрения: (o,w) → детект → ломаная (ЕДИНСТВЕННАЯ карта)
├── align.py                оркестровка пары: pred → разбор правок → ресэмпл → звук → график
└── anchor/                  АУДИО-доводка band/muq поверх зрения
    ├── apply.py            coarse_off0, следящий band, smooth_drift_curve, варп, fill
    ├── detect.py            ОБЩИЙ аппарат: wfit, warp_curve, tail_filter, edge_refine
    ├── assemble.py          freeze + сборка варпа
    ├── params.py            FRAME, T, STEP, PEN, QPOW, SMAX, MSIZE_S …
    ├── maps/band.py        48-полосная DSP-метрика (без модели) — дефолт
    ├── maps/muq.py          GPU-эмбеддинги (альтернатива)
    ├── plots_unified.py    ЕДИНЫЙ график зрение+звук (PNG + HTML)
    └── plots_html.py        отдельный HTML band (легаси-путь)</pre>
{| class="wikitable"
|-
! Тема
! Отчёт (суть)
|-
| Прорыв видео-синхрона
| <code>doc/reports/audio_align/BREAKTHROUGH_VIDEO_SYNC.md</code>
|-
| Методика зрячих свидетелей аудио
| <code>doc/reports/audio_bench/METHODOLOGY_audio_matching.md</code>
|-
| Грубый→band-follow
| <code>doc/reports/audio_bench/EXPERIMENTS_coarse_band_follow.md</code>
|-
| Аудио-аанкер (band/muq)
| <code>doc/reports/audio_bench/EXPERIMENTS_audio_auto.md</code>
|-
| Архитектура conform
| <code>doc/conform_architecture_audit.md</code> §14
|}
 
 
-----
 
<div style="border-left:5px solid #aaa; background:#f6f6f6; padding:6px 12px; margin:10px 0;">
'''Версия документа:''' 2026-06-18, под архитектуру «анализатор зрения — единственная видео-карта; band/muq — доводка поверх». Числа и формулы сверены с боевым кодом <code>src/track_muxer/conform/**</code>. При расхождении кода и документа — приоритет у кода, документ актуализировать.
</div>

Версия от 19:50, 18 июня 2026

Назначение документа. Полное инженерное описание двух алгоритмов выравнивания, на которых стоит модуль conform: анализатора зрения (строит карту таймлайна по видеоряду) и аудио-доводки band/muq (правит остаточный сдвиг звука поверх зрения). Документ самодостаточен: математика, геометрия, рекурренты, точные константы и их обоснования, схемы пайплайнов и разбор реальных диагностических графиков. Читатель — инженер, который при желании сможет воспроизвести метод.

Конвенция знаков (сквозная). Сдвиг измеряем в кадрах рефа; правее (дубль отстаёт) = +, левее (спешит) = −. Один кадр = 41.708 мс (23.976 fps, FRAME в anchor/params.py). Сетка анализа звука T = arange(2.5, 1400, 0.5) с, шаг STEP = 0.5 с.

Источники. Всё, что ниже, сверено с боевым кодом src/track_muxer/conform/** и отчётами doc/reports/**. Ключевые файлы перечислены в Приложении Б.


0. Постановка задачи и общая архитектура

0.1. Что мы выравниваем

Дан эпизод сериала (или отдельный фильм) в виде набора файлов разных озвучек (студий). Все они содержат один и тот же видеоряд (с точностью до монтажных отличий: разные заставки, вырезы, вставки, иногда другой fps), но разные аудиодорожки. Нужно для каждой пары (референс, дубль) так преобразовать аудио дубля во времени, чтобы оно синхронно легло на таймлайн референса — и потом смуксировать все дорожки в один MKV.

Главная трудность — это не «найти один сдвиг». Сдвиг переменный по всей длине:

Источник рассинхрона Как выглядит на карте (t_реф → t_дубль)
Постоянный сдвиг (priming, encoder delay) вертикальный параллельный перенос
Дрейф скорости (разный fps, PAL/NTSC) наклон кривой ≠ 1
Монтажный вырез в дубле (короче рефа) ступень вверх (разрыв)
Монтажная вставка в дубле (длиннее рефа) ступень вниз (разрыв)
Заставка/реклама в начале свободный левый конец

И всё это — на контенте, где прямое применение обоих слоёв теряет чувствительность: видеоряд может идти «на двойках» (соседние кадры идентичны — анимация на 2 кадра, статичные планы), музыка повторяется (корреляция фиксируется на такт), у дубля чужой голос поверх общей музыки.

🖼️ ИЗОБРАЖЕНИЕ 0.1 — «Проблема в одной картинке». Слева: две киноленты (реф и дубль) друг под другом, на дубле — вырезанный кусок (короче) и вставленная заставка в начале (длиннее), стрелки соответствия кадров между лентами расходятся (не параллельны). Справа: те же соответствия как точки в осях t_реф(X) / t_дубль(Y) — складываются в ломаную линию с наклоном и двумя вертикальными ступенями. Подпись: «Выравнивание = восстановить эту ломаную».

0.2. Двухслойная архитектура: зрение строит карту, звук доводит

Фундаментальное решение проекта (отчёт BREAKTHROUGH_VIDEO_SYNC.md): видеоряд — общий инвариант всех озвучек, поэтому грубую и структурную карту строим по видео, а аудио используем лишь как тонкую доводку остатка, который видео физически не видит (аудио источника бывает смещено относительно своего же видео).

Почему именно так, а не «всё по аудио» или «всё по видео»:

  • Только аудио упирается в потолок: 5 разных аудио-методов (PHAT, MFCC, 2D-спектр) дают одни и те же промахи на повторяющемся контенте — это не алгоритмическая, а информационная нехватка в одном аудиоканале (BREAKTHROUGH_VIDEO_SYNC.md §2).
  • Только видео не видит сдвиг аудио относительно видео внутри самого источника (AV-sync delta, студийный сдвиг M&E) — его видит лишь аудио.
  • Поэтому: видео даёт структуру (где вырез, где вставка, какой дрейф), аудио доводит субкадровый остаток там, где видео слепо по физике (двойки) или где аудио смещено относительно картинки.

0.3. Сквозные величины и обозначения

Обозначение Смысл Значение / где
FRAME мс на кадр 41.708 (≈23.976 fps)
T сетка анализа звука arange(2.5, 1400, 0.5) с
STEP шаг сетки 0.5 с
o[t] измеренный сдвиг в точке t кадры рефа
w[t] уверенность измерения норм. по медиане
pred[j] для кадра дубля j — кадр рефа (или −1) целое
cos мера схожести двух кадров [1,1], на практике [0,1]

Дальше — два алгоритма по слоям.



Часть I. Зрение — построитель видео-карты

Файлы: features.py, kernel/coarse.py, kernel/dropdtw.py, kernel/band_align.py, vision_detect.py, разбор правок в align.py. Это единственный построитель видео-карты в conform (прежний «скат» _build_map_linear удалён 2026-06-18).

<a name="1-геометрия-задачи"></a>

1. Геометрия задачи: карта как монотонная кусочно-линейная кривая

Всё зрение сводится к одной геометрической цели — построить функцию

τ:tрефtдубль,

которая каждому моменту таймлайна референса сопоставляет момент в дубле, откуда брать звук. У этой функции жёсткая физическая структура:

  • кусочно-линейная — внутри монтажного блока скорость постоянна (дрейф = наклон);
  • монотонно неубывающая — время не идёт вспять (контент не переставляется);
  • с вертикальными разрывами на монтажных стыках — вырез/вставка = ступень.
 t_дубль
   ▲
   │                                        ╱ (наклон ≈ fps_реф/fps_дубль)
   │                                   ╱╱╱
   │            вставка ↓         ╱╱╱
   │                        ┌────╱           ← разрыв вниз: в дубле лишний кусок
   │                        ┊
   │                   ╱╱╱╱╱┘
   │              ╱╱╱╱
   │   вырез ↑   ╱
   │         ┌──┘                            ← разрыв вверх: в дубле кусок вырезан
   │      ╱╱╱┊
   │  ╱╱╱╱   ┊
   └──┴──────┴──────────────────────────────►  t_реф

Задача зрения — восстановить эту ломаную из зашумлённых измерений соответствия кадров. Шум двух видов: (а) ложные соответствия (двойки, лого, тёмные сцены), (б) пропуски (вырезы/вставки рвут непрерывность). Поэтому метод строится в три эшелона надёжности: грубая монотонная цепочка (§3) → точное выравнивание с пропусками (§4) → робастная ломаная поверх (§6).

<a name="2-srm-отпечаток-кадра"></a>

2. SRM-отпечаток кадра

Чтобы сравнивать кадры рефа и дубля, каждый кадр сворачивается в вектор-отпечаток так, чтобы схожесть контента была устойчива к различиям источника (BD vs Web, разный кодек, яркость, логотип студии). Наивный путь — сравнивать пиксели яркости — ломается на первом же логотипе и перепаде гаммы. Решение — SRM (Spatial Rich Model): отпечаток не яркости, а остаточного высокочастотного сигнала (текстуры/краёв).

Отступление: что такое «отпечаток кадра» и SRM. Отпечаток (дескриптор) — это короткий числовой слепок картинки: вектор чисел, по которому два кадра можно сравнить, не сличая их попиксельно. Хороший отпечаток одинаков у одного и того же кадра в разном качестве и не сбивается от логотипа или яркости. SRM (Spatial Rich Model) — приём из цифровой криминалистики изображений: вместо самой картинки берут её высокочастотный остаток — то, что остаётся после вычитания плавных перепадов яркости (контуры, мелкая текстура). Именно остаток устойчив к перекодированию и смене источника.

2.1. Конвейер построения (features.py::build_srm)

Пошагово. Кадр приводится к серому g размера 128×72 (GW×GH).

Простыми словами: свёртка ядром. Свёртка — это проход маленькой матрицей-окном по всем пикселям: в каждой точке берётся взвешенная сумма соседних пикселей. Подбором весов окно настраивают на нужный признак — перепад яркости, край, текстуру. Сумма весов, равная нулю, означает, что окно «не замечает» ровной заливки и реагирует только на изменения.

Здесь два фиксированных ядра свёртки:

KB=(121242121),D1=(000011000).

  • KB — дискретный оператор второго порядка (лапласиан-подобный, сумма коэффициентов =0). Он зануляет постоянную и линейную составляющую яркости и реагирует на кривизну — текстуру, контуры, мелкие детали. Именно эта высокочастотная «подпись» одинакова у одного и того же кадра в разных рипах.
  • D1 — первая горизонтальная разность (/x), ловит вертикальные края и направление градиента.

Свёртка идёт с краевым режимом reflect: rK=(g*KB), rD=(g*D1). Далее — обрезка выбросов и нормировка каждого канала по отдельности:

r~K=clip(rK,3,3)clip(rK,3,3)2+ε,r~D=clip(rD,3,3)clip(rD,3,3)2+ε.

Обрезка ±3 давит редкие яркие выбросы (блики, субтитры), L2-нормировка убирает зависимость от общего контраста кадра. Итоговый вектор кадра — конкатенация обоих каналов, масштабированная на 1/2:

v=12[r~Kr~D]18432,18432=212872.

2.2. Почему cos = v_1\cdot v_2 напрямую

Простыми словами: косинусная близость. Если каждый кадр представить стрелкой (вектором) в многомерном пространстве, то мерой их похожести служит угол между стрелками: сонаправленные (один контент) дают косинус ≈ 1, перпендикулярные (разный контент) ≈ 0. Косинус зависит только от направления, не от длины стрелок, — поэтому он устойчив к общей яркости и контрасту.

Множитель 1/2 выбран так, что готовый вектор уже нормирован: поскольку каждый канал имеет единичную норму,

v22=12(r~K22+r~D22)=12(1+1)=1.

Поэтому скалярное произведение двух отпечатков сразу равно косинусной близости, и она же — среднее канальных косинусов:

v1v2=12(cosKB+cosD1)=cos(v1,v2)[1,1].

Это ключ к скорости всего зрения: матрица всех попарных схожестей — одно матричное умножение G=CsCr (косинусы без отдельной нормировки), а стоимость для Drop-DTW — просто C=1cos.

Инженерные детали, ломающие бит-в-бит совпадение, если их нарушить (features.py): ffmpeg -vsync 0 (passthrough — индекс кадра = позиция во времени), float16, порядок clip → norm → concat. Декод потоковый, при low_mem фичи пишутся в memmap, значения идентичны in-RAM пути.

🖼️ ИЗОБРАЖЕНИЕ 2.1 — «Что видит SRM». Три колонки: (1) исходный кадр видео с логотипом студии в углу; (2) карта отклика KB (контуры/текстура, лого почти не выделяется — оно гладкое); (3) карта отклика D1 (вертикальные края). Подпись: «Отпечаток строится по высокочастотному остатку → устойчив к яркости и логотипу».

<a name="3-грубый-проход"></a>

3. Грубый проход: матрица косинусов, надёжные якоря, LIS

Цель первого эшелона — получить грубую, но монотонную оценку сдвига off(j) для каждого кадра дубля j, чтобы потом узкая полоса Drop-DTW искала точное соответствие не во всём рефе, а в коридоре вокруг этой линии. Файл kernel/coarse.py.

3.1. Надёжные якоря (_anchors)

Кадры прореживаются с шагом K=8 (грубому проходу субкадровая точность не нужна). Строится матрица косинусов всех прореженных synth × ref:

G=CsCr,Gm,r=cos(vmsyn,vrref).

Для каждого кадра дубля m берётся лучший партнёр и его близость bestm=argmaxrGm,r, bestvm=maxrGm,r. Затем окрестность ±W (W=80 прореженных кадров) этого пика зануляется, и берётся второй максимум secondm. Кадр становится надёжным якорем только если выполнены оба условия:

bestvm>VHIпик высокийbestvmsecondm>CMINпик одинок (различим),VHI=0.60,CMIN=0.12.

Первое условие отсекает мусорные сцены (тьма, переход), второе — двойки и повторы: если в рефе есть второй почти такой же кадр, отрыв мал и кадр НЕ берётся в якоря. Это прямой ответ на главную проблему повторяющегося контента.

3.2. Монотонная цепочка через LIS (lis_nd, coarse_robust)

Отступление: зачем «длиннейшая неубывающая подпоследовательность» (LIS). Среди якорей есть верные и единичные ложные. Верные обязаны идти «по возрастанию»: чем позже кадр в дубле, тем позже его партнёр в рефе (время не отматывается назад). LIS — классический алгоритм, который из разбросанных точек вытаскивает самую длинную цепочку, идущую только вверх; всё, что в неё не уложилось, почти наверняка ложные пары. Так костяк соответствия очищается без порогов.

Надёжные якоря ещё могут содержать одиночные ложные пары. Истинное соответствие монотонно по рефу, поэтому из набора якорей (synth_кадр → ref_кадр) извлекается длиннейшая неубывающая по ref подпоследовательность (Longest Non-Decreasing Subsequence, O(nlogn) через хвостовые индексы + бинарный поиск). Всё, что не легло в монотонную цепочку, отбрасывается как шум. По выжившим якорям линейной интерполяцией строится off(j) для всех кадров:

off(j)=interp(j; {ak}, {rkak}).

 ref                     LIS оставляет монотонный костяк, шум выпадает
  ▲        ✗(ложный)
  │   ●───●        ✗
  │  ╱       ●───●───●
  │ ●                   ●───●     ← возрастающая цепочка = реальное соответствие
  │╱            ✗
  └───────────────────────────►  synth (кадр дубля)

3.3. Оконный вариант для длинных файлов (coarse_windowed)

Полная матрица G имеет размер Ns×Nr — для серии это гигабайты. Оконный проход режет дубль на окна по CWIN=8000 кадров с перекрытием COV=2000; seed-offset перетекает из окна в окно, и в каждом окне ищется ref только в полосе ±CSR=4000 вокруг seed. Память и время ограничены окном (не растут с длиной файла), а результат бит-в-бит совпал с полным проходом на 26 кейсах.

<a name="4-drop-dtw-выравнивание-с-пропусками"></a>

4. Drop-DTW: выравнивание с пропусками

Второй эшелон — точное соответствие кадров. Обычный DTW (Dynamic Time Warping) обязан сопоставить каждый кадр — он не умеет сказать «этого куска в рефе нет, это вставка». А у нас именно вырезы и вставки. Решение — Drop-DTW: DTW, которому разрешено выбрасывать кадры с обеих сторон за фиксированный штраф. Это ровно как diff для текста или выравнивание последовательностей ДНК: ищем общий костяк, непарные куски помечаем как «вставка»/«удаление». Файл kernel/dropdtw.py.

4.1. Стоимость и базовая рекуррента (drop_dtw)

Стоимость сопоставить кадр рефа i с кадром дубля k:

Ci,k=1cos(viref,vksyn)[0,2].

Простыми словами. Задача — перебрать все способы «растянуть / сжать / пропустить» кадры так, чтобы суммарная непохожесть сопоставленных кадров была минимальной, и сделать это не наивным перебором (астрономически долго), а накоплением лучшего ответа клетка за клеткой — это и есть динамическое программирование. Ниже — правило, по которому заполняется таблица лучших частичных ответов.

Динамика заполняет таблицу Di,k — минимальную накопленную стоимость пути, пришедшего в клетку (i,k). На каждом шаге доступны пять ходов (диагональ, два варпа, два выброса); старт — отдельная инициализация (свободный левый конец):

Di,k=min{Di1,k1+Ci,kдиагональ (1:1 совпадение)Di1,k+Ci,kвертикальный варп (ref тянется)Di,k1+Ci,kгоризонтальный варп (дубль тянется)Di,k1+DROPвыброс кадра дубля (вставка)Di1,k+DROPвыброс кадра рефа (вырез)

с штрафом DROP=0.20. Backpointer Bi,k хранит, какой ход выбран, для восстановления пути. Свободные концы:

  • свободный стартD[i,0] = C[i,0] для всех i: путь может начаться с любой строки рефа (дубль не обязан стартовать с самого начала рефа);
  • свободный конец — обратный ход начинается с argmin последнего столбца: путь может закончиться на любой строке рефа.

Восстановление пути (backtrack) даёт три выхода: pred[k] (для кадра дубля k — его кадр рефа, либо -1), drop_syn (маска выброшенных кадров дубля = вставки), drop_ref (список выброшенных кадров рефа = вырезы).

🖼️ ИЗОБРАЖЕНИЕ 4.1 — «Путь по матрице Drop-DTW». Тепловая карта стоимости Ci,k (тёмное = похоже), ось X = кадры дубля, ось Y = кадры рефа. Поверх — оптимальный путь: диагональные участки (совпадение), один горизонтальный «провал» (выброс кадров дубля = вставка) и один вертикальный «прыжок» (выброс кадров рефа = вырез). Сбоку — легенда шести ходов. Подпись: «Зелёная линия — карта; разрывы = монтажные стыки».

4.2. Аффинный штраф: острый шов вместо размазни (drop_dtw_affine)

Если вырез длинный, выбрасывать его «по одному кадру» дорого и нестабильно — путь норовит размазать выброс или убежать на логотипе. Аффинная (two-state) динамика берёт открытие прогона выброса дороже, чем его продолжение (как gap-open / gap-extend в биоинформатике):

  • состояние M — «матч/варп» (платит C);
  • состояние Dr — «внутри выброса рефа»: открыть из M за OPEN=0.20, продлить из Dr за EXT=0.02;
  • выброс кадра дубля (вставка) — плоская цена DSYN=0.30 внутри M.

Dr[i,k]=min(M[i1,k]+OPEN,Dr[i1,k]+EXT).

Дёшево продлевать (0.02) → длинный вырез схлопывается в один чистый шов, а не в рваную лесенку. На ровных логотипных участках состояние Dr не открывается → путь не уходит в сторону.

4.3. Guard: нельзя выбросить кадр с хорошим матчем (drop_dtw_affine_guard)

Без защиты прогон выброса склонен захватывать хвосты совпадающих кадров (где cos=1), раздувая вставку. Guard заранее считает для каждого кадра дубля k лучший достижимый матч по столбцу:

colmink=miniCi,k.

Выброс этого кадра (ход drop-syn) разрешён только если Невозможно разобрать выражение (синтаксическая ошибка): {\textstyle \text{colmin}_k>\text{MATCH\_THR}} (=0.30); иначе цена выброса = — кадр с хорошим матчем защищён от выбрасывания.

4.4. Free-start: заставка начала выпадает сама

Реклама/заставка в начале дубля не имеет пары в рефе. Free-start даёт свободный левый конец по дублю: путь может стартовать с любой клетки, выбросив ведущие кадры [0..k1] по цене вставки kDSYN. Штраф растёт с k — поэтому короткая заставка выпадает, но путь не «улетает» далеко.

старт в (i,k):cost=Ci,k+kDSYN.

4.5. Следящая полоса (band_align)

Drop-DTW на полной матрице Ns×Nr невозможен по памяти. Поэтому он считается в узкой полосе вокруг грубой линии off (§3): дубль режется на куски по CHUNK=4000 кадров с перекрытием OVERLAP=700; для каждого куска ref-окно берётся как [min(refk)MARG, max(refk)+MARG], полуширина MARG=120 кадров (≈5 с — покрывает обычную правку). На стыках кусков для каждого кадра берётся ответ из того куска, где кадр дальше от края (надёжнее):

 кусок A:  ├──────────────────┤
 кусок B:              ├──────────────────┤
                       └ overlap ┘
 кадр в overlap → берём из того куска, где он ГЛУБЖЕ (центр надёжнее краёв)

Итог слоя §4 — массив pred длины Ns: для каждого кадра дубля либо кадр рефа, либо -1 (выброшен).

<a name="5-разбор-правок"></a>

5. Разбор правок: вырезы, вставки, налипания

pred сырой: в нём вперемешку настоящие монтажные правки и артефакты (слепые зоны зрения, налипания). Их разбирает единое физическое правило в align.py, а не набор порогов под контент.

5.1. Правка реальна только при стойком сдвиге УРОВНЯ offset (_level_decide)

Ключевая идея: считаем поканальный offset на сопоставленных кадрах offasg=pred[asg]asg. Для каждого выброшенного блока (drop-syn) сравниваем медиану уровня offset до и после блока (окно LEVEL_WIN_S=4 с):

Δ=|median(offпосле)median(offдо)|.

  • Δ LEVEL_EDIT_S (1 с) или блок короче LEVEL_MIN_INS_S (2 с) → слепая зона / дрожь → восстановить интерполяцией (заодно перекрывает парный пробел рефа). Ложное восстановление безвредно по построению.
  • иначе → реальная вставка → оставить выброшенной.

Любой оставшийся пробел рефа при непрерывном дубле > CUT_MIN_S (0.3 с) — это реальный вырез. Доказано: пробел рефа при непрерывном дубле всегда сдвигает уровень ⇒ слепых вырезов не остаётся.

5.2. Фикс «пилы»: вырез только при устойчивом сдвиге, а не при выбросе на статике

На статике (длинный неподвижный план) зрение может дать временный выброс offset, который возвращается — это не вырез, а артефакт. Соседние пробелы склеиваются в кластер (LEVEL_CUT_COALESCE_S=3 с); если в пределах LEVEL_CUT_RECOVER_S (8 с) уровень возвращается к доврезному — кластер восстанавливается (пила убрана), если держится — схлопывается в один интервал выреза (а не лесенку коротких тишин).

5.3. Детектор «налипания» (_creep_drop, кейс case/04)

Особый дефект: вставка-повтор своего же контента с наложенным текстом присваивается Drop-DTW «ползучим ходом» вместо выброса. Сигнал кричащий: присвоенные кадры по содержимому чужие своим реф-партнёрам — cos0.000.02 на много секунд подряд, тогда как честные матчи даже в тёмных сценах держат cos0.22. Механика: скользящая медиана cos присвоенных кадров (окно ~1 с); связные зоны ниже CREEP_COS=0.15 длиннее CREEP_MIN_S=3 с → pred=-1. Дальнейшую судьбу решает _level_decide (§5.1) — ложное срабатывание безвредно.

5.4. Возврат краёв (_recover_edges)

Free-start мог выкинуть совпадающее начало/конец (общий опенинг при разном вступлении BD↔WEB). Берём выброшенный кусок дубля против непокрытого куска рефа, гоняем band_align на подзадаче и вписываем вернувшиеся матчи (где cos>EDGE_COS_MIN=0.5).

<a name="6-укладка-ломаной"></a>

6. Укладка ломаной: (o,w) → детект ступеней → кусочно-линейная карта

Финал зрения (vision_detect.py) — превратить дискретный pred в гладкую кусочно-линейную карту с явными разрывами на резах. Это перенос сильного аудио-аппарата (anchor/detect.py) на выход зрения: зрение видит структуру сдвига точнее аудио, а прежний «скат» (maximum.accumulate) её подавлял.

6.1. Из pred в узлы сетки (o, w) (vision_ow)

Для сопоставленных кадров считаем время в рефе и сдвиг в кадрах:

tkref=pred[k]fpsref,shk=(kfpsdubtkref)fpsref.

На каждом узле сетки tT в окне ±VIS_WIN (0.3 с) берём взвешенную медиану сдвига (вес = cos), и оцениваем уверенность как медиану cos, умноженную на долю согласных (anchor-«agree») — кадров, чей сдвиг в пределах tol_fr=2 кадра от медианы:

o[t]=wmedian(sh, cos),w[t]=median(cos)1n#{|sho|2}agree.

Взвешенная медиана — робастная статистика без порогов: сортируем по значению, идём по кумулятивному весу до половины. Пустые узлы интерполируются (w=0), веса нормируются на медиану положительных.

6.2. Детект ступеней: DP-сегментация ломаными (detect.warp_curve)

Кривую o(t) режем на сегменты, в каждом — одна взвешенная прямая; стыки сегментов = кандидаты в резы. Это DP по префиксным суммам.

Отступление: робастная прямая (IRLS). Обычная прямая по методу наименьших квадратов легко уводится одной выбросной точкой. IRLS (итеративно перевзвешенный МНК) лечит это: проводим прямую, смотрим, кто далеко отклонился, уменьшаем таким точкам вес и проводим заново. Через пару итераций прямая опирается на основную массу точек и игнорирует выбросы.

Взвешенная прямая (wfit, IRLS). Для точек (t,y) с весами w оценка наклона a и сдвига b — обычная взвешенная регрессия, но в 2 итерации с даунвейтом выбросов (Iteratively Reweighted Least Squares):

a=w(tt¯)(yy¯)w(tt¯)2,b=y¯at¯,wwexp[(r2.5s)2],

где r=y(at+b) — остаток, s — взвешенный СКО. Второй проход гасит выбросы → прямая не ведётся за единичными ложными узлами.

Сегментация (_wsegment). Стоимость сегмента [i,j) — взвешенная SSE наилучшей прямой (берётся за O(1) из шести префиксных сумм w,wt,wy,wt2,wty,wy2). DP минимизирует суммарную ошибку плюс штраф PEN за каждый сегмент (чем больше PEN, тем меньше резов), при минимальной длине сегмента msize:

opt[j]=minijmsize(opt[i]+SSE(i,j)+PEN).

Наклон каждого сегмента ограничен потолком ±SMAX (физика дрейфа ≤~2%). Рез ставится на стыке, если разрыв прямых MIN_FR (3 кадра ≈ 120 мс).

6.3. Фильтр резов: устойчивость уровня на большом окне (video_cut_filter)

DP-сегментация склонна давать избыточные стыки; настоящий рез отличается тем, что уровень offset устойчиво разный слева и справа на большом окне ±VC_WIN_S (80 с), и считать его надо по надёжным узлам. Берём взвешенную медиану уровня слева/справа (робастна к «холмику» в окне — холмик у слепой зоны зрения возвращается, и медиана его игнорирует):

Невозможно разобрать выражение (синтаксическая ошибка): {\displaystyle \text{рез реален}\iff \big|\operatorname{wmedian}(o_R,w^3)-\operatorname{wmedian}(o_L,w^3)\big|\ge\text{VC\_THR}. }

Два важных нюанса:

  • вес-гейт VC_GATE=0.55: в окне берутся только узлы с весом выше квантиля 0.55 (_gmask) — всплески у слепых зон отбрасываются;
  • окно ограничено соседними резами (как tail_filter в аудио): на узком сегменте между двумя близкими резами окно ±80 с не должно перехлёстывать через соседний рез, иначе поймает чужой уровень и ложно подтвердит рез. Регрессия 0/36 дорожек.

6.4. Кусочно-линейная укладка (piecewise_lines)

Между резами кладём одну робастную прямую (wfit, наклон VIS_SMAX=0.45 к/с), изломы только на резах. Это усредняет весь кусок и потому нечувствительно к мелкой ряби o. Итог — кривая сдвига на сетке T.

6.5. Сборка карты (build_map)

shift(t)=interp(t; T, curve),τ(t)=t+shift(t)FRAME1000 [с].

На резах в shift ступень (разрыв) — её перекрывает тишина (вызывающий разносит контент на монтажном стыке). Между резами карта монотонна (наклон VIS_SMAX). Выход: (grid, tg_s, cuts, o, w, curve)tg_s идёт в ресэмпл аудио, o/w/curve — в графики, cuts — в тишину.

Итог Части I. Зрение превращает два видеофайла в карту τ(t) «время рефа → время дубля» — монотонную кусочно-линейную, с явными монтажными резами, устойчивую к двойкам, лого и слепым зонам. По этой карте аудио дубля ресэмплится на таймлайн рефа; в резах ставится тишина. Дальше — слой звука.



Часть II. Звук — доводка band/muq поверх зрения

Файлы: anchor/apply.py (оркестровка), anchor/maps/band.py (метрика), anchor/detect.py (общий аппарат детекта, тот же, что у зрения), anchor/assemble.py. Метод по умолчанию — band (DSP, 48 полос, без модели/лицензии); альтернатива muq (GPU-эмбеддинги, та же сборка).

<a name="7-зачем-аудио-после-видео"></a>

7. Зачем аудио после видео

Зрение положило дубль по картинке. Но между видео и аудио внутри самого файла бывает сдвиг, которого на картинке не видно:

  • AV-sync delta — разный AAC priming / encoder delay (в JoJo измерено стабильные +89 мс между файлами, BREAKTHROUGH_VIDEO_SYNC.md §4.4);
  • студийный сдвиг M&E — студия подвинула фон/музыку в миксе относительно своего видео;
  • двойки — видео физически не различает соседние одинаковые кадры (предел ±83 мс), аудио снимает это ограничение.

Поэтому второй слой меряет остаточный сдвиг аудио дубля (уже лежащего по видео-карте) относительно аудио рефа и доводит его. Важно: аудио-слой работает после простановки тишины в монтажных резах — он должен видеть реф/тишину в вырезе (как боевой эталон), а не сырой несинхронный дубль, иначе на стыке зрение↔аудио появляется ложный краевой рез.

<a name="8-принцип-зрячих-свидетелей"></a>

8. Принцип зрячих свидетелей

Отчёт METHODOLOGY_audio_matching.md фиксирует намертво: наивная корреляция звука слепа, и это легко принять за «аудио измерить нельзя». Озвучка = чужой голос поверх той же музыки и эффектов (M&E). Прямой NCC сырого сигнала ловит несовпадение голоса и возвращает шум. Лечится не другим инструментом, а изоляцией инварианта ПЕРЕД корреляцией. У каждого искажения дубляжа есть то, что оно не трогает:

Искажение дубляжа Что губит Инвариант (что не трогает) Как изолируем
Чужой голос диктора часть частотных полос чистые полосы M&E 48 лог-полос + взвеш. медиана
Другой мастеринг/тембр тонкую структуру волны динамику громкости лог-огибающая RMS
Повтор музыкального такта однозначность пика окна с одиночным пиком comb-veto

Свидетели разной физики теряют чувствительность в разных местах → их сумма покрывает всю длину. Ниже подробно про основной свидетель боевого band — многополосную метрику (она же несёт оба первых инварианта: полосы против голоса, лог-энергия против мастеринга).

🖼️ ИЗОБРАЖЕНИЕ 8.1 — «Почему полосы видят сквозь голос». Спектрограмма окна озвучки: горизонтальными лентами выделены 48 лог-полос; полосы, занятые голосом диктора (≈300–3000 Гц), подсвечены красным («низкий контраст, малый вес»), полосы чистой музыки/эффектов — зелёным («высокий контраст, голосуют»). Снизу — та же сцена в рефе. Подпись: «Взвешенная медиана по полосам игнорирует испорченные голосом полосы — без знания, где голос».

<a name="9-band-метрика-48-полос"></a>

9. band-метрика: 48 полос

Файл anchor/maps/band.py. Рецепт NB48: sr=16000, NB=48 лог-полос в диапазоне 5014000 Гц, окно win=5 с, STFT nfft=2048, hop=256 (⇒ кадровый темп огибающей fps=sr/hop=62.5 Гц), агрегация wmedian, качество agree.

9.1. Полосовые огибающие (_benv)

Отступление: STFT и полосы. STFT (кратковременное преобразование Фурье) режет звук на короткие кусочки и для каждого показывает, сколько в нём энергии на каждой частоте, — получается спектрограмма «время × частота». Мы группируем частоты в 48 логарифмических полос (как деления на эквалайзере) и следим за громкостью каждой полосы во времени — это и есть «огибающая полосы». Лог-шкала частот ближе к слуху: низкие частоты дробятся мельче, высокие — крупнее.

Для окна сигнала x берём STFT и мощность P=|Z|2. Лог-частотная полосовая маска BM{0,1}NB×(nfft/2+1) (границы — logspace(50,14000,49)) суммирует мощность по полосам:

Eb,t=fBMb,fPf,t,E~b,t=log(1+Eb,t).

Каждая полоса z-нормируется по времени (убираем общий уровень и масштаб — это инвариант к мастерингу):

E^b,t=E~b,tmeantE~bstdtE~b+ε.

log1p + z-score — это и есть «лог-огибающая громкости полосы»: динамика, общая у дубля и рефа, остаётся; тембр выкидывается.

9.2. По-полосный лаг через кросс-корреляцию (_om_core)

Для каждой полосы независимо считаем кросс-корреляцию огибающих рефа E^br и дубля E^bd по времени (через FFT, знак EdEr даёт «правее=+»):

ccb[]=(E^bdE^br)[],[Mf,Mf],  Mf=maxlagfps.

Выраженность пика полосы (насколько он торчит над фоном):

promb=maxccb[]medianccb[].

9.3. Агрегация: взвешенная медиана по полосам (agg="wmedian")

Вместо суммы корреляций (её подавляет голос) — по-полосный аргмакс-лаг и взвешенная медиана этих лагов по 48 полосам, вес = prombpromp (promp=1):

bshb=argmaxccb[],^=wmedian({bshb}, {promb}).

Медиана робастна: даже если голос испортил треть полос и они показывают «не туда», медиана держится за большинство чистых. Финальный сдвиг уточняется параболической интерполяцией вокруг ^ по агрегированной поверхности и переводится в кадры:

off=^12y+yy+2y0+yfps1000FRAME [кадры].

9.4. Качество = согласие полос (quality="agree")

Уверенность узла — доля полос, чей собственный аргмакс-лаг согласен с итоговым сдвигом (в пределах tol=2 кадра), взвешенная по выраженности:

w=bpromb𝟏[|bshboff|2]bpromb+ε.

Это самопроверка без внешней истины: если полосы единодушны — узлу можно верить; если разбрелись (повтор/тишина/голос везде) — w мал, и детект/укладка такой узел не слушают.

muq как альтернатива. Метод muq заменяет полосовую огибающую на GPU-эмбеддинги музыкальной модели, но дальше всё то же — та же поверхность (o,w), тот же детект detect.py, та же сборка assemble.py. band не требует новых зависимостей (только torch/numpy) и потому выбран дефолтом.

<a name="10-грубая-off0--следящий-band"></a>

10. Грубая off0 + следящий band

Рабочее окно band узкое — ±MAXLAG=0.7 с (точность ценой диапазона). Но сдвиг опенинга бывает крупнее. Поэтому, как и в зрении, две стадии: грубая «тропа» + точное слежение (anchor/apply.py).

10.1. Грубая off0 (_coarse_off0)

Та же band-метрика, но с широким окном лага ±COARSE_LAG_S=2.5 с — она видит большой сдвиг. Робастность только статистикой, без порогов под контент:

  1. страж края — если аргмакс на границе ±2.5 с, это «нет пика», вес обнуляется;
  2. скользящая взвешенная медиана (окно med_win_s=5 с) сдвига;
  3. гейт согласия соседей — узел стабилен, если |omed|stab_tol=4 кадра;
  4. повторная медиана только по стабильным + интерполяция → непрерывная off0(t).

10.2. Слежение (band.build_arr ±0.7с)

Дубль предварительно варпится по off0 (грубая коррекция), и band с узким окном ±0.7 с меряет остаток на пред-варпленном дубле. Полный сдвиг — сумма:

o(t)=off0(t)+oост(t).

Так узкое точное окно никогда не «теряет» крупный сдвиг — он уже снят грубой тропой, а band доводит лишь малый остаток. (Урок EXPERIMENTS_coarse_band_follow.md: GCC в грубом проходе подавляется голосом, band-грубый с медианой по полосам — нет.)

<a name="11-детект-ступеней-и-кривая-дрейфа"></a>

11. Детект ступеней и кривая дрейфа

11.1. Детект — тот же аппарат, что у зрения (detect.detect)

(o,w) идёт в тот же warp_curve (DP-сегментация взвешенными ломаными, §6.2), затем:

  • tail_filter — рез реален только при устойчивом сдвиге хвоста: медиана уровня до/после на окне ±win=25 с, ограниченном соседними резами, с вес-гейтом; величина реза = это смещение. Аналог video_cut_filter из зрения, но для аудио.
  • _edge_refine — отдельно выделяет рез в первом/последнем сегменте (краевая зона, короче msize), где обычная сегментация его не выделит.

11.2. Кривая дрейфа: Надарая–Уотсон + потолок скорости (_smooth_drift_curve)

Простыми словами: сглаживание Надарая–Уотсона. Это «скользящее среднее с приоритетом надёжным точкам»: значение кривой в каждый момент — среднее соседних измерений, но уверенные точки (большой вес w) тянут сильнее, а шумные почти не влияют. Ширина «гауссова окна» σ задаёт, насколько далёких соседей ещё учитывать.

Внутри куска между резами строим гладкую кривую, которая следит за горками (плавный дрейф) и стоит на шуме. Это ядерное сглаживание Надарая–Уотсона с весом wqpow (qpow=3, гауссово ядро σ=3 с):

sm(t)=(ow3)*Gσw3*Gσ+ε.

Поверх — потолок скорости изменения сдвига: между соседними узлами |Δsm| не больше lim, что соответствует ускорению max_pct_s (1.25 %/с = SMAX):

Невозможно разобрать выражение (синтаксическая ошибка): {\displaystyle \text{lim}=\frac{\text{max\_pct\_s}}{100}\cdot\text{STEP}\cdot\frac{1000}{\text{FRAME}}\approx0.15\ \text{кадра/узел}. }

Потолок не даёт кривой прыгнуть за единичными яркими выбросами в слепой зоне.

11.3. Опора после реза (фикс 2026-06-18)

Тонкость: в куске, который начинается с реза, band теряет чувствительность на самом монтажном стыке (тусклые выбросы сразу за резом). Если вести потолок скорости от левого края куска, он занижает старт, и кривая ~35 с медленно вытягивается к плато. Идея пользователя: расходиться в обе стороны от самого уверенного якоря в первых anchor_lookback_s=30 с куска (= начало плато):

   сдвиг
     ▲                  ● ● ● ● ● ● ●  ← плато (уверенные якоря)
     │      опора ↑ argmax w в первых 30с
     │     ╱   ╲        clamp влево к резу + вправо вдоль плато
     │  ✗ ╱     ╲
     │ слепой    
     │ старт      
     ┼──┊─────────────────────────────►  t
        рез

Слепой старт подтягивается к плато, а не плато к старту. Первый кусок (без реза слева) — по-старому: левый край = опора. Приёмка: 47/55 дорожек не тронуты, изменения = только исправления (память project_band_postcut_anchor_fix).

<a name="12-варп-тишина-в-резах-заполнение-рефом"></a>

12. Варп, тишина в резах, заполнение рефом

12.1. Кусочный варп (_warp_piecewise)

Дубль ресэмплится по кускам между резами: внутри куска — гладкая wcurve (горки), на резе — резкий стык. Для выходного отсчёта t источник в дубле:

src(t)=(t+wcurve(t)FRAME1000)sr,out[t]=interp(src(t)).

12.2. Тишина в резах

Непрерывный варп на монтажном стыке «переиграл» бы звук дважды, поэтому в резах ставится тишина (контент разносится):

  • Δ<0 (нехватка дубля) → тишина |Δ| по центру реза;
  • Δ>0 (лишнее вырезано) → узкий шов ±0.15 с.

12.3. Заполнение тишины синхронным рефом (_fill_silence_from_ref)

Финальный проход — только после полного band/muq, когда дорожка уже синхронна рефу. Там, где озвучка молчит, а реф звучит, подставляем реф с кроссфейдом. Порог тишины измерен:

RMSdBFS=20log10(RMS32768+ε),fillRMSдубль<90 дБ  RMSреф90 дБ.

Порог 90 дБ — в центре измеренного плато настоящих дыр (ниже 80), далеко от обрыва тихого контента (4060), окно RMS 20 мс, мин. длина зоны 0.15 с, кроссфейд 30 мс. Где у обоих тишина — остаётся тишина; озвучка не теряется (трогаем только пустоты).

Запрет (намертво). fill_cuts — заливка вырезов рефом по видео, до анализа — запрещён навсегда: вставка в ещё несинхронную дорожку плодит мнимый рассинхрон. Разрешено только fill_silence — заполнение после выравнивания, по синхронной дорожке (память feedback_no_audio_replacement_fill_cuts).

12.4. Кросс-модальный детектор студийного A/V-десинка (detect_av_desync)

Read-only диагностика (на wav не влияет). После укладки out уже на сетке рефа; широким PHAT-окном (±2.5 с, полоса 50–13500 Гц) меряем остаточный лаг M&E к реф-аудио по окнам. Если он большой и устойчивый (медиана велика, MAD мал) — аудио источника смещено относительно его же видео (студийный дефект, надёжно не чинится) → дорожка помечается красным. Паттерн валидирован: AniLibria ep01 kamennyj-okean медиана −40к / MAD 2.2к против ~0/~0 у 10 здоровых.

Итог Части II. Поверх видео-карты звук band/muq снимает остаточный сдвиг аудио, который видео не видит: многополосная метрика делает корреляцию зрячей сквозь голос и мастеринг, грубая off0 ловит крупный сдвиг, следящий band доводит остаток, общий аппарат detect.py ставит резы, кривая дрейфа с потолком скорости и опорой-после-реза кладёт гладко, тишина заполняется синхронным рефом. Дальше — как всё это читается на графиках.



Часть III. Графики — диагноз с одного взгляда

Файл anchor/plots_unified.py. Проектная установка: алгоритмы доведены до состояния, где качество читается по графику, без ручной сверки в Premiere. Беглый взгляд на любой график даёт чёткий ответ «всё в порядке или нет». Поэтому график — не украшение, а главный инструмент приёмки и отладки.

<a name="13-зачем-графики"></a>

13. Зачем графики и что на них видно

Единый график строится на каждую пару и показывает оба слоя сразу: верхняя панель — зрение (видео-укладка, всегда), нижняя — звук (доводка band/muq, если включена), на общей оси времени с синхронным зумом и ховером. Один взгляд отвечает на четыре вопроса:

Вопрос Где смотреть
Держится ли укладка за надёжные данные? цвет точек (уверенность): линия должна идти по ярким, не по тусклым
Есть ли необъяснённый перекос/увод? отклонение линии от облака точек
Верно ли найдены монтажные резы? вертикальные пунктиры и их подписи (мс)
Согласны ли зрение и звук? сравнение двух панелей в одной зоне

<a name="14-дрейф-форма"></a>

14. Дрейф-форма и робастная база Тейла–Сена

Сырой сдвиг по всей серии может быть сотни кадров (крупный масштаб оси), и на нём не видно ни плато, ни ступеней в десяток кадров. Поэтому обе панели рисуются в дрейф-форме — вычитается робастная линейная база, и всё разворачивается вокруг нуля.

Отступление: база Тейла–Сена. Чтобы провести «среднюю линию» через облако точек и не дать единичным выбросам её перекосить, берут наклон между каждой парой точек и выбирают медианный — половина пар «за», половина «против». Это устойчивее обычной прямой и не требует никаких порогов отбраковки.

База оценивается методом Тейла–Сена (_baseline) — он устойчив к выбросам, в отличие от МНК: на выборке точек (до 400) берутся все попарные наклоны, и базовый наклон — их медиана; сдвиг — медиана остатков:

a=mediani<jyjyitjti,b=mediank(ykatk).

База считается по телу дорожки (исключая голову/заставку: t>head+5 с). Затем:

devякорь=shift(at+b),devкривая=curve(aT+b),

а предел оси берётся как max(12, p99(|dev|)+4) кадров — авто-масштаб под реальный разброс. На резах в линию вставляется NaN (_breaks), чтобы она не соединяла куски через разрыв — ступень видна как обрыв, а не как вертикаль.

🖼️ ИЗОБРАЖЕНИЕ 14.1 — «Сырой сдвиг vs дрейф-форма». Два графика друг над другом для одной дорожки: сверху сырой shift(t) (линия уходит на −600 кадров, мелкие детали не видны); снизу та же дорожка в дрейф-форме (база вычтена, всё колеблется в ±15 кадров вокруг 0, видны и плато, и ступенька реза). Подпись: «Дрейф-форма вытаскивает структуру, которую крупный масштаб прячет».

<a name="15-слои-и-разбор-кейсов"></a>

15. Слои, цветокодирование, разбор реальных кейсов

15.1. Слои единого графика

Верхняя панель (ЗРЕНИЕ):

  • точки якорей (tреф, devякорь), цвет = cos (viridis, 0…1) — надёжность каждого видео-якоря;
  • оранжевая линия — карта зрения в дрейф-форме, с разрывами на резах;
  • crimson-пунктиры — резы зрения с подписью величины (мс);
  • заголовок несёт абсолютную медиану сдвига.

Нижняя панель (ЗВУК):

  • точки (T, o), цвет = w (cividis, 0…1.2) — уверенность band;
  • серые точки — gcc-свидетель (±2.5 с), справочно;
  • зелёная линия — кривая band (дрейф), с разрывами на резах;
  • purple-пунктиры — резы детекта аудио.

gcc — НЕ судья. Серая gcc-линия на контенте проекта (закадр+музыка) почти слепа и скачет ±40к = шум; её нельзя брать за подтверждение укладки. Судьи сходимости — сам band (48 полос) / muq + уши/глаза на наложенных дорожках (память feedback_gcc_ncc_blind_judge). gcc на графике — фон, не вердикт.

15.2. Как цвет читается мгновенно

Цвет точки = уверенность. Тёмные (фиолетовые/синие) — шумные, ярких (жёлтые/зелёные) — надёжные. Правильная линия идёт по ярким точкам. Если линия отклонилась, а рядом плотное облако ярких — это сигнал проблемы: либо увод укладки, либо линия последовала за тусклыми выбросами на краю. Это и есть «диагноз с одного взгляда».

🖼️ ИЗОБРАЖЕНИЕ 15.1 — «Эталон единого графика (здоровая дорожка)». Реальный скрин двухпанельного графика: вверху зрение — плотное жёлтое облако якорей точно на 0, оранжевая линия плоская, резов нет; внизу звук — узкое облако вокруг 0, зелёная линия плоская. Подпись: «Так выглядит ОК: линии по ярким точкам, ноль отклонения, нет резов». (Скрин взять из <серия>/_plots/<stem>__track.png любой здоровой пары.)

🖼️ ИЗОБРАЖЕНИЕ 15.2 — «Перекос укладки (до фикса опоры-после-реза)». Скрин зоны 180–220 с AniDUB: после реза линия band провалилась вниз за парой тусклых (тёмных) якорей у края, хотя выше плотное плато ярких якорей на другом уровне. Рядом — тот же участок после фикса: линия держится за плато (опора = увереннейший якорь начала плато), слепой старт подтянут к плато. Подпись: «Линия не должна вестись за тусклыми единицами на краю».

🖼️ ИЗОБРАЖЕНИЕ 15.3 — «Ложный рез из-за слепоты зрения (ep05 Amazing, 950–979 с)». Скрин зоны с двумя близкими резами: до фикса между ними узкий клин и лишний рез (окно проверки ±80 с перехлестнуло через соседний рез и поймало чужой уровень); после фикса (окно ограничено соседними резами) лишний рез убран. Подпись: «Окно проверки уровня ограничивается соседними резами — иначе чужой уровень ложно подтверждает рез».

15.3. Носители: PNG-превью и интерактивный HTML

  • PNG (matplotlib, Agg) — статичная миниатюра (1 или 2 панели) для быстрого взгляда в панели/отчёте.
  • HTML (plotly, self-contained, открывается офлайн) — для детального разбора. Точки и линия в WebGL (scattergl), чтобы линия рисовалась поверх точек независимо от порядка; ховер x unified, зум любой зоны вручную. Отдельные PNG каждого реза не создаём — HTML позволяет приблизить любой стык (решение 2026-06-18).

Read-only по контракту: падение рендера не роняет conform (ловит вызывающий).



Приложения

<a name="приложение-а-константы"></a>

Приложение А. Константы и их обоснования

Все ключевые числа калиброваны не «на глаз», а по плато нечувствительности: на свипе по диапазону результат (резы по датасету) не меняется — значит подстройки под контент нет.

Зрение

Константа Значение Файл Смысл / обоснование
GW×GH 128×72 features размер серого кадра под SRM
clip T ±3 features обрезка выбросов отклика фильтров
dim 18432 features 212872, два канала
K (coarse) 8 coarse прорежение грубого прохода
W 80 coarse полуокно зануления при поиске 2-го пика
VHI 0.60 coarse порог «пик высокий» (надёжный якорь)
CMIN 0.12 coarse порог различимости (пик одинок → не двойка)
DROP 0.20 dropdtw штраф выброса (базовый Drop-DTW)
OPEN/EXT 0.20 / 0.02 dropdtw gap-open / gap-extend (острый шов)
DSYN 0.30 dropdtw/align плоская цена выброса кадра дубля (вставка)
MATCH_THR 0.30 dropdtw/align guard: кадр с матчем лучше этого нельзя выбросить
MARG 120 band_align полуширина следящей полосы (≈5 с)
CHUNK/OVERLAP 4000 / 700 band_align кусок Drop-DTW и перекрытие
VIS_WIN 0.3 vision_detect окно агрегации якорей (плато 0.2–0.5)
VIS_PEN 800 vision_detect штраф DP-сегментации (плато 200–3000)
VIS_QPOW 3.0 vision_detect степень веса = (cos·agree)³ (плато 2–3)
VIS_SMAX 0.45 vision_detect потолок наклона прямых, к/с (плато 0.3–0.6)
VIS_MSIZE 30 vision_detect мин. длина сегмента, с (плато 8–60)
VC_WIN_S 80 vision_detect окно проверки устойчивости уровня (плато 45–120)
VC_GATE 0.55 vision_detect вес-гейт надёжных узлов (плато 0.3–0.8)
VC_THR 200 мс vision_detect порог величины реза (плато 60–400 мс)

Звук

Константа Значение Файл Смысл / обоснование
sr 16000 band частота анализа
NB 48 band число лог-полос (50–14000 Гц)
nfft/hop 2048 / 256 band STFT (темп огибающей 62.5 Гц)
win 5 с band окно анализа
MAXLAG 0.7 с band рабочее окно лага (точность)
COARSE_LAG_S 2.5 с apply окно грубой off0 (видит сдвиг опенинга)
tol 2 кадра band допуск согласия полос (agree)
FRAME 41.708 мс params мс/кадр (23.976 fps)
STEP 0.5 с params шаг сетки T
MIN_FR 3 (120 мс) params порог величины реза
PEN 700 params штраф DP-сегментации (детект)
QPOW 3.0 params степень веса якоря
SMAX 0.3 params макс. наклон сегмента (≤~2%)
MSIZE_S 20 с params мин. длина сегмента
max_pct_s 1.25 %/с apply потолок скорости кривой дрейфа
anchor_lookback_s 30 с apply окно поиска опоры-после-реза
SIL_FILL_DB −90 дБ align порог тишины озвучки (центр плато дыр)
XFADE 30 мс align кроссфейд заполнения рефом

Правило прогонов (память feedback_warn_long_runs): любой тест ≤10 мин — параллелить (клипы --workers 8, конформы ×6 пар, синтетика --workers 24) или резать объём; дольше 10 мин — только с явного согласия пользователя, оценку времени — заранее.

<a name="приложение-б-карта-файлов"></a>

Приложение Б. Карта файлов

src/track_muxer/conform/
├── features.py              SRM-отпечатки кадров (KB/D1, L2, dim 18432)
├── kernel/
│   ├── coarse.py            грубый проход: G=cs·crᵀ, якоря, LIS, оконный
│   ├── dropdtw.py           Drop-DTW (base/affine/guard/free_start)
│   └── band_align.py        следящая полоса + склейка кусков
├── vision_detect.py         УКЛАДКА зрения: (o,w) → детект → ломаная (ЕДИНСТВЕННАЯ карта)
├── align.py                 оркестровка пары: pred → разбор правок → ресэмпл → звук → график
└── anchor/                  АУДИО-доводка band/muq поверх зрения
    ├── apply.py             coarse_off0, следящий band, smooth_drift_curve, варп, fill
    ├── detect.py            ОБЩИЙ аппарат: wfit, warp_curve, tail_filter, edge_refine
    ├── assemble.py          freeze + сборка варпа
    ├── params.py            FRAME, T, STEP, PEN, QPOW, SMAX, MSIZE_S …
    ├── maps/band.py         48-полосная DSP-метрика (без модели) — дефолт
    ├── maps/muq.py          GPU-эмбеддинги (альтернатива)
    ├── plots_unified.py     ЕДИНЫЙ график зрение+звук (PNG + HTML)
    └── plots_html.py        отдельный HTML band (легаси-путь)
Тема Отчёт (суть)
Прорыв видео-синхрона doc/reports/audio_align/BREAKTHROUGH_VIDEO_SYNC.md
Методика зрячих свидетелей аудио doc/reports/audio_bench/METHODOLOGY_audio_matching.md
Грубый→band-follow doc/reports/audio_bench/EXPERIMENTS_coarse_band_follow.md
Аудио-аанкер (band/muq) doc/reports/audio_bench/EXPERIMENTS_audio_auto.md
Архитектура conform doc/conform_architecture_audit.md §14



Версия документа: 2026-06-18, под архитектуру «анализатор зрения — единственная видео-карта; band/muq — доводка поверх». Числа и формулы сверены с боевым кодом src/track_muxer/conform/**. При расхождении кода и документа — приоритет у кода, документ актуализировать.