nlve/README.md
4aiman f841ed79ec Честное предупреждение о контейнере и отметка недописанной сборки
У MP4 два неудобства, о которых человек узнавал поздно: прерванную
сборку в нём продолжить нельзя вовсе, а перекодированный стык точного
реза воспроизводится правильно не везде. Теперь об обоих говорится до
начала работы, одним окном с кнопками «Собрать в MKV», «Всё равно mp4»,
«Не собирать».

Признак точного реза заодно исправлен: смотрели на отметку edPrecise,
а резы стали точными всегда — предупреждение молчало. Теперь признак —
клип, начинающийся не с начала файла.

Продолжить прерванную сборку предлагается по отметке рядом с файлом, а
не по его наличию: оборванный MKV открывается и играет, и отличить его
от готового больше нечем. Отметка ставится при неудаче, снимается при
успехе и хранит число записанных кадров — его и показывают в вопросе.

Ещё: на каждом стыке кусков приёмник писал «Packet corrupt», по паре
строк на кусок. Данные целы, но журнал забивался, и настоящую ошибку в
нём было не найти. Лечится ключом -mpegts_flags +initial_discontinuity
у отправителя: сверено по кадрам и по md5, результат тот же.
2026-09-30 16:48:31 +03:00

79 KiB
Raw Blame History

nlve

Нелинейный видеоредактор без перекодирования. Куски видео вырезаются и склеиваются копированием потока, поэтому сборка ролика занимает время копирования файла, а не время рендера.

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

Сборка

./build.sh              редактор под эту систему
./build.sh windows      редактор под Windows (кросс-сборка)
./build.sh tool         консольный инструмент проверок
./build.sh test         инструмент плюс прогон проверок
./build.sh all          всё перечисленное
./build.sh run          собрать и запустить
./build.sh clean        убрать промежуточные файлы

Используется тулчейн fpcupdeluxe, а не системные fpc и Lazarus: системный Lazarus другой версии, и собранные им модули LCL с нашими несовместимы. Другой корень задаётся переменной: FPCUP=/путь ./build.sh.

То же самое вручную:

/home/chaiman/fpcupdeluxe/lazarus/lazbuild --primary-config-path=/home/chaiman/fpcupdeluxe/config_lazarus nlve.lpi
/home/chaiman/fpcupdeluxe/lazarus/lazbuild --primary-config-path=/home/chaiman/fpcupdeluxe/config_lazarus --cpu=x86_64 --os=win64 --ws=win32 nlve.lpi

Требования во время работы: libmpv.so.2 (пакет mpv), ffmpeg и ffprobe в PATH. Линковки с libav* нет — ffmpeg вызывается внешним процессом.

Запуск

./nlve                          # пустой проект
./nlve <проект.nlve>            # открыть проект
./nlve <видео> <картинка> ...   # добавить файлы в новый проект

Окно перепаковки открывается из меню «Файл». Здесь раньше значился запуск ./nlve remux <файл>... — в коде этого нет и не было: слово remux не является именем файла и молча пропускалось.

Встраивание в рабочий стол

tools/install-desktop.sh              # берёт ./nlve рядом с репозиторием
tools/install-desktop.sh /путь/к/nlve

Скрипт кладёт пункт меню, значки и описание типа файла — всё в домашний каталог, без прав администратора. После этого *.nlve перестают быть просто текстом: у них своё имя типа (application/x-nlve-project), свой значок и открытие двойным щелчком. Проверяется так:

xdg-mime query filetype проект.nlve   # application/x-nlve-project
xdg-mime query default application/x-nlve-project

Внутри проект — обычный JSON, и тип объявлен наследником application/json. Наследником простого текста делать не стали: тогда файл снова открывался бы редактором по умолчанию.

Файловый менеджер может показать новый значок не сразу — ему нужно перечитать базу.

Управление

Действие Клавиши
Пуск и пауза Пробел
В начало / конец монтажа Home / End
Показать, где курсор (окно едет к нему) \
Разрезать по курсору (все дорожки) S
Удалить выделенное Delete
Выделить всё Ctrl+A
Выделить областью протяжка по пустому месту дорожек
Режим выделения: рамкой или по времени D
Схлопнуть дыру под курсором Ctrl+Shift+R
Отменить, повторить Ctrl+Z, Ctrl+Y
Масштаб таймлайна Ctrl + колесо мыши
Прокрутка по дорожкам колесо мыши
Прокрутка по времени Shift + колесо, средняя кнопка
Прокрутка журнала вбок Shift + колесо над журналом
Растянуть форму звука по высоте Shift + стрелки вверх и вниз
Выключить дорожку / оставить только её кнопки М и С в заголовке
Громкость клипа тянуть ручку на клипе вверх-вниз
Автосхлопывание вкл/выкл Ctrl+K
Копировать и вставить клипы Ctrl+C, Ctrl+V
Скопировать и применить оформление Ctrl+Shift+C, Ctrl+Shift+V
Связать или развязать клипы Ctrl+G
Отменить прилипание на время Shift при перетаскивании
Вписать проект целиком Ctrl+0
Новый, открыть, сохранить проект Ctrl+N, Ctrl+O, Ctrl+S
Добавить файлы Ctrl+I
Собрать ролик Ctrl+E

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

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

Если бросить клип ниже всех дорожек, заведётся новая.

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

Клип переносится перетаскиванием, в том числе на другую дорожку и на пустое место под последней — тогда дорожка тоже добавится. За края клипа он растягивается и укорачивается. Картинку можно растянуть на любую длину, видео — только в пределах исходника.

Щелчок по клипу переставляет курсор воспроизведения. Именно щелчок: если кнопку нажали и потянули, курсор остаётся на месте, в том числе во время воспроизведения.

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

Прилипание курсора (кнопка с клеем). Курсор, поставленный щелчком, притягивается к границам клипов на любой дорожке. Без этого в стык не попасть: на общем плане одна точка экрана — это секунды материала, а ставят курсор чаще всего именно на стык. Shift при щелчке работает так же — меняет решение на противоположное.

Переключателя два, а не один, потому что это разные привычки: клипы липнут при перетаскивании, курсор — при щелчке, и одно бывает нужно без другого.

Курсор посередине (кнопка с целью). Включено — при воспроизведении монтаж едет под курсором, и всегда видно, что будет дальше. Выключено — окно не двигается вовсе, даже когда курсор ушёл за край: отмотал Shift с колесом — там и останешься. Раньше в выключенном состоянии окно всё равно подтягивалось к курсору, стоило тому выйти из видимой части, и прокрутка отскакивала назад со второго поворота колеса.

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

Считается это по площади самого оверлея, а не всего кадра. Замер на 2688x1512 (600 кадров, десять секунд материала): основа 2.07 с, с наложением 2.43 с, с ключом и прозрачностью 2.66 с. То есть наложение стоит 0.36 с, ключ добавляет ещё 0.23 с — вчетверо быстрее реального времени. На слабой машине это может быть заметнее, потому выключатель и есть.

Отмеченный участок. У линейки два этажа. Верхний, в двенадцать точек, — про отметки: протяжка по нему отмечает участок, даже если отметок не было, а щелчок без движения их снимает. По краям участка стоят жёлтые уголки, за них тянут. Нижний этаж — про курсор, как и раньше.

Клавиши тоже остались: I ставит начало по курсору, O — конец; оба пункта есть в меню «Правка», там же снятие отметок. Пока он отмечен, сборка собирает только его — это единственный способ проверить правку на длинном монтаже, не тратя на каждую проверку столько же времени, сколько идёт сам ролик. Отметки живут в файле проекта: участок относится к монтажу, а не к привычкам человека.

Дорожки. Слева в заголовке две стрелки: поднять и опустить. У верхней дорожки некуда вверх, у нижней вниз — такая стрелка нарисована тускло и не нажимается. Заголовок можно и просто потащить мышью. Двойной щелчок по названию переименовывает дорожку: на монтаже из десятка дорожек «карта», «звук мотора» и «титры» читаются несравнимо лучше, чем V1, A2 и Т1.

Правый щелчок по заголовку открывает меню: поднять, опустить, переименовать, удалить. Удаление дорожки с клипами спрашивает подтверждение и называет число клипов.

Копирование клипов. Ctrl+C копирует выделенные клипы, Ctrl+V кладёт копии по курсору. Отбивку или текстовый блок так размножить проще, чем заводить заново и настраивать оформление с нуля.

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

Дорожка выбирается сама: сначала та, откуда копировали, если это время на ней свободно; потом любая подходящая по виду; в крайнем случае заводится новая. Класть копию поверх соседа нельзя — на одной дорожке это означает переход, а его никто не заказывал.

Копирование свойств клипа. Ctrl+Shift+C берёт с выделенного клипа всё оформление — кадрирование, поворот, увеличение, смещение, прозрачность, цветовой ключ, затухания, громкость, — Ctrl+Shift+V применяет их ко всем выделенным. Вид клипа и его текст при этом не переносятся: свойства копируют между разными клипами, и превращать картинку в надпись незачем.

Схлопывание. Кнопка внизу показывает и режим, и состояние: утоплена — автосхлопывание включено, и тогда при перетаскивании клипа всё, что начинается не раньше него, едет следом. Стрелка рядом открывает выбор режима: только текущая дорожка или все. Ctrl+K включает и выключает.

Режим действует и при выключенном автосхлопывании — по нему работает Ctrl+Shift+R, который схлопывает дыру под курсором. То есть выключенная кнопка означает лишь «не двигать ничего само собой при перетаскивании».

Во время воспроизведения курсор держится посередине окна, а монтаж едет под ним. При нажатии на пуск, если курсор оказался за краем видимой части, вид подтягивается к нему — масштаб при этом не меняется.

На каждом клипе проходит линия громкости с ручкой посередине видимой его части: тянешь ручку вверх и вниз — меняется громкость, от тишины до двойной. Ручка стоит посередине именно видимой части, а не всего клипа: до середины часового клипа, уехавшей за край экрана, мышью не дотянуться.

Громкость, отличная от обычной, — единственное место, где звук перекодируется: изменить громкость, не тронув сам звук, нельзя. Видео при этом по-прежнему копируется. Проверено: клип с громкостью 30% дал в ролике уровень на 10.5 дБ ниже исходного при ожидаемых 10.46 дБ.

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

В списке файлов слева показывается кадр из каждого файла, его параметры и длительность. Кадры делаются в фоне и запоминаются в кэше проекта.

На клипах видна форма звука. Считается она по заранее разобранным пикам — сто значений в секунду, каждое есть наибольший модуль отсчёта за свои десять миллисекунд. Рисовать по настоящим отсчётам нельзя: в секунде их 48 тысяч, а в клипе на экране сотни пикселей.

Разбор идёт в фоне и запоминается в кэше проекта. Для записи на 892 с это около 13 секунд — столько занимает чтение шести гигабайт с диска, и быстрее не выйдет; при следующем открытии проекта пики берутся готовыми.

Shift со стрелками вверх и вниз растягивает форму по высоте. Звук бывает тихим, и угадывать за человека, во сколько раз растянуть картинку, неправильно. Усиление меняет только рисунок, самого звука не касается, и запоминается между запусками.

Сохранение и автосохранение

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

Запись не оставляет огрызков. Проект пишется во временный файл рядом и подставляется на место настоящего, только когда записан целиком. Прежний файл при этом становится запасным — имя.nlve.bak. Падение или кончившееся место посреди записи больше не оставляют половину проекта.

Копия пишется сама. Пока в проекте есть несохранённое, раз в полминуты рядом появляется имя.nlve.autosave. Это не сохранение: файл проекта не трогается, признак «изменён» остаётся.

При открытии проекта программа смотрит, нет ли рядом копии новее его самого. Есть — значит прошлая работа до файла не дошла, и программа показывает оба времени и предлагает открыть копию. Отказ копию не удаляет: она лежит, пока проект не сохранят заново. После сохранения копия не нужна и убирается.

Кнопки внизу

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

Подсказка идёт в строку состояния, а не во всплывающее окно. Всплывающее окно появлялось прямо поверх кнопки, о которой рассказывало, и перехватывало щелчок: нажать кнопку было нельзя, пока подсказка не уйдёт. Теперь наведение мыши пишет ту же фразу в строку состояния справа от кнопок — она ничего не закрывает. Щелчок по кнопке подсказку тут же переписывает: «Журнал виден» становится «Журнал спрятан».

Строка состояния занимает всё свободное место справа и ужимается только на время, пока рядом показан ход работы.

Каждый щелчок считается. Раньше второй щелчок подряд оконная библиотека отдавала как двойной, и до кнопки он не доходил: нажимаешь журнал три раза быстро — срабатывает один. Теперь двойных щелчков кнопкам не приходит вовсе, и любая очередь щелчков отрабатывается вся (tests/lfmcheck, раздел «Кнопки нижней панели»; tests/buttonclicks.sh проверяет то же самое мышью на невидимом экране).

Два режима выделения

Кнопка внизу (справа от схлопывания) и клавиша D. Режим показан значком: рамка — выделение клипов, промежуток со стрелкой — выделение по времени.

Рамкой по клипам. Протяжка по пустому месту дорожек выделяет всё, что рамка задела. Обычное выделение: выделил, нажал Delete.

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

Зачем второй режим: вырезать из фильма кусок целиком. Отметил промежуток, нажал S — все дорожки разрезаны в двух местах, по краям промежутка, и выделенной осталась ровно середина. Дальше Delete, и при включённом автосхлопывании дыра закрывается сама.

Края промежутка кладутся на кадровую сетку: между кадрами времени нет.

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

Починка проекта

Меню «Проект» → Проверить и починить проект.... Показывает, что нашла, и чинит только по согласию; отменяется через Ctrl+Z.

Что ищется:

  • клипы мимо кадровой сетки. Между кадрами времени нет, а в старых проектах времена записаны как попало: 2.457599 с при 59.94 к/с — это 147.31 кадра. Картинка всё равно выйдет целым числом кадров, звук наберётся с точностью до отсчёта, и предпросмотр со сборкой не смогут совпасть в точности. Починка двигает клип не больше чем на полкадра; на проекте из 82 клипов общая длина изменилась на 0.028 с, то есть на полтора кадра за два часа;
  • клипы на дорожках чужого вида. Такой клип нарисован на монтаже, но в готовый ролик не попадает. То же самое предлагается при открытии проекта, здесь — по требованию;
  • дорожки с повторяющимися именами.

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

Файл не совпадает с проектом

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

Пересобираются только они. Остальной материал по-прежнему копируется без потерь и почти мгновенно. Ролик целиком не перекодируется никогда.

Как именно кадр ложится в кадр проекта, решает окно «Кадрирование и поворот»: вписать целиком с полями, заполнить кадр с обрезкой или растянуть, плюс поворот на 90, отражение, увеличение и смещение.

Слева в окне — кадр источника с рамкой: её тянут мышью за середину и за правый нижний угол, и она держит пропорции кадра проекта. Справа — настоящий результат, пропущенный через ту же цепочку фильтров, что пойдёт в дело.

Настройка принадлежит клипу, а открывается кнопкой в правом нижнем видимом углу самого клипа. Именно видимом: у клипа длиной в час настоящий угол почти всегда за краем экрана. Два куска одного файла можно кадрировать по-разному.

У файла в библиотеке хранится такая же настройка, но она лишь заготовка: её берёт новый клип при добавлении. Правится она из контекстного меню библиотеки.

По умолчанию кадр вписывается целиком. Растяжение надо выбрать руками: раньше оно применялось молча, и вертикальное видео размазывалось по ширине.

«Приведение к параметрам проекта» делает ту же работу один раз при добавлении: файл перекодируется целиком, зато каждая последующая сборка остаётся быстрой.

Что и почему происходит с каждым файлом, видно в окне «Проект → Сведения о проекте»: параметры проекта, параметры каждого файла, выбранное вписывание и вердикт — копируется, пересобирается или из него делается клип. Там же кнопка полного ответа ffprobe.

Что показывает предпросмотр

Предпросмотр показывает кадр проекта, а не кадр исходника. Файл другого соотношения сторон виден с полями по краям — ровно так, как он попадёт в готовый ролик. Проверено сравнением: кадр предпросмотра и кадр сборки совпадают бит в бит.

Само по себе это не бесплатно, поэтому включается только когда есть что вписывать. Разное разрешение при одинаковом соотношении сторон (скажем, 3840x2160 в проекте 2688x1512) ничего не требует: mpv сам впишет такой файл в панель, и картинка совпадёт до пикселя.

Когда вписывание нужно, декодирование переключается на вариант с копированием кадров в обычную память — цепочка фильтров не умеет работать с кадрами, лежащими в памяти видеокарты. Аппаратное декодирование при этом сохраняется, видно в журнале: nvdec без вписывания, nvdec-copy с ним. Замер на материале 2688x1512 при 60 к/с: 327 кадров в секунду без фильтра, 230 с настоящим масштабированием — запас четырёхкратный.

Чего предпросмотр не умеет: показать собственную настройку кадрирования файла. Цепочка фильтров у mpv одна на всю склейку, а заполнение с обрезкой, поворот и смещение задаются для каждого файла отдельно — выбрать между ними общий фильтр не может, поэтому он всегда вписывает целиком. Точную картинку для такого файла показывает окно «Кадрирование и поворот».

Цвета окна

Все цвета заданы в uTheme и применяются принудительно, а не берутся у системы. Причина простая: под Linux вид назначает тема GTK, под Windows — тема оформления, и на светлой теме тёмный таймлайн оказывается вставленным в белую рамку.

Что для этого пришлось сделать:

  • Свои кнопки (TThemedButton). Системная кнопка под Windows свой цвет не отдаёт: LCL рисует её через службу оформления, и та кладёт светлую подложку поверх заданного цвета. Проверено по исходникам LCL — TCustomSpeedButton.PaintBackground всегда вызывает ThemeServices.DrawElement. Поведение обычной кнопки сохранено: Default, Cancel, ModalResult, работа по Enter и Esc.
  • Своя полоса меню из плоских кнопок со всплывающими списками. Системную полосу рисует Windows, и её фон нашим цветом не закрашивается: SetMenuInfo меняет подложку пунктов, но не самой полосы. Сочетания клавиш при этом не теряются — их ищет TMenu.IsShortCut, вызываемый из TMainForm.FormShortCut. Ценой стал переход по меню клавишей Alt: его больше нет.
  • Своё окно сообщения (ThemedMessageDlg) вместо MessageDlg.
  • Отрисовка пунктов меню и строк выпадающих списков своими руками.

Чего сделать нельзя, и это надо знать:

  • полосы прокрутки рисует система, и цвет им задать нечем. Единственный способ получить их в своих цветах — не пользоваться системными вовсе. Так и сделано: журнал (uLogView) и таблицы (uThemedScroll) обходятся своими. Осталась системная полоса у списка файлов слева — там её нечем выключить, не переписав сам список;
  • рамка окна и её кнопки принадлежат оконному менеджеру;
  • под gtk2 пункты меню своими руками не рисуются — в LCL это прямо помечено как неподдерживаемое (interfaces/gtk2/issues.xml), там их цвет назначает тема GTK. Под Windows рисуются, там это и нужно.

Зависимости и запуск под Wine

Программе нужны ffmpeg, ffprobe и libmpv. Под Linux они ставятся пакетным менеджером. Под Windows ставить нечем, поэтому файлы кладутся рядом с программой, а меню «Файл → Зависимости...» умеет их скачать.

Список и адреса лежат в deps-windows.json или deps-linux.json рядом с программой — имя своё для каждой системы. Общий файл приводил к тому, что запущенная под Wine программа записывала windows-список, а сборка под Linux потом искала по нему .dll.

Где имя файла сборки меняется с каждой датой, адрес не задаётся: программа спрашивает последний выпуск у GitHub. Архивы .7z распаковываются внешним 7zr.exe — он тоже есть в списке и качается одним файлом, поэтому замкнутого круга не возникает.

Под Wine программа узнаёт себя по наличию wine_get_version в ntdll и настраивается сразу:

  • вывод через OpenGL, а не Direct3D 11: последний в Wine падает при создании контекста, ещё до первого кадра;
  • аппаратное декодирование выключено. Дело не в бесполезных попытках: для DXVA2 mpv создаёт устройство Direct3D 9, а Wine при его создании сбрасывает гамму экрана в единицу — настройку всей системы. Замерено: с auto-safe гамма 0.89 превращалась в 1.0 при каждом открытии проекта, с no остаётся нетронутой.

На настоящей Windows обе настройки остаются обычными.

Журнал дублируется в nlve.log рядом с программой. Нужно ровно для случая, когда программа падает до появления окна: иначе всё, что она о себе рассказала, пропадает вместе с окном.

Наложение, переходы и прозрачность

Ползунок на клипе картинки задаёт непрозрачность, на звуковом — громкость. Клип с непрозрачностью меньше единицы смешивается с тем, что под ним.

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

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

Два клипа на одной дорожке, наползшие друг на друга, дают переход: один плавно перетекает в другой. На разных дорожках перекрытие означает не переход, а наложение — там по-прежнему побеждает верхний, если он непрозрачен.

Различие не косметическое. Если бы кроссфейдом считалось любое перекрытие, то в проекте, где клипы на разных дорожках перекрываются постоянно, каждое такое место пришлось бы пересобирать.

У клипа есть плавное появление и уход. Для картинки это затемнение, для звука — нарастание громкости.

Всё это стоит перекодирования — но только своего участка. Перед сборкой программа сообщает, сколько секунд из скольких придётся пересобрать, и даёт отказаться.

Звук при наложении сводится прямо в отсчётах, без фильтров ffmpeg: доля каждого слоя считается на каждый отсчёт, и длина результата получается ровно такой, какая нужна.

Текст режет кусок, а не пересобирает его целиком

Вжигание надписи не делает весь кусок пересобираемым. Кусок режется на части: до надписи, под надписью и после. Пересобирается только середина, остальное копируется. Надпись в тридцать секунд внутри сорокаминутного куска раньше стоила сорока минут пересборки — половины ролика.

Часть, которая начинается не с начала куска, помечается точным резом: копия пошла бы от ближайшего опорного кадра и сдвинула бы картинку. Сборка перекодирует огрызок до опорного кадра, а дальше снова копирует — доли секунды вместо минут.

Обрезки короче секунды не копируются, а дожигаются вместе с надписью: возни с опорными кадрами там больше, чем выгоды.

Качество пересобираемых кусков

Куски, которые нельзя скопировать, целятся в наибольший битрейт среди файлов проекта. Без этого кодировщик берёт своё значение по умолчанию, и оно скромное: на съёмке 2688x1512 с 56 Мбит/с NVENC выдавал 5.9 Мбит/с — пересобранный кусок бросался в глаза среди скопированных соседей.

Кроме среднего задаётся потолок в полтора раза выше и буфер вдвое: без них кодировщик разгоняется на сложных кадрах ровно настолько, насколько позволит средний, и мелкие детали всё равно теряются.

Берётся именно наибольший, а не средний: пересобранный кусок стоит рядом со скопированными, и если он окажется хуже их, разница видна именно на нём. Лишнее место дешевле.

Место на диске

Куски весят примерно столько же, сколько готовый ролик, а на длинном материале и больше: на часовой съёмке набралось 110 ГБ. Поэтому:

  • картинка не собирается в промежуточный файл. Звук собирается раньше, и склейка сразу кладёт всё вместе в конечный файл. Раньше получалось два полных ролика на диске одновременно — 91 ГБ в кэше и 91 ГБ рядом — и лишний проход копирования ради одной звуковой дорожки;
  • после удачной сборки куски удаляются, и в журнале сказано, сколько места освободилось. После неудачной — остаются: с них продолжают.

Оставить их можно настройкой keep_parts в файле настроек: пригодится, когда разбираешься, из чего собрался ролик.

Дыры и вставки

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

Готовые вставки делаются двух длин, 15 и 60 секунд. Дыра длиннее минуты набирается повторами одного и того же минутного файла: и список кусков предпросмотра, и список склейки повторы принимают. Раньше лестница длин доходила до восьми часов, и дыра в сорок минут требовала двухчасового чёрного файла — программа выглядела зависшей.

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

Если правка монтажа сделала идущую генерацию ненужной, она прекращается: программа проверяет, нужна ли ещё та вставка, что делается сейчас.

Прерванная сборка

Если сборка не дошла до конца — упала, была отменена или программу закрыли, — при следующей сборке в тот же файл программа предложит продолжить.

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

Дописать в конец существующего MKV нельзя — Matroska этого не даёт, — поэтому продолжение всегда пишется в новый файл, и на диске в этот момент лежат оба. Места нужно вдвое больше готового ролика.

MP4 продолжать нельзя вовсе: оглавление пишется в конец, и оборванный файл не открывается — moov atom not found. Программа говорит об этом прямо и собирает заново. Если сборка длинная, лучше собрать в MKV, а потом переупаковать в MP4 окном ремукса: это копирование потоков, без перекодирования.

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

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

Сколько осталось

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

Как собирается ролик

  1. Монтаж уплощается в последовательность непересекающихся кусков. Перекрытия разрешаются в пользу верхней дорожки, дыры превращаются в чёрные вставки.
  2. Картинки и дыры материализуются в клипы в параметрах проекта. Иначе их нечем показать: подсунуть mpv картинку или генератор прямо в списке кусков нельзя, он на этом зависает. Заготовки делаются готовых длин (15 с, 60 с, 5 мин и так далее), а нужный кусок из них вырезается — тогда любое движение клипа не требует новой генерации.
  3. Границы кусков кладутся на кадровую сетку проекта: между кадрами времени нет, а звук набирается с точностью до отсчёта, и доли кадра разводят их в стороны.
  4. Рез всегда точный: огрызок от точки реза до ближайшего опорного кадра перекодируется, остальное копируется. Копировать можно только с опорного кадра, поэтому «резать где угодно и ничего не перекодировать» не бывает — но платить приходится долями секунды на рез.
  5. Каждому куску заказывается точное число кадров (-frames:v), а не только время: при копировании ffmpeg отмеряет конец по времени декодирования, и у записи с B-кадрами кусок выходит длиннее запрошенного.
  6. Куски идут в готовый файл потоком. Каждый режется своим ffmpeg прямо в MPEG-TS на стандартный вывод, все потоки подряд переливаются в один принимающий ffmpeg, а тот пишет готовый файл. Место куску в общем потоке задаёт -output_ts_offset, считаемое в кадрах от начала. На диск не ложится ничего, кроме результата и звуковой дорожки.
  7. Результат сверяется дважды: сумма кадров кусков обязана совпасть с числом пакетов в готовом файле, а число кадров — с тем, что просил монтаж. Второе и есть проверка синхронности звука с картинкой; вердикт пишется в журнал сборки.

Звук собирается раньше картинки: приёмник берёт его вторым входом. В кусках картинки звука нет — звуковая дорожка собирается отдельно и точнее, а лишний поток в куске MPEG-TS ещё и сбивает тайминг склейки: замерено 36 мс на стык.

Через кэш кусков собирается то, что потоком не выходит: когда кодек проекта не переносится в MPEG-TS или когда весь ролик пересобирается под площадку одним кодировщиком. Тогда каждый кусок пишется в отдельный файл, а склейка идёт concat-демультиплексором с явно указанной длительностью каждого. Этот путь и был единственным раньше; оба дают побитово один и тот же ролик — сверено на материале с дрона, PSNR бесконечен на каждом кадре. Разница в цене: на отрывке в 17.5 с кэш весил 152 МБ против 421 КБ, а времени ушло 17.5 с против 14.2 с.

Напрашивающийся способ — один проход с директивами inpoint и outpoint, без временных файлов — проверен и отвергнут: он давал 574 кадра вместо 600 и не пересчитывал метки времени.

Ловушки, на которые уже наступили

Все перечисленные ниже вещи найдены замерами, а не рассуждениями.

Локаль. libmpv отказывается создаваться, если числовая часть локали процесса не C: mpv_create возвращает nil. В русской локали десятичный разделитель запятая, и разбор чисел внутри mpv ломается. Лечится вызовом ForceCNumericLocale до mpv_create (uMpvLib).

Панель без окна. Под gtk2 панель LCL не имеет собственного окна X11, и запрос окна возвращает окно формы. Отдав такой идентификатор mpv, получаем видео поверх всего интерфейса. Поэтому под X11 создаётся собственное дочернее окно по границам панели (EnsureVideoWindow в uMpvPlayer).

Относительные пути в списке склейки. Путь с префиксом file: внутри списка concat разбирается относительно текущего рабочего каталога, а не относительно самого списка. Все пути приводятся к абсолютным сразу.

Смешивание кусков от разных кодировщиков. MP4 и Matroska хранят набор параметров кодека один на дорожку. Как только рядом оказываются скопированный кусок из камеры и созданная нами вставка, декодер применяет параметры первого ко всем: замерено 384 ошибки декодера HEVC при верном числе кадров. Лечится склейкой через промежуточный MPEG-TS, где параметры повторяются перед каждым опорным кадром — стало 0 ошибок. Способ выбирается автоматически: один источник и никаких точных резов — прямая склейка, иначе MPEG-TS. Резы теперь всегда точные, так что на деле это почти всегда MPEG-TS.

Лишний кадр на стыке точного реза. При копировании -to работает как «строго меньше», при перекодировании — как «меньше либо равно». Кадр на границе попадал в ролик дважды: 601 вместо 600. Лечится отступом на полкадра от границы.

B-кадры во вставках. Двунаправленные кадры от NVENC при переносе в Matroska давали метки декодирования, идущие назад, и двадцать совпадающих меток на шестьсот кадров. Коротким вставкам B-кадры не нужны, поэтому они запрещены (-bf 0).

Звук отставал от картинки — четыре причины сразу. Разбор занял день, и каждая находка мерялась, а не угадывалась (tests/syncprobe.sh: исходник со вспышкой и щелчком на каждой секунде, монтаж с резами не на опорных кадрах, поиск обоих следов в готовом ролике).

  1. Притянутый рез. Начало куска картинки сдвигалось на ближайший опорный кадр, а звук брался из непритянутой точки: в журналах пользователя средняя притяжка 0.24 с, худшая 0.99 с. Лечится точным резом всегда.
  2. Список правок MP4. Разбор заголовка не учитывал блок elst, и метки опорных кадров уезжали на задержку показа — на пробе два кадра. Рез просил перекодировать до метки, которая опорным кадром не была.
  3. Конец куска при копировании. -to отмеряет по времени декодирования, и у записи с B-кадрами кусок выходил длиннее запрошенного. Лечится явным числом кадров (-frames:v).
  4. Длительность куска в склейке. Последний пакет скопированного куска показывается позже всех прочих, контейнер считает его в длительность, и склейка растягивала время — 0.05 с на кусок. Лечится директивой duration в списке склейки. Туда же: звук внутри кусков картинки сбивал выравнивание на 36 мс, теперь его там нет.

Плюс «разогрев» кодировщика звука: сжатый AAC начинается со служебных отсчётов, MP4 помечает их пропускаемыми, Matroska такой пометки не хранит — звук ехал на 21.3 мс позже. Величина меряется пробным сжатием и вычитается.

Итог замера на образце: было до 0.6 с и росло по ходу ролика, стало 0.02-0.04 с без накопления, при точности самого измерения 0.017 с.

Вставка под каждую длину дыры. Сначала под каждую дыру делался чёрный клип ровно её длины. Любое движение клипа меняло длину дыры, требовало новой генерации, и до её готовности предпросмотр играл прежнюю расстановку. Выглядело так, будто выключение дорожки или удаление клипа не действуют. Лечится заготовками готовых длин, из которых вырезается нужный кусок.

Первая загрузка начинала воспроизведение. У незагруженного mpv признак паузы снят, поэтому первый же список кусков ехал сам. Лечится опцией pause=yes при создании плеера.

Молчаливое растяжение кадра. Приведение файла к проекту задавало размер ключом -s, а он растягивает кадр не считаясь с соотношением сторон. Вертикальное видео превращалось в размазанное по ширине. Теперь размер задаётся цепочкой фильтров, а вписывание выбирается осознанно.

Фильтр mpv и кадры в памяти видеокарты. Цепочка фильтров ffmpeg работает с обычной памятью, а при аппаратном декодировании кадры остаются в памяти видеокарты. Граф фильтров тогда просто не собирается: Impossible to convert between the formats supported by the filter 'mpv_src_in0'. Лечится переключением на hwdec=auto-copy, пока фильтр нужен.

Ловушка была не в этом, а в том, как я это проверял: из командной строки mpv сам выбрал nvdec-copy, всё работало, и ошибки не было видно. Она вылезла только на снимке окна работающего редактора. Проверять такое надо там, где оно живёт, а не в удобной обстановке.

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

Модуль Windows конфликтует именами. Он объявляет свои TRect, Point и RegisterClass, а имена в Паскале нечувствительны к регистру — обычные вызовы LCL начинают разбираться как системные, с невнятными ошибками вида «ожидалось )». Подключать его целиком не нужно: SetMenuInfo и MENUINFO объявлены в uTheme напрямую.

Знак «▼» в шрифте Windows. Вместо стрелки выходил пустой квадрат. Треугольник рисуется сам (TThemedButton.Arrow).

Раскраска до построения меню. ApplyDarkTheme вызывался в начале FormCreate, а меню строится в середине — и цветов не получало. Вызов должен идти последним.

Исключения вещественной арифметики. Паскаль по умолчанию требует сообщать о каждой неточности вычислений. libmpv и ffmpeg внутри считают как им удобно и на это не рассчитывают: при создании плеера вылезало «Invalid floating point operation», и предпросмотр не запускался. Лечится SetExceptionMask после загрузки библиотеки.

Цепочку смешивания надо снимать до загрузки. Она ссылается на дорожки по номерам, и оставшаяся от прошлого раза не даёт открыть новый файл вовсе: проверено на живом mpv — после такой загрузки дорожек ноль, кадра нет, не играет ничего. Выглядит это как чёрное окно и полная тишина, и на плеер не похоже совсем.

Снимать её после загрузки поздно: ломается сама загрузка.

Внешняя звуковая дорожка не подключается свойством. Задать audio-files перед загрузкой недостаточно: список дорожек после загрузки остаётся прежним, внешний файл не подключается вовсе, и играет звук, встроенный в саму картинку. Проверено на живом mpv через его канал управления: после установки свойства дорожек по-прежнему две, обе не внешние.

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

Правильный способ — команда audio-add с признаком select после загрузки: она добавляет дорожку, выбирает её и снимает выбор с внутренней. Проверено: дорожек становится четыре, внутренняя звуковая помечается невыбранной.

Аргументы loadfile. В mpv 0.38 у команды появился четвёртый аргумент — номер позиции в списке, и настройки файла сдвинулись на пятое место. Прежний вызов стал ошибкой «The loadfile option must be an integer», и под Wine с новой библиотекой не грузилось ничего. Гадать по номеру версии не нужно: те же настройки задаются свойствами перед загрузкой, и называются они одинаково во всех версиях.

Модуль Windows конфликтует именами. Он объявляет свои TRect, Point, DeleteFile, GetTempFileName и RegisterClass, а имена в Паскале нечувствительны к регистру — обычные вызовы LCL начинают разбираться как системные, с невнятными ошибками вида «ожидалось )». Подключать его целиком не нужно: SetMenuInfo и MENUINFO объявлены в uTheme напрямую. За сессию эта ловушка сработала четыре раза.

Порядок байт в TColor. LCL хранит цвет как $00BBGGRR, а не как привычный RGB. Записанный по привычке код даёт цвет с переставленными красным и синим.

Имена, занятые предками. Локальная переменная перекрывает одноимённое свойство класса: Text и Position заняты у TControl, Finished — у TThread, Name и Caption — у формы. Компилятор ловит это как «Duplicate identifier», но причину не объясняет.

Стык кусков в потоке выглядит как порча. Каждый кусок — это отдельный поток MPEG-TS со своим счётчиком непрерывности, и на стыке приёмник пишет «Packet corrupt», по паре строк на кусок. Данные при этом целы, но журнал сборки забивается жалобами, из-за которых не найти настоящую ошибку. Лечится ключом -mpegts_flags +initial_discontinuity у отправителя: он помечает начало куска как разрыв, жалобы исчезают, а результат тот же — сверено по кадрам и по md5.

Приёмник двигает весь ролик. -avoid_negative_ts make_zero у приёмника сдвигает поток так, чтобы самая ранняя метка декодирования стала нулём, — а у первого куска она уходит в минус на задержку кодировщика, и весь ролик встаёт на 21 мс позже. Само по себе это безобидно, но продолжение прерванной сборки несёт этот сдвиг в перенесённой части, и куски за ней, размещённые по счёту кадров от нуля, налезают на неё на кадр. Поэтому при переносе сдвиг снимается отрицательным -output_ts_offset, а приёмник накладывает его заново.

Сдвиг к нулю отменяет сдвиг по времени. При сборке потоком место куску задаёт -output_ts_offset, но -avoid_negative_ts make_zero считается уже после него и двигает кусок обратно в ноль — ключ просто перестаёт действовать. Замерено: все шесть кусков встали в начало, картинка разошлась с эталоном (PSNR 18 дБ) при верном числе кадров и тех же пакетах. В потоке сдвиг к нулю выключен; отрицательные метки в самом начале подбирает приёмник, у него make_zero свой.

-fflags +igndts сминает кадры на стыках. С ним ffmpeg выбрасывает пришедшие метки декодирования и считает свои, а материал с B-кадрами хранится не в том порядке, в каком показывается. На стыке появлялось по три кадра с одной меткой, картинка расходилась с эталоном. Без этого ключа метки идут шаг в шаг, и ни одной жалобы на непоследовательность.

Двойной щелчок съедает второй щелчок. Пока у элемента есть csDoubleClicks, второй щелчок подряд приходит не как Click, а как DblClick, и обработчик кнопки его не видит: человек щёлкает три раза, срабатывает один. Лечится двумя строками в конструкторе: ControlStyle := ControlStyle - [csDoubleClicks] и DblClick, переопределённый в Click, — на случай, если двойной всё же дойдёт.

Всплывающая подсказка перехватывает щелчок. Окно подсказки в gtk2 встаёт поверх кнопки, о которой рассказывает, и щелчок попадает в него, а не в кнопку. У кнопок в тесной полосе ShowHint выключен, а текст подсказки идёт в строку состояния (UseStatusHints в uMainForm).

Консольный инструмент проверок

Часть вещей проверяется числами, а не глазами. Сборка: ./build.sh tool.

./tests/nlvetool kf <файл>                 индекс опорных кадров
./tests/nlvetool kfcheck <файл>            сверка быстрого пути с ffprobe
./tests/nlvetool flattest                  проверка уплощения монтажа
./tests/nlvetool edl <файл> <старт> <длина> [...]
./tests/nlvetool export <файл> <выход> <старт> <длина> [...]
./tests/nlvetool export --cancel-after=<сек> ...   проверка остановки
./tests/nlvetool remux <файл>... <расширение>
./tests/nlvetool build <проект> <видео> [<картинка>]
./tests/nlvetool buildcut <проект> <видео>
./tests/nlvetool render <проект> <выход>
./tests/nlvetool syncproj <проект> <видео>   монтаж для замера синхронности

Команды возвращают ненулевой код при расхождении, поэтому годятся для автоматической проверки.

Отдельно — замер синхронности звука и картинки:

./tests/syncprobe.sh                  весь прогон: образец, сборка, замер
./tests/syncprobe.sh measure ФАЙЛ     замер готового файла

Как он устроен. Делается исходник, где ровно на каждой секунде в кадре белая вспышка, а в звуке щелчок. Из него собирается монтаж, у которого начала кусков заведомо не попадают на опорные кадры. В готовом ролике ищутся времена вспышек и щелчков: если они разошлись — звук уехал от картинки, и видно, на сколько и с какого куска.

Времена щелчков считаются по самим отсчётам (tests/clicktimes.py), а не оконным анализом: окно двигается кратно своему размеру, и его собственный уход выглядит как накапливающийся рассинхрон, которого нет. На это уже попались один раз.

Структура

Файл Что это
src/uAppTypes.pas Базовые типы, форматирование чисел без учёта локали
src/uSettings.pas Настройки между запусками (JSON)
src/uProcRunner.pas Запуск ffmpeg и ffprobe, разбор вывода, остановка
src/uFfmpegCmd.pas Сборка аргументов ffmpeg, разбор его прогресса
src/uMediaProbe.pas Что за файл нам дали и чем он отличается от проекта
src/uKeyframeIndex.pas Где опорные кадры: разбор заголовка MP4 и запасной путь
src/uProject.pas Модель монтажа, JSON проекта, отмена действий
src/uFlatten.pas Монтаж в плоский список кусков
src/uGenerate.pas Клипы для картинок и дыр
src/uExport.pas Вырезание, склейка, сверка числа кадров
src/uRemuxQueue.pas Очередь перепаковки
src/uEdl.pas Сборка edl:// для предпросмотра
src/uMpvLib.pas Объявления libmpv, динамическая загрузка
src/uMpvPlayer.pas Плеер: встраивание в окно, воспроизведение, события
src/uTimelineCtrl.pas Компонент монтажного стола
src/uTransform.pas Как кадр исходника ложится в кадр проекта
src/uPanCropForm.pas Окно кадрирования и поворота
src/uProjectInfoForm.pas Сведения о проекте и ответ ffprobe
src/uThumbs.pas Кадры-миниатюры для списка файлов
src/uWaveform.pas Пики звука для формы волны на клипах
src/uTheme.pas Цвета окна, своя кнопка, своё окно сообщения
src/uLogView.pas Журнал: своя отрисовка, свои полосы прокрутки
src/uThemedScroll.pas Полоса прокрутки и обвязка для таблиц
src/uDeps.pas Что нужно программе, где искать, как ставить
src/uDownload.pas Загрузка по сети: WinHTTP под Windows, curl под Linux
src/uDepsForm.pas Окно «Зависимости»
src/uMainForm.pas Главное окно
src/uProgressForm.pas Ход долгой операции
src/uRemuxForm.pas Окно перепаковки
src/uMismatchForm.pas Предупреждение о несовпадении параметров
build.sh Сборка целями
tests/ Консольный инструмент проверок
vendor/mpv-headers/ Заголовки mpv 0.37.0 — эталон для биндингов

Чего пока нет

Раздельные звуковые дорожки. Звук идёт с того же клипа, что и картинка. Громкость у клипа своя, но смешивать звук с нескольких дорожек нельзя: это потребовало бы второго прохода по всему материалу. Модель данных к разделению готова, само разделение не сделано.

Осциллограмма звука на клипах.

Переходы, эффекты, изменение скорости. Всё это требует перекодирования и в замысел не входит.