Compare commits

...

21 commits

Author SHA1 Message Date
3bfd8bc688 README: проверки на невидимом экране и полная таблица модулей
Четыре проверки, которые гоняют саму программу, собраны в одном месте с
припиской: каждую стоит прогонять и на прежней сборке, иначе она не
проверяет ничего. Названы монтажи, которые им нужны, и почему их нет в
репозитории.

В таблицу модулей дописаны десять, которых там не было: текст и его
замер, разметка для libass, окна ключа, перекодирования, сборки и «о
программе», страница текста со ссылками, запуск ffmpeg с ходом работы и
вшитые значки.
2026-10-01 16:35:59 +03:00
d75596bca3 Доля растёт ровно: строка «progress=continue» больше не обнуляет место
Показатель висел на пяти процентах, потом прыгал к семидесяти девяти,
потом сразу готово. Виноват не ffmpeg: замерено, что он отдаёт ход
безупречно ровно — блок каждые полсекунды, 7%, 14%, 20%, 27% и дальше
без единого скачка.

Блок заканчивается строкой «progress=continue», в которой места нет:
она сообщает только, что работа продолжается, и разбор честно оставляет
переданное число как было. Я передавал туда ноль, поэтому каждый блок
заканчивался обнулением доли. Показатель стоял и дёргался вверх лишь
тогда, когда опрос попадал между строкой со временем и этой.

Теперь отсчёт начинается с уже известного места — как это давно делает
сборка ролика. Проверено глазами на невидимом экране: шесть снимков
подряд через полторы секунды дают 54, 65, 74, 82, 91, 99 процентов,
полоса растёт вместе с числом.

Ловушку закрепляет проверка в tests/lfmcheck: «progress=continue» и
«progress=end» узнаются, но времени не трогают.
2026-10-01 16:24:59 +03:00
e8cb3cc392 Ручку затухания можно взять и во второй раз
Зона захвата совпадала с нарисованным квадратиком — девять точек на
девять. Пока затухание нулевое, ручка стоит в углу клипа и целиться есть
куда; стоит её сдвинуть, она висит посреди клипа сама по себе, и попасть
в неё почти нельзя.

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

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

Проверено протяжкой мыши на невидимом экране (tests/fadegrab.sh): первый
захват за угол даёт затухание 1.387 с, второй — с прицелом на пять точек
ниже и четыре в стороне от середины ручки — доводит до 2.261 с. На сборке
из прошлого коммита второй захват не делает ничего.
2026-10-01 16:13:37 +03:00
44c0a5de24 Показатель работы: число и полоса показывают одно и то же
Полоса ползла по догадке, а подпись показывала замер — и человек видел
полосу, заполненную на девять десятых, рядом с нулём процентов. Что в
этот момент происходит, по такой паре понять нельзя.

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

Догадка заодно перестала закрывать промежуток до вехи целиком: предел —
девять десятых, остаток виден глазом. Проверка на это добавлена в
tests/progress.

Полоса в окне сборки была системной, и под gtk2 заполнялась ступенями по
восемь кусков на ширину. Теперь там та же своя полоса, что и внизу
главного окна, — заполняется сплошь. Чтобы она читалась из файла формы,
у неё опубликованы Anchors, Align и Visible: читатель формы берёт только
опубликованные свойства, и без этого окно падало при открытии.

Проверено глазами на невидимом экране: «Собираю наложение 1 из 1 — 2%»
при полосе в 2%, через четыре секунды 69% при полосе в 69%.
2026-10-01 15:50:10 +03:00
8d1cb9af5c Сведение слоёв распаковывает исходник видеокартой
Затухание в полторы-две минуты собиралось минутами, и всё это время
предпросмотр ждёт. Виноваты оказались не фильтры: на десяти секундах
2688x1512 цепочка со сведением стоит 13.5 с, а вовсе без фильтров —
12.7 с. Время уходит на разбор HEVC процессором.

Ключ -hwaccel cuda перед входом убирает почти всё: настоящее затухание
на семьдесят секунд собирается 18.3 с вместо 75.9 с, картинка бит в бит
та же (одинаковый md5 потока, 4196 кадров там и там).

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

Сборка ролика пока по-старому: там кусок с наложением идёт в общий
поток, и повторить его после отказа посреди работы нельзя — данные уже
ушли в трубу. Это отдельная задача.
2026-10-01 15:11:43 +03:00
d2a49343c3 Сборка вставки не начинается заново от каждой правки
Затухание в семьдесят секунд не собиралось никогда: сборка сведённого
куска шла полторы минуты, и любая правка в это время сбрасывала её на
ноль. В журнале это видно как «Расстановка изменилась» и тот же самый
запуск ffmpeg по кругу.

Причина — в вопросе, который задавался. «Нужна ли ещё эта работа»
проверялось по списку недостающего, а недостающее у нас — это то, чего
нет на диске. У куска, который делается прямо сейчас, файл уже есть:
ffmpeg создаёт его в первую же секунду. Кусок в список не попадал, ответ
был «не нужна», работа отменялась.

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

Проверено обеими сторонами (tests/genrestart.sh, на настоящем материале,
затухание 70 с): пять резов в стороне от затухания — один запуск и ни
одной отмены, было шесть запусков и десять отмен. Удалили всё — работа
прекращается за две секунды, как и прежде.
2026-10-01 00:47:28 +03:00
235cd00e9a Предпросмотр показывает кадрирование клипа
Клип со своим кадрированием играл в окне во весь кадр: цепочка фильтров
у mpv одна на весь список кусков, а включить её на одном куске и
выключить на следующем нечем — из фильтров ffmpeg по времени
переключается только наложение.

Обошлось без цепочки вовсе. У вывода mpv своя обрезка, и делает её
видеокарта: курсор зашёл в клип с кадрированием — выставили, вышел —
сняли. Ни перестройки цепочки, ни копирования кадров из памяти
видеокарты в обычную.

Рамку источника считает теперь один код на две стороны: по ней окно
«Кадрирование и поворот» рисует рамку, и её же плеер отдаёт как обрезку.
Проверка в tests/lfmcheck сверяет рамку с фильтром сборки: увеличение
2.77 даёт 970x546 посередине, и это ровно то, что вырезает
crop=2688:1512:2379:1338 после scale=7446:4188.

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

Не покрыто и записано в BACKLOG: отражение (свойства для него нет),
растяжение и участки с наложением — там кадр уже сведён цепочкой.
2026-10-01 00:04:57 +03:00
2160c33790 BACKLOG: кадрирование в окне и два видео рядом — по замеру
Про кадрирование обычного клипа было написано, что цепочка фильтров для
2688x1512 слишком дорога. Это память о другом замере: на деле 330 к/с
без фильтра против 214 с веткой, включаемой по времени, при нужных 60 —
запас трёхкратный. Заодно записан путь дешевле: mpv умеет обрезать и
увеличивать на выводе, силами видеокарты.

Про два видео рядом заведён свой раздел: граф сборки проверен на
настоящих файлах, и названы три вещи, которых не хватает. Главная —
клипу негде записать, какое место в кадре он занимает: масштаб сейчас
всегда равномерный, и полосу в половину кадра им не выразить.
2026-09-30 22:00:49 +03:00
d0ec869743 Масштаб таймлайна берётся от точки под мышкой
Ctrl с колесом должен оставлять на месте то, на что смотрит мышь, а
оставлял точку левее — ровно на левый край таймлайна. При мыши у левого
края опора и вовсе уходила за пределы окна.

Причина: MousePos у DoMouseWheel по описанию экранный, но под gtk2 в нём
уже клиентские координаты, и ScreenToClient переводил их второй раз.
Берём Mouse.CursorPos — он экранный в любой системе.

Замерено на невидимом экране: до правки мышь в 700, 1000 и 1300 давала
опору в 260, 560 и 860; после — ровно 700, 1000 и 1300. Проверка
оформлена в tests/zoomanchor.py и прогнана на обеих сборках: на прежней
она падает, на новой проходит.
2026-09-30 20:45:32 +03:00
4526f65fcc Записано, чего сборка потоком не даёт
Четыре оговорки с ценой каждой: продолжение стоит места вдвое, отступ
назад идёт до целого куска, MP4 продолжать нельзя вовсе, и потоком идёт
не всё. Все четыре известны заранее, а не вскроются через месяц.
2026-09-30 16:50:18 +03:00
f841ed79ec Честное предупреждение о контейнере и отметка недописанной сборки
У MP4 два неудобства, о которых человек узнавал поздно: прерванную
сборку в нём продолжить нельзя вовсе, а перекодированный стык точного
реза воспроизводится правильно не везде. Теперь об обоих говорится до
начала работы, одним окном с кнопками «Собрать в MKV», «Всё равно mp4»,
«Не собирать».

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

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

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

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

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

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

В nlvetool у render появились --cancel-after=<сек> и сочетание слов:
cache resume проверяет, что старый путь через кэш не сломался.
2026-09-30 16:37:28 +03:00
38f4b295b9 Сборка потоком: куски идут сразу в готовый файл, без кэша
Раньше каждый кусок монтажа вырезался в отдельный файл, и только потом
список этих файлов шёл на склейку. Кэш весил столько же, сколько готовый
фильм — замерено 110 ГБ на часе материала, — а диск писал всё дважды.

Теперь каждый кусок режется своим ffmpeg прямо в MPEG-TS на стандартный
вывод, все потоки подряд переливаются в один принимающий ffmpeg, а тот
пишет готовый файл. Место куску задаёт -output_ts_offset, считаемый в
кадрах от начала. На диск ложится только результат и звуковая дорожка.

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

Сверено на четырёх монтажах, включая настоящий материал с дрона
(HEVC 2688x1512, 55 Мбит/с, файлы с шестью потоками): картинка совпала
попиксельно, PSNR бесконечен на каждом кадре, длина и размер те же.
Сверка шла и с прошлым выпуском, а не только нового пути со старым.
На отрывке в 17.5 с: 14.2 с и 421 КБ на диске против 17.5 с и 152 МБ.

По дороге найдены две ловушки, обе записаны в README:
-avoid_negative_ts make_zero отменяет -output_ts_offset, а -fflags
+igndts у приёмника сминает кадры на стыках.
2026-09-30 16:12:00 +03:00
aba9bd57e4 Кнопки внизу: подсказка не перехватывает щелчок, щелчки не теряются
Две беды у всех кнопок нижней полосы.

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

Второй щелчок подряд виджетсет отдавал как двойной, и кнопка его не
видела: быстрая очередь щелчков срабатывала через раз. У кнопки убран
csDoubleClicks, а на случай, если двойной всё же дойдёт, он считается
за обычный.

Проверено: tests/lfmcheck, раздел «Кнопки нижней панели», и
tests/buttonclicks.sh мышью на невидимом экране — по всем шести
переключателям очереди из 1-5 щелчков отрабатываются полностью.
2026-09-30 15:44:29 +03:00
569d0382f7 Кадрирование показывает кадр из клипа, а не из середины файла
Окно кадрирования брало кадр на десятой части от начала исходного файла,
не глядя, какой клип правят. Клип обычно вырезан из середины долгой
съёмки, и в окне была совсем другая сцена: прицелиться увеличением было
нечем.

Теперь для клипа берётся его собственное время: место курсора, если он
внутри клипа, иначе середина клипа. Кадрирование файла целиком из списка
работает как раньше.

Проверено на съёмке с дрона: клип из 900-й секунды, окно показало кадр
именно оттуда (совпал с эталонным кадром на 900 с), раньше показывало
кадр на пятой секунде.
2026-09-30 12:05:44 +03:00
0ba3bd40a6 Огрызкам на резах задаётся битрейт проекта
Перекодированный кусочек от точки реза до опорного кадра шёл без ключа
битрейта, и NVENC брал своё умолчание. Замерено в готовом ролике
пользователя: 5.7-6.8 Мбит/с в огрызке против 54-60 в скопированном
материале рядом — мутное пятно на полсекунды.

Раньше это задевало только редкие точные резы, а теперь точны все: в том
ролике 98 кусков, то есть 98 таких пятен.

После правки на том же материале: огрызок 86.8 Мбит/с (потолок в полтора
битрейта проекта, для куска в 0.7 с с опорным кадром это нормально),
соседний скопированный 61.6. Сверка кадров и звука проходит.
2026-09-30 00:55:09 +03:00
6617e29514 Выделение по времени, рез по краям промежутка, курсор идёт за резом
Режимов выделения стало два, переключаются клавишей D и кнопкой внизу,
режим показан её значком.

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

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

Рез кладётся на кадровую сетку: между кадрами времени нет, и рез на доле
кадра оставлял бы в монтаже время, которого не бывает.

Выделение рамкой требует настоящего перекрытия, а не касания краями:
сосед, кончающийся ровно на краю рамки, попадал в выделение из-за разницы
в последнем знаке дробей.

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

Значки — icons8, набор ios-glyphs, авторство дописано в attribution.txt.
Набора «ios26» на их раздаче нет (404), взят ближайший по виду к нынешним.

Проверки: рез по курсору и по краям промежутка, кадровая сетка, выделение
середины, курсор после удаления и после схлопывания.
2026-09-29 23:58:30 +03:00
37787ce96c Починка проекта: клипы на кадровую сетку, пункт в меню
Меню «Проект» → «Проверить и починить проект...». Показывает найденное и
чинит только по согласию, отменяется через Ctrl+Z.

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

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

Проверено на копии настоящего проекта: 82 клипа, после починки мимо сетки
ни одного, клипы остались встык, общая длина изменилась на 0.028 с за два
часа; Ctrl+Z возвращает как было. Плюс проверка в lfmcheck, включая клип
короче кадра — он не должен схлопнуться в ничто.
2026-09-16 20:39:33 +03:00
019d016db9 Звук больше не отстаёт от картинки
Рез всегда точный. Раньше начало куска притягивалось к ближайшему
опорному кадру, а звук брался из непритянутой точки: по журналам сборки
средняя притяжка 0.24 с, худшая 0.99 с, и на длинном куске этот сдвиг
держится все его минуты. Плата за точность — доли секунды перекодирования
на рез. Второй вид реза (Shift+S) стал тем же самым и убран.

Разбор заголовка MP4 не учитывал список правок (elst): метки опорных
кадров уезжали на задержку показа, на пробе два кадра. Сверка быстрого
пути с ffprobe теперь совпадает точно, в том числе на записи с B-кадрами.

Куску заказывается точное число кадров, а не только время: при
копировании ffmpeg отмеряет конец по времени декодирования, и у записи с
B-кадрами кусок выходил длиннее.

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

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

Границы кусков ложатся на кадровую сетку: картинка выходит целым числом
кадров, звук набирается до отсчёта, и доли кадра разводили их в стороны.

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

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

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

Замер (tests/syncprobe.sh, исходник со вспышкой и щелчком на каждой
секунде): было до 0.6 с и росло по ходу ролика, стало 0.02-0.04 с без
накопления при точности измерения 0.017 с.
2026-09-16 18:28:29 +03:00
18e55d466e Работа не теряется: автосохранение, видимое сохранение, запасной файл
Сохранение теперь отвечает в строке состояния: «Проект сохранён: имя,
время». Раньше об этом знал только журнал, а он по умолчанию закрыт —
нажал Ctrl+S и не знаешь, дошло ли до файла.

Проект пишется во временный файл рядом и подставляется целиком; прежний
остаётся как .bak. Оборванная запись больше не может оставить огрызок
вместо проекта.

Автосохранение: пока есть несохранённое, раз в полминуты рядом с
проектом пишется копия .autosave. При открытии программа сравнивает её
время с временем проекта и, если копия новее, показывает оба времени и
предлагает её открыть. После сохранения проекта копия убирается.

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

Клавиша \ подтягивает окно к курсору и ставит его посередине: её жмут
после реза или выделения. Переключатель «курсор посередине» остался
кнопкой с целью.
2026-09-14 16:06:40 +03:00
86c28a3308 Выделение областью, рез всех дорожек, курсор не улетает
Рамка выделения: протяжка по пустому месту дорожек обтягивает клипы,
Delete убирает всё, что она задела. Щелчок без движения по-прежнему
ставит курсор.

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

Курсор после правки монтажа улетал в конец: при перезагрузке списка
кусков mpv сбрасывает отсчёт времени, программа ждала перемотки, не
дожидалась и подтягивала курсор к плееру. На паузе курсор теперь
остаётся там, куда его поставил человек. Это и делало Ctrl+Shift+R
неудобным: после каждого схлопывания приходилось целиться заново.

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

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

Проверки: рамка, рез всех дорожек, допуск при схлопывании (lfmcheck).
2026-09-14 15:27:43 +03:00
34 changed files with 5385 additions and 523 deletions

3
.gitignore vendored
View file

@ -51,6 +51,9 @@ shot*.png
/tests/lfmcheck/lfmcheck
/tests/lfmcheck/units/
# Снимок, который делает проверка масштаба таймлайна.
/tests/кадры-зума.png
# Проверка хода полоски: двоичный файл и промежуточные модули.
/tests/progress/progtest
/tests/progress/*.o

View file

@ -76,28 +76,77 @@ ffmpeg проблемой не является: он запускается о
MP4/H.264 нельзя — контейнер и кодек альфу не хранят. Для этого нужен
отдельный вывод в VP9 или ProRes 4444.
## Предпросмотр не показывает кадрирование обычного клипа
## Предпросмотр: чего он про кадрирование всё ещё не показывает
Кадрирование и поворот видны в окне только у клипов с ключом или
прозрачностью: их плеер кладёт поверх основы сам и применяет к ним
цепочку фильтров. Обычный клип идёт списком кусков, а список фильтровать
нечем — он играется как есть, во весь кадр. В готовом ролике при этом всё
верно: такой кусок помечен на пересборку и обкладывается полями.
Кадрирование обычного клипа окно теперь показывает — обрезкой на выводе
плеера, её делает видеокарта даром. Остались три случая, где оно
по-прежнему играется как есть, и для них точную картинку даёт окно
«Кадрирование и поворот»:
Проверено опытом: у клипа выключен только ключ, кадрирование оставлено —
окно показывает его во весь кадр.
**Отражение.** У вывода mpv нет свойства для отражения, только для
поворота. Понадобится — придётся вести такой клип через цепочку.
Как это делается тем же механизмом, что и наложение: провести такой клип
через ту же дорожку наложения, а под ним заставить основу быть чёрной —
непрозрачный клип с полями в готовом ролике именно так и закрывает всё,
что ниже. Значит, уплощению нужно понятие «здесь дыра, даже если ниже
что-то есть».
**Растяжение.** Кадр жмётся неравномерно, рамкой это не описать.
Чего это стоит: ветка масштабирования в цепочке работает на каждом кадре,
а не только там, где клип виден. Для накладки в 512 точек это пустяк, для
куска 2688x1512 — уже нет. Поэтому сначала замер, потом решение.
**Участок с наложением.** Там кадр уже сведён цепочкой, и обрезка на
выводе резала бы сведённое целиком, вместе с нижним слоем. Такой клип
нужно вести через ту же цепочку, что и само наложение.
## Окно настроек программы
Цена пути через цепочку известна, мерили (DJI 2688x1512, HEVC
55 Мбит/с, 600 кадров, `--vo=null`):
| | время | кадров в секунду |
|---|---|---|
| без фильтра | 1.82 с | 330 |
| ветка «обрезать и растянуть» с `enable` | 2.81 с | 214 |
Нужно 60, то есть запас трёхкратный: по деньгам проходит. Устроена она
так: раздвоить поток, одну ветку обрезать и растянуть, и наложить её на
другую с `enable='between(t,...)'`. Иначе никак — из фильтров ffmpeg по
времени переключается только `overlay`, у `crop` и `scale` поддержки
временной шкалы нет.
## Два видео рядом в одном кадре
Два ролика 16:9, каждый обрезан до половины кадра: один слева до
середины, другой от середины вправо. Никакого смешивания, просто два
куска рядом.
Сборке для этого не нужно ничего нового — только числа. Проверено на
настоящих файлах, кадр получается с первого раза:
[0:v]crop=1344:1512:672:0,pad=2688:1512:0:0,setsar=1[l];
[1:v]crop=1344:1512:672:0,setsar=1[r];
[l][r]overlay=1344:0[out]
То есть каждому источнику своя обрезка, затем оба кладутся на свои места.
Именно такой граф и строит `ArgsRenderLayers`: у слоя уже есть и своя
цепочка фильтров, и координаты, куда его положить.
Не хватает трёх вещей, и первая — главная.
**Клипу негде записать, какое место в кадре он занимает.** Преобразование
сейчас описывает только, как источник ложится в кадр целиком: вписать,
заполнить с обрезкой, увеличение, смещение, поворот. Масштаб при этом
всегда равномерный, поэтому из кадра 2688x1512 никакими настройками не
получить полосу 1344x1512 — для неё нужно сжать вдвое по ширине и не
трогать высоту. Нужен прямоугольник назначения: четыре числа в долях
кадра проекта. Тогда всё остальное — вписывание, увеличение, смещение —
начинает работать внутри него, а `BuildTransform` меняется только в
аргументах: размер прямоугольника вместо размера кадра, а возвращаемые
координаты сдвигаются на его начало.
**Уплощение считает непрозрачный клип закрывающим всё.** Нижний клип
подхватывается, только когда верхний полупрозрачный или с ключом. Значит
половинка на верхней дорожке сегодня закроет собой нижнее видео чёрными
полями. Условие должно смотреть на прямоугольник назначения: не накрывает
кадр — значит под ним видно то, что ниже.
**Предпросмотру нужна вторая дорожка наложения.** Сейчас плеер добавляет
одну: основа — список кусков, поверх неё одна внешняя дорожка. Для двух
половинок обе должны идти через цепочку над чёрной основой, то есть
внешних дорожек нужно две. Механика та же самая — ещё один `video-add` и
ещё один вход `[vidN]` в цепочке.
Переключателей, которые стоят машинного времени, становится больше, а
живут они кто где: прилипание и схлопывание — кнопками внизу, ключ в
@ -119,6 +168,35 @@ NVENC, если он есть). Это разрозненно и не даёт
неподъёмным. Пока цифры замеряны только на одной машине, у человека
должна быть возможность выключить любую из этих трат.
## Сборка потоком: чего она не даёт
Ролик собирается потоком — куски идут сразу в готовый файл, кэш не
создаётся. За это заплачено тремя вещами, и все три известны заранее.
**Продолжение стоит места.** Дописать в конец существующего MKV нельзя:
Matroska этого не даёт. Продолжение всегда пишется в новый файл, куда
сперва переливается целая часть старого, так что на диске в этот момент
лежат оба — места нужно вдвое больше готового ролика. Обойти это можно
только разбором и правкой самого контейнера, а это уже своя маленькая
библиотека Matroska.
**Отступ назад — до целого куска.** Продолжают не с последнего
записанного кадра, а с границы последнего целого куска: кусок — это
неделимая единица, у него свои метки времени и своё число кадров. На
замерах терялось от нуля до нескольких секунд работы. Мельче отступать
можно было бы, храня рядом с файлом карту стыков, но выигрыш в секундах
не стоит нового файла, который надо держать в согласии с роликом.
**MP4 продолжать нельзя вовсе.** Оглавление пишется в конец, оборванный
файл не открывается — `moov atom not found`. Программа предупреждает об
этом до начала сборки и предлагает MKV. Теоретически недописанный MP4
можно восстановить, собрав оглавление по кускам самому (так делают
untrunc и подобные), но это отдельная задача другого размера.
**Потоком идёт не всё.** MPEG-TS несёт не любой кодек, и пересборка
всего ролика под площадку требует списка кусков. В этих случаях сборка
идёт по-старому, через кэш — со всеми его 110 ГБ на часе материала.
## Мелочи, до которых не дошли руки
**Вид дорожки не виден в её заголовке.** Дорожки картинки, звука и текста

382
README.md
View file

@ -76,13 +76,15 @@ xdg-mime query default application/x-nlve-project
|---|---|
| Пуск и пауза | Пробел |
| В начало / конец монтажа | Home / End |
| Разрезать по курсору | S |
| Разрезать точно (перекодирует стык) | Shift+S |
| Показать, где курсор (окно едет к нему) | \ |
| Разрезать по курсору (все дорожки) | S |
| Удалить выделенное | Delete |
| Выделить всё | Ctrl+A |
| Выделить областью | протяжка по пустому месту дорожек |
| Режим выделения: рамкой или по времени | D |
| Схлопнуть дыру под курсором | Ctrl+Shift+R |
| Отменить, повторить | Ctrl+Z, Ctrl+Y |
| Масштаб таймлайна | Ctrl + колесо мыши |
| Масштаб таймлайна, от точки под мышкой | Ctrl + колесо мыши |
| Прокрутка по дорожкам | колесо мыши |
| Прокрутка по времени | Shift + колесо, средняя кнопка |
| Прокрутка журнала вбок | Shift + колесо над журналом |
@ -143,7 +145,7 @@ xdg-mime query default application/x-nlve-project
липнут при перетаскивании, курсор — при щелчке, и одно бывает нужно без
другого.
**Курсор посередине** (кнопка с целью, клавиша `\`). Включено — при
**Курсор посередине** (кнопка с целью). Включено — при
воспроизведении монтаж едет под курсором, и всегда видно, что будет
дальше. Выключено — окно не двигается вовсе, даже когда курсор ушёл за
край: отмотал Shift с колесом — там и останешься. Раньше в выключенном
@ -222,6 +224,13 @@ Ctrl+Shift+R, который схлопывает дыру под курсоро
двойной. Ручка стоит посередине именно видимой части, а не всего клипа:
до середины часового клипа, уехавшей за край экрана, мышью не дотянуться.
**Затухания тянутся за ручки в верхних углах клипа.** Взять их можно и
промахнувшись: зона захвата шире нарисованного квадратика и уходит внутрь
клипа. Пока затухание нулевое, ручка стоит в самом углу и целиться есть
куда; стоит её сдвинуть — она висит посреди клипа сама по себе, и попасть
в девять точек почти нельзя. Над зоной захвата меняется курсор — по нему
и видно, где взять (`tests/fadegrab.sh`).
Громкость, отличная от обычной, — единственное место, где звук
перекодируется: изменить громкость, не тронув сам звук, нельзя. Видео при
этом по-прежнему копируется. Проверено: клип с громкостью 30% дал в ролике
@ -250,6 +259,99 @@ Ctrl+Shift+R, который схлопывает дыру под курсоро
неправильно. Усиление меняет только рисунок, самого звука не касается, и
запоминается между запусками.
## Сохранение и автосохранение
**Сохранение видно.** После 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 с, то есть на
полтора кадра за два часа;
- **клипы на дорожках чужого вида.** Такой клип нарисован на монтаже, но
в готовый ролик не попадает. То же самое предлагается при открытии
проекта, здесь — по требованию;
- **дорожки с повторяющимися именами.**
Для синхронности звука с картинкой чинить проект не нужно: рез правится
при сборке, и старый проект соберётся сходящимся. Починка — про то, чтобы
монтаж не хранил невозможных времён.
## Файл не совпадает с проектом
Проект имеет одно разрешение и один набор кодеков на всё — иначе куски не
@ -308,11 +410,22 @@ Ctrl+Shift+R, который схлопывает дыру под курсоро
Замер на материале 2688x1512 при 60 к/с: 327 кадров в секунду без фильтра,
230 с настоящим масштабированием — запас четырёхкратный.
Чего предпросмотр не умеет: показать собственную настройку кадрирования
файла. Цепочка фильтров у mpv одна на всю склейку, а заполнение с обрезкой,
поворот и смещение задаются для каждого файла отдельно — выбрать между ними
общий фильтр не может, поэтому он всегда вписывает целиком. Точную картинку
для такого файла показывает окно «Кадрирование и поворот».
**Кадрирование клипа окно показывает.** Цепочка фильтров у mpv одна на всю
склейку, а кадрирование у каждого клипа своё, и включать его по времени в
цепочке нечем: из фильтров ffmpeg по времени переключается только
наложение. Зато у вывода есть своя обрезка, и делает её видеокарта даром.
Курсор зашёл в клип со своим кадрированием — обрезка выставляется, вышел —
снимается. Ни лишней цепочки, ни копирования кадров из памяти видеокарты.
Рамку считает один и тот же код, что рисует её в окне «Кадрирование и
поворот», — иначе окно обещало бы одно, а предпросмотр показывал другое
(`TransformSourceRect` в `uTransform`, проверка в `tests/lfmcheck`).
Чего это не покрывает: отражение (свойства для него у вывода нет),
растяжение (там кадр жмётся неравномерно, рамкой не описать) и участки с
наложением — там кадр уже сведён цепочкой, и обрезка резала бы сведённое
целиком, вместе с нижним слоем. Для таких клипов точную картинку
по-прежнему показывает окно «Кадрирование и поворот».
## Цвета окна
@ -487,15 +600,34 @@ Linux потом искала по нему `.dll`.
## Прерванная сборка
Каждый доведённый до конца кусок помечается в списке рядом с самими
кусками (`готово.txt` в кэше). Если сборка не дошла до конца — упала,
была отменена или программу закрыли, — при следующей сборке в тот же файл
программа предложит продолжить с готовых кусков.
Если сборка не дошла до конца — упала, была отменена или программу
закрыли, — при следующей сборке в тот же файл программа предложит
продолжить.
Проверяется не только наличие файла: сборку могли прервать посреди
записи, и файл оказался бы обрезанным. Поэтому куском считается только
тот, который успел попасть в список.
**При сборке потоком** продолжают с самого недописанного файла: он и есть
уже собранная часть фильма, и в нём видно, сколько целых кадров успело
записаться. Программа отступает назад до границы последнего целого куска,
переносит эту часть в новый файл и досылает остаток. Собранное таким
образом совпадает с собранным за один раз попиксельно.
Дописать в конец существующего MKV нельзя — Matroska этого не даёт, —
поэтому продолжение всегда пишется в новый файл, и на диске в этот момент
лежат оба. Места нужно вдвое больше готового ролика.
**MP4 продолжать нельзя вовсе:** оглавление пишется в конец, и оборванный
файл не открывается — `moov atom not found`. Программа говорит об этом
прямо и собирает заново. Если сборка длинная, лучше собрать в MKV, а потом
переупаковать в MP4 окном ремукса: это копирование потоков, без
перекодирования.
Готовый файл до последнего цел: сборка идёт в файл рядом и занимает место
готового, только когда дописана. Сорвавшаяся попытка не отбирает то, что
уже было собрано, — из двух недописанных остаётся тот, где кадров больше.
**При сборке через кэш кусков** продолжают иначе: каждый доведённый до
конца кусок помечается в списке рядом с самими кусками (`готово.txt`).
Проверяется не только наличие файла — сборку могли прервать посреди
записи; куском считается только тот, который успел попасть в список.
Первая строка списка — отпечаток плана: пути, места и длины всех кусков.
Не совпал — значит монтаж с тех пор менялся, и готовые куски не подойдут;
программа скажет об этом и соберёт заново.
@ -509,6 +641,24 @@ Linux потом искала по нему `.dll`.
пересчитывается на каждом шаге. Даже такая оценка отвечает на главный
вопрос: успею ли я сходить за чаем.
**Полоса и число — одно и то же число.** Показатель работы внизу главного
окна подписан процентами, и это ровно та доля, которую он нарисовал.
Раньше полоса ползла по догадке, а подпись показывала замер: человек
видел полосу, заполненную на девять десятых, рядом с нулём процентов, и
понять по такой паре было нельзя ничего.
**Доля берётся у ffmpeg, а не угадывается.** Сборка участка с наложением
или затуханием может идти минуту и дольше, и всё это время «кусок 1 из 1»
не меняется. Поэтому ffmpeg спрашивают, докуда он дошёл (`-progress`), и
доля считается по его ответу. Пока он не ответил ни разу, полоса трогается
с места на пару процентов и там стоит: пустая полоса и есть правда о
только что начатой работе.
**Обе полосы рисует один и тот же код.** Полоса в окне сборки раньше была
системной (`TProgressBar`), и под gtk2 она заполнялась ступенями — восемь
кусков на всю ширину. Теперь и там, и внизу главного окна стоит своя
`TThemedProgress`, которая заполняется сплошь.
## Как собирается ролик
1. Монтаж уплощается в последовательность непересекающихся кусков.
@ -519,13 +669,40 @@ Linux потом искала по нему `.dll`.
кусков нельзя, он на этом зависает. Заготовки делаются готовых длин
(15 с, 60 с, 5 мин и так далее), а нужный кусок из них вырезается —
тогда любое движение клипа не требует новой генерации.
3. Начало каждого куска притягивается к опорному кадру, потому что копировать
можно только с него. Конец притягивать не нужно — при копировании он и так
получается кадроточным.
4. При точном резе огрызок от точки реза до следующего опорного кадра
перекодируется, остальное копируется.
5. Куски склеиваются concat-демультиплексором, результат сверяется: сумма
пакетов кусков обязана совпасть с числом пакетов в готовом файле.
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
@ -556,7 +733,8 @@ Linux потом искала по нему `.dll`.
числе кадров. Лечится склейкой через промежуточный MPEG-TS, где параметры
повторяются перед каждым опорным кадром — стало 0 ошибок. Способ выбирается
автоматически: один источник и никаких точных резов — прямая склейка,
иначе MPEG-TS.
иначе MPEG-TS. Резы теперь всегда точные, так что на деле это почти всегда
MPEG-TS.
**Лишний кадр на стыке точного реза.** При копировании `-to` работает как
«строго меньше», при перекодировании — как «меньше либо равно». Кадр на
@ -568,6 +746,33 @@ 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 с.
**Вставка под каждую длину дыры.** Сначала под каждую дыру делался чёрный
клип ровно её длины. Любое движение клипа меняло длину дыры, требовало
новой генерации, и до её готовности предпросмотр играл прежнюю расстановку.
@ -660,11 +865,90 @@ Wine с новой библиотекой не грузилось ничего.
привычный RGB. Записанный по привычке код даёт цвет с переставленными
красным и синим.
**Последняя строка блока о ходе работы обнуляет место.** ffmpeg шлёт ход
блоками, и заканчивается блок строкой `progress=continue`, в которой
места нет — она сообщает только, что работа продолжается. Разбор честно
оставляет переданное число как было, поэтому передавать туда надо уже
известное место, а не ноль. С нулём доля обнуляется в конце каждого
блока: показатель стоит на месте и дёргается вверх лишь тогда, когда
опрос попадёт между строкой со временем и этой — «висит на пяти
процентах, потом скачет до семидесяти девяти». Сам ffmpeg при этом
отдаёт ход безупречно ровно: блок каждые полсекунды, замерено.
**Разбор HEVC на процессоре — самое дорогое в сведении слоёв.** Не
фильтры, как кажется: на десяти секундах 2688x1512 цепочка со сведением
стоит 13.5 с, а без всяких фильтров — 12.7 с. Всё время уходит на разбор
исходника. Ключ `-hwaccel cuda` перед входом убирает его почти целиком:
затухание на семьдесят секунд собирается 18.3 с вместо 75.9 с, картинка
бит в бит та же. Просить его стоит только там, где кодирует та же карта
(выбран NVENC): иначе на машине без неё пришлось бы ловить отказ.
**Список недостающего — не список нужного.** Недостающее у нас — это то,
чего нет на диске. Но у куска, который делается прямо сейчас, файл уже
есть: ffmpeg создаёт его в первую же секунду и пишет до конца. Проверка
«нужна ли ещё эта работа» спрашивала по списку недостающего — и отвечала
«не нужна» про то, что делается. Работа отменялась в разгаре и начиналась
заново. На коротком затухании это проходило незаметно, на затухании в
семьдесят секунд сборка не заканчивалась никогда: любая правка сбрасывала
её. Теперь уплощение отдельно отдаёт список нужного, независимо от того,
лежит файл на диске или ещё пишется (`tests/genrestart.sh`).
**MousePos у колеса мыши — не экранный.** По описанию `DoMouseWheel`
получает точку в координатах экрана, но под gtk2 в ней уже клиентские:
`ScreenToClient` переводит их второй раз, и точка уезжает влево ровно на
левый край элемента. У масштаба таймлайна это выглядело так, будто он
берётся не от мыши: замерено на невидимом экране — мышь в 700, 1000 и
1300, опора в 260, 560 и 860, всегда на 440 левее. Лечится обращением к
`Mouse.CursorPos`: он экранный в любой системе, и перевод остаётся один.
**Имена, занятые предками.** Локальная переменная перекрывает одноимённое
свойство класса: `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`.
@ -680,11 +964,51 @@ Wine с новой библиотекой не грузилось ничего.
./tests/nlvetool build <проект> <видео> [<картинка>]
./tests/nlvetool buildcut <проект> <видео>
./tests/nlvetool render <проект> <выход>
./tests/nlvetool syncproj <проект> <видео> монтаж для замера синхронности
```
Команды возвращают ненулевой код при расхождении, поэтому годятся для
автоматической проверки.
Отдельно — замер синхронности звука и картинки:
```
./tests/syncprobe.sh весь прогон: образец, сборка, замер
./tests/syncprobe.sh measure ФАЙЛ замер готового файла
```
Как он устроен. Делается исходник, где ровно на каждой секунде в кадре
белая вспышка, а в звуке щелчок. Из него собирается монтаж, у которого
начала кусков заведомо не попадают на опорные кадры. В готовом ролике
ищутся времена вспышек и щелчков: если они разошлись — звук уехал от
картинки, и видно, на сколько и с какого куска.
Времена щелчков считаются по самим отсчётам (`tests/clicktimes.py`), а не
оконным анализом: окно двигается кратно своему размеру, и его собственный
уход выглядит как накапливающийся рассинхрон, которого нет. На это уже
попались один раз.
Ещё четыре проверки гоняют саму программу на невидимом экране
(`Xvfb :99`). Рабочий экран они не занимают:
```
./tests/buttonclicks.sh <окно> <x> <y> каждый щелчок по кнопке считается
./tests/zoomanchor.py <окно> масштаб берётся от точки под мышкой
./tests/genrestart.sh [<путь к nlve>] сборка вставки не сбрасывается зря
./tests/fadegrab.sh [<путь к nlve>] ручку затухания видно и во второй раз
```
Двум последним нужен монтаж в `test/поток/`: `длинный-фейд.nlve` — один
клип с затуханием секунд на семьдесят (такой кусок сводится около
полутора минут, и всё это время человек продолжает работать),
`пара.nlve` — два клипа без затуханий. Путь к первому задаётся
переменной `NLVE_FADE_PROJECT`. В репозиторий они не входят, потому что
ссылаются на съёмку, которой здесь нет.
Каждую из них стоит прогонять и на прежней сборке: проверка, которая
проходит везде, не проверяет ничего. Все четыре на сборке до своей
правки падают.
## Структура
| Файл | Что это |
@ -719,6 +1043,16 @@ Wine с новой библиотекой не грузилось ничего.
| `src/uProgressForm.pas` | Ход долгой операции |
| `src/uRemuxForm.pas` | Окно перепаковки |
| `src/uMismatchForm.pas` | Предупреждение о несовпадении параметров |
| `src/uTextForm.pas` | Надпись на клипе: текст и оформление |
| `src/uTextMeasure.pas` | Точный замер ширины текста для подбора размера |
| `src/uSubtitles.pas` | Текст поверх картинки: разметка для libass |
| `src/uColorKeyForm.pas` | Выбор цвета, который станет прозрачным |
| `src/uEncodeForm.pas` | Настройки сборки с перекодированием |
| `src/uExportForm.pas` | Куда собирать, в каком контейнере, что с текстом |
| `src/uAboutForm.pas` | О программе: возможности и чем она обязана чужому |
| `src/uArticleView.pas` | Страница текста со ссылками |
| `src/uFfmpegJob.pas` | Один запуск ffmpeg в потоке, с ходом работы и отменой |
| `src/uIconData.pas` | Значки, вшитые в программу |
| `build.sh` | Сборка целями |
| `tests/` | Консольный инструмент проверок |
| `vendor/mpv-headers/` | Заголовки mpv 0.37.0 — эталон для биндингов |

View file

@ -2,4 +2,6 @@ Log panel: <a target="_blank" href="https://icons8.com/icon/79872/property">Prop
Ripple gaps: <a target="_blank" href="https://icons8.com/icon/2EQegp8tbR6Q/merge-vertical">Merge Vertical</a> icon by <a target="_blank" href="https://icons8.com">Icons8</a>
Magnet: <a target="_blank" href="https://icons8.com/icon/79942/magnet">Magnet</a> icon by <a target="_blank" href="https://icons8.com">Icons8</a>
Playhead snapping: <a target="_blank" href="https://icons8.com/icon/111296/glue">Клей</a> icon by <a target="_blank" href="https://icons8.com">Icons8</a>
App icon: <a target="_blank" href="https://icons8.com/icon/69093/showing-video-frames">Showing Video Frames</a> icon by <a target="_blank" href="https://icons8.com">Icons8</a>
App icon: <a target="_blank" href="https://icons8.com/icon/69093/showing-video-frames">Showing Video Frames</a> icon by <a target="_blank" href="https://icons8.com">Icons8</a>
Selection mode, clips: <a target="_blank" href="https://icons8.com/icon/set/select-all/ios-glyphs">Select All</a> icon by <a target="_blank" href="https://icons8.com">Icons8</a>
Selection mode, time range: <a target="_blank" href="https://icons8.com/icon/set/width/ios-glyphs">Width</a> icon by <a target="_blank" href="https://icons8.com">Icons8</a>

Binary file not shown.

After

Width:  |  Height:  |  Size: 476 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 656 B

View file

@ -232,12 +232,30 @@ begin
end;
function AdvanceProgress(AShown, AReal, ANext, AStepShare: Double): Double;
var
Guessed: Double;
begin
Result := AShown;
if AReal > Result then
Result := AReal;
if ANext > Result then
Result := Result + (ANext - Result) * AStepShare;
{ Догадка не закрывает промежуток до вехи целиком.
Шаги мельчают, но за сотню их полоса подбиралась к вехе вплотную, и
на единственном шаге — когда веха одна и она же конец — доползала до
края. Человек видел заполненную полосу у работы, которая ещё идёт, и
считал программу зависшей. Девять десятых промежутка — предел:
остаток виден глазом и говорит, что веха не достигнута. }
Guessed := AReal + (ANext - AReal) * 0.9;
{ Назад полоса не идёт никогда, даже если веха сместилась: предел не
опускается ниже уже показанного. }
if Guessed < AShown then
Guessed := AShown;
if (ANext > AReal) and (Result > Guessed) then
Result := Guessed;
{ До самого конца полоска не доходит: конец наступает тогда, когда
работа кончилась, а не когда мы досчитали. }
if Result > 0.999 then

File diff suppressed because it is too large Load diff

View file

@ -35,6 +35,13 @@ uses
type
TArgs = array of string;
const
{ Значение AAt у сборщиков аргументов, означающее «пишем в файл, а не в
трубу». Отдельная величина, а не просто «меньше нуля»: время куска в
потоке бывает и отрицательным — так с перенесённого куска снимается
сдвиг, который прошлая сборка наложила на весь ролик. }
NotPiped = -1000000.0;
{ Пути к инструментам. Ищутся один раз и запоминаются. }
function FfmpegExe: string;
function FfprobeExe: string;
@ -57,10 +64,21 @@ function ToolsAvailable(out AError: string): Boolean;
AVolume — множитель громкости. При единице звук копируется как есть;
при любом другом значении его приходится пересобрать, потому что
громкость нельзя изменить, не тронув сам звук. Видео при этом
по-прежнему копируется — перекодируется только звуковая дорожка. }
по-прежнему копируется — перекодируется только звуковая дорожка.
AFrames — сколько кадров картинки должно оказаться в куске. Ноль —
сколько выйдет.
Зачем считать кадры, когда есть время начала и конца. При копировании
потока ffmpeg отмеряет конец по времени декодирования, а не показа, и у
записи с B-кадрами это разные вещи: кусок выходит на пару кадров длиннее
запрошенного. Куски идут один за другим, и эта пара кадров копится —
замерено 0.42 с лишней картинки на шести кусках. Число кадров такой
двусмысленности не имеет. }
function ArgsExtract(const ASrc, ADst: string; AStart, AEnd: Double;
const AFormat: string; AVolume: Double = 1;
const AAudioCodec: string = ''): TArgs;
const AAudioCodec: string = ''; AFrames: Integer = 0;
AWithAudio: Boolean = True; AAt: Double = NotPiped): TArgs;
{ Перепаковать файл целиком в заданный формат, копируя потоки. }
function ArgsCopyTo(const ASrc, ADst, AFormat: string): TArgs;
@ -75,7 +93,9 @@ function ArgsCopyTo(const ASrc, ADst, AFormat: string): TArgs;
function ArgsEncodeFragment(const ASrc, ADst: string; AStart, AEnd: Double;
AFpsNum, AFpsDen: Integer;
const AVideoCodec, APixFmt, AAudioCodec: string;
ASampleRate, AChannels: Integer; const AFormat: string): TArgs;
ASampleRate, AChannels: Integer; const AFormat: string;
AFrames: Integer = 0; AWithAudio: Boolean = True;
ABitrate: Int64 = 0; AAt: Double = NotPiped): TArgs;
{ Пересобрать кусок в параметры проекта.
@ -87,7 +107,8 @@ function ArgsTranscodeSegment(const ASrc, ADst: string; AStart, AEnd: Double;
const AVideoCodec, APixFmt, AAudioCodec: string;
ASampleRate, AChannels: Integer; AVolume: Double;
const AFormat: string; const AFilter: string = '';
const ASubFile: string = ''; ABitrate: Int64 = 0): TArgs;
const ASubFile: string = ''; ABitrate: Int64 = 0;
AAt: Double = NotPiped): TArgs;
{ Путь внутри строки фильтра. Двоеточие там разделяет настройки, обратная
косая и кавычка имеют своё значение — всё это надо обезвредить, иначе
@ -127,6 +148,12 @@ function ArgsConcat(const AListFile, ADst: string): TArgs;
надо. Пусто в AAudio — значит звука нет, тогда это обычная склейка. }
function ArgsConcatMux(const AListFile, AAudio, ASubFile, ADst: string): TArgs;
{ Приёмник сборки потоком: читает MPEG-TS из трубы и пишет готовый файл.
То же, что ArgsConcatMux, только куски приходят не списком файлов, а
одним потоком в стандартный ввод. AAudio и ASubFile — как там. }
function ArgsStreamMux(const AAudio, ASubFile, ADst: string): TArgs;
{ Склеить куски и сразу перекодировать картинку в заданный размер и поток.
Нужно для сборки «под площадку»: когда ролик надо отдать в 1080p с
@ -151,9 +178,17 @@ function ArgsExtractAudioRaw(const ASource, ATarget: string;
const AStart, ADuration: Double; ARate, AChannels: Integer;
const AVolume: Double): TArgs;
{ Сжатие собранного сырого звука. Один проход на весь ролик. }
{ Сжатие собранного сырого звука. Один проход на весь ролик.
ASkipSeconds — сколько отбросить от начала. Нужно, чтобы погасить
«разогрев» кодировщика: сжатый звук начинается с нескольких сотен
отсчётов служебного мусора, контейнер MP4 помечает их как пропускаемые,
а Matroska такой пометки не хранит — и при копировании звук уезжает
позже картинки на время разогрева (замерено 21.3 мс). Отбросив ровно
столько же от начала, мы возвращаем содержимое на место. }
function ArgsEncodeAudio(const ASource, ATarget: string;
ARate, AChannels: Integer; const ACodec: string): TArgs;
ARate, AChannels: Integer; const ACodec: string;
ASkipSeconds: Double = 0): TArgs;
{ Пересборка участка с наложением: два слоя сводятся в один кадр.
@ -163,12 +198,19 @@ function ArgsEncodeAudio(const ASource, ATarget: string;
ASubFile — разметка текста, которую надо вжечь в этот кусок. Пусто — не
вжигать. Без этого текст пропадал везде, где есть наложение: кусок шёл
этой веткой, а она про субтитры не знала. }
этой веткой, а она про субтитры не знала.
AHwDecode — распаковывать исходники видеокартой. На длинном затухании
это разница между тремя минутами и одной: замерено на HEVC 2688x1512,
13.5 с против 3.3 с на десяти секундах материала, картинка бит в бит
та же. Признак необязательный: не пойдёт — вызывающий повторит без
него, как это уже делает склейка с перекодированием. }
function ArgsRenderLayers(const ALayers: array of TSegmentLayer;
const ATarget: string; const ADuration: Double;
AWidth, AHeight, AFpsNum, AFpsDen: Integer;
const APixFmt, ACodec: string; AIsTs: Boolean;
ABitrate: Int64 = 0; const ASubFile: string = ''): TArgs;
ABitrate: Int64 = 0; const ASubFile: string = '';
AAt: Double = NotPiped; AHwDecode: Boolean = False): TArgs;
{ Сшивание готовой картинки с готовым звуком. Оба потока копируются.
@ -190,10 +232,18 @@ function ArgsProbeDuration(const ASrc: string): TArgs;
декодирования. Читает файл целиком, но не распаковывает. }
function ArgsCountVideoPackets(const ASrc: string): TArgs;
{ Метка времени первого звукового пакета. У сжатого звука она
отрицательная — на величину разогрева кодировщика. }
function ArgsFirstPacketTime(const ASrc: string): TArgs;
{ Метки времени и признаки всех видеопакетов. Запасной путь построения
индекса опорных кадров для контейнеров, кроме MP4. }
function ArgsPacketFlags(const ASrc: string): TArgs;
{ Метки первых кадров картинки: по ним видно, с какого времени начинается
файл. Нужно продолжению прерванной сборки. }
function ArgsFirstVideoTimes(const ASrc: string): TArgs;
{ Название кодировщика для кодека.
Предпочитается аппаратный NVENC: на этой машине он кодирует HEVC в
@ -259,6 +309,10 @@ uses
слоёв, а она по тексту раньше самой функции. }
function BitrateArgs(ABitrate: Int64): TArgs; forward;
{ Хвост вывода: куда писать и как докладывать о ходе работы. Объявлено
здесь по той же причине — отрисовка слоёв идёт раньше. }
function TailArgs(const AFormat, ADst: string; AAt: Double): TArgs; forward;
const
{ Поток для чёрных вставок: мегабит в секунду. }
BlackBitrate = 1000000;
@ -289,7 +343,8 @@ begin
end;
function ArgsEncodeAudio(const ASource, ATarget: string;
ARate, AChannels: Integer; const ACodec: string): TArgs;
ARate, AChannels: Integer; const ACodec: string;
ASkipSeconds: Double): TArgs;
var
Codec: string;
begin
@ -297,20 +352,38 @@ begin
if Codec = '' then
Codec := 'aac';
Result := ['-hide_banner', '-nostdin', '-loglevel', 'error', '-y',
'-f', 's16le', '-ar', IntToStr(ARate), '-ac', IntToStr(AChannels),
'-i', ASource, '-c:a', Codec, '-b:a', '192k',
'-progress', 'pipe:1', '-nostats', ATarget];
'-f', 's16le', '-ar', IntToStr(ARate), '-ac', IntToStr(AChannels)];
if ASkipSeconds > 0 then
Result := Concat(Result, ['-ss', SecondsToStr(ASkipSeconds)]);
Result := Concat(Result, ['-i', ASource, '-c:a', Codec, '-b:a', '192k',
'-progress', 'pipe:1', '-nostats', ATarget]);
end;
function ArgsRenderLayers(const ALayers: array of TSegmentLayer;
const ATarget: string; const ADuration: Double;
AWidth, AHeight, AFpsNum, AFpsDen: Integer;
const APixFmt, ACodec: string; AIsTs: Boolean;
ABitrate: Int64; const ASubFile: string): TArgs;
ABitrate: Int64; const ASubFile: string; AAt: Double;
AHwDecode: Boolean): TArgs;
var
I: Integer;
Graph, Fit, Src, OutLabel: string;
Crossfade: Boolean;
{ Распаковка на видеокарте. Ключ входной, поэтому ставится перед
каждым входом отдельно.
Просим её только там, где кодировать будет та же карта: раз NVENC
выбран, значит карта есть и драйвер её видит. На машине без неё ключ
не появится вовсе, и просить откат будет не о чем. }
function HwArgs: TArgs;
begin
if AHwDecode and (Pos('nvenc', PreferredEncoder(ACodec)) > 0) then
Result := ['-hwaccel', 'cuda']
else
Result := nil;
end;
begin
Result := ['-hide_banner', '-nostdin', '-loglevel', 'warning', '-y'];
@ -328,7 +401,7 @@ begin
'-t', SecondsToStr(ADuration),
'-i', ALayers[I].FilePath])
else
Result := Concat(Result,
Result := Concat(Concat(Result, HwArgs),
['-ss', SecondsToStr(ALayers[I].SrcStart),
'-t', SecondsToStr(ADuration),
'-i', ALayers[I].FilePath]);
@ -420,12 +493,11 @@ begin
'-c:v', PreferredEncoder(ACodec), '-r', Format('%d/%d', [AFpsNum, AFpsDen]),
'-pix_fmt', APixFmt]);
Result := Concat(Result, BitrateArgs(ABitrate));
Result := Concat(Result,
['-bf', '0', '-an', '-avoid_negative_ts', 'make_zero',
'-progress', 'pipe:1', '-nostats']);
Result := Concat(Result, ['-bf', '0', '-an']);
if AIsTs then
Result := Concat(Result, ['-f', 'mpegts']);
Result := Concat(Result, [ATarget]);
Result := Concat(Result, TailArgs('mpegts', ATarget, AAt))
else
Result := Concat(Result, TailArgs('', ATarget, AAt));
end;
function ArgsMux(const AVideo, AAudio, ATarget: string;
@ -541,6 +613,72 @@ begin
Result := ['-progress', 'pipe:1', '-nostats'];
end;
{ То же, когда стандартный вывод занят: в него идёт сам материал.
Прогресс тогда отправляется в поток ошибок, вперемешку с журналом.
Строки там разные и разбираются по-разному, так что смешение не мешает:
ход работы — это «ключ=значение», журнал — обычный текст. }
function ProgressArgsToStderr: TArgs;
begin
Result := ['-progress', 'pipe:2', '-nostats'];
end;
{ Хвост вывода: файл или труба.
AAt < 0 — обычный файл в заданном контейнере. AAt >= 0 — труба, и тогда
контейнер всегда MPEG-TS: только он даёт склеивать куски встык, повторяя
параметры кодека перед каждым опорным кадром.
-output_ts_offset ставит кусок на его место в общем потоке. Без него
каждый кусок начинался бы с нуля, и приёмник свалил бы их в одну точку.
-muxdelay и -muxpreload в ноль: обычно MPEG-TS откладывает начало на
семь десятых секунды под буфер проигрывателя, и эта задержка сдвинула бы
каждый кусок относительно заданного ему времени.
-copyts здесь намеренно нет: с ним метки остаются исходными, и на пробе
ролик из шести кусков по четыре секунды вышел длиной 48.7 с вместо
24.0. }
function OutputTail(const AFormat, ADst: string; AAt: Double): TArgs;
begin
if AAt <= NotPiped then
begin
{ Метки времени вырезанного куска должны начинаться с нуля, иначе при
склейке куски разъедутся по времени. }
Result := ['-avoid_negative_ts', 'make_zero'];
{ Пустое имя контейнера — значит его выбирает сам ffmpeg по
расширению файла. }
if AFormat = '' then
Result := Concat(Result, [ADst])
else
Result := Concat(Result, ['-f', AFormat, ADst]);
Exit;
end;
{ А вот в потоке make_zero всё ломает, и это стоило разбирательства.
Сдвиг к нулю считается уже после того, как применён -output_ts_offset,
и сдвигает кусок обратно в ноль — ключ просто перестаёт действовать.
Замерено: все шесть кусков встали в начало, метки времени в готовом
файле перестали расти, а картинка разошлась с эталоном (PSNR 18 дБ
вместо бесконечности при том же числе кадров и тех же пакетах).
Поэтому здесь сдвиг к нулю выключен: место куску задаёт offset, а
отрицательные метки в самом начале ролика подберёт приёмник — у него
make_zero свой. }
{ initial_discontinuity помечает начало куска как разрыв потока.
Без него приёмник видит, что счётчик непрерывности прыгнул, и на
каждом стыке пишет «Packet corrupt» — по паре строк на кусок. Данные
при этом целы (сверено побитово), но журнал сборки забивается
жалобами, из-за которых потом не найти настоящую ошибку. С флагом
жалоб нет, а результат тот же: 240 кадров и одинаковый md5. }
Result := ['-avoid_negative_ts', 'disabled',
'-output_ts_offset', SecondsToStr(AAt),
'-muxdelay', '0', '-muxpreload', '0',
'-mpegts_flags', '+initial_discontinuity',
'-f', 'mpegts', 'pipe:1'];
end;
function Concat(const A, B: TArgs): TArgs;
var
I: Integer;
@ -553,6 +691,16 @@ begin
Result[Length(A) + I] := B[I];
end;
{ Ход работы и хвост вывода разом: они всегда меняются вместе. }
function TailArgs(const AFormat, ADst: string; AAt: Double): TArgs;
begin
if AAt <= NotPiped then
Result := ProgressArgs
else
Result := ProgressArgsToStderr;
Result := Concat(Result, OutputTail(AFormat, ADst, AAt));
end;
{ Нужен ли контейнеру перенос заголовка в начало файла.
Для MP4 это делает файл пригодным для воспроизведения по мере загрузки.
@ -566,17 +714,25 @@ begin
end;
function ArgsExtract(const ASrc, ADst: string; AStart, AEnd: Double;
const AFormat: string; AVolume: Double; const AAudioCodec: string): TArgs;
const AFormat: string; AVolume: Double; const AAudioCodec: string;
AFrames: Integer; AWithAudio: Boolean; AAt: Double): TArgs;
begin
Result := Concat(CommonFfmpegPrefix, [
'-ss', SecondsToStr(AStart),
'-to', SecondsToStr(AEnd),
'-i', ASrc,
'-map', '0:v:0',
'-map', '0:a:0?'
'-map', '0:v:0'
]);
if Abs(AVolume - 1) < 0.001 then
{ Звук в куске картинки обычно лишний: звуковая дорожка ролика
собирается отдельно и точнее. Хуже того, он вредит: у кусков
MPEG-TS звуковой поток начинается не там же, где картинка, склейка
равняется по нему, и куски встают со сдвигом — замерено 36 мс на
стык. }
if AWithAudio then
Result := Concat(Result, ['-map', '0:a:0?']);
if (not AWithAudio) or (Abs(AVolume - 1) < 0.001) then
Result := Concat(Result, ['-c', 'copy'])
else
begin
@ -591,11 +747,13 @@ begin
Result := Concat(Result, ['-c:a', 'aac']);
end;
{ Метки времени вырезанного куска должны начинаться с нуля, иначе
при склейке куски разъедутся по времени. }
Result := Concat(Result, ['-avoid_negative_ts', 'make_zero']);
Result := Concat(Result, ProgressArgs);
Result := Concat(Result, ['-f', AFormat, ADst]);
{ Ровно столько кадров картинки, сколько просили. Время конца остаётся
как было: оно ограничивает звук куска и служит запасом, если кадров в
исходнике не хватит. }
if AFrames > 0 then
Result := Concat(Result, ['-frames:v', IntToStr(AFrames)]);
Result := Concat(Result, TailArgs(AFormat, ADst, AAt));
end;
function ArgsCopyTo(const ASrc, ADst, AFormat: string): TArgs;
@ -613,7 +771,9 @@ end;
function ArgsEncodeFragment(const ASrc, ADst: string; AStart, AEnd: Double;
AFpsNum, AFpsDen: Integer;
const AVideoCodec, APixFmt, AAudioCodec: string;
ASampleRate, AChannels: Integer; const AFormat: string): TArgs;
ASampleRate, AChannels: Integer; const AFormat: string;
AFrames: Integer; AWithAudio: Boolean; ABitrate: Int64;
AAt: Double): TArgs;
var
HalfFrame, StopAt: Double;
begin
@ -641,19 +801,27 @@ begin
'-ss', SecondsToStr(AStart),
'-to', SecondsToStr(StopAt),
'-i', ASrc,
'-map', '0:v:0',
'-map', '0:a:0?'
'-map', '0:v:0'
]);
if AWithAudio then
Result := Concat(Result, ['-map', '0:a:0?']);
{ Ни размер, ни частота кадров намеренно не задаются. Отрезок берётся
из того же файла, что и соседние куски, так что менять ему нечего.
Хуже того: принудительная частота включает пересчёт кадров, и на
пробе это добавило один лишний кадр на стыке — 601 вместо 600. }
{ Битрейт обязателен. Без него кодировщик берёт своё умолчание, а у
NVENC оно около двух мегабит: замерено в готовом ролике 5.7 Мбит/с в
огрызке против 54-60 в скопированном материале рядом. Раньше это
задевало редкие точные резы, а теперь точны все — и мутный огрызок
появлялся на каждом. }
Result := Concat(Result, MasterOutputArgs(0, 0, 0, 0,
AVideoCodec, APixFmt, AAudioCodec, ASampleRate, AChannels));
AVideoCodec, APixFmt, AAudioCodec, ASampleRate, AChannels, ABitrate));
Result := Concat(Result, NoBFramesArgs);
Result := Concat(Result, ['-avoid_negative_ts', 'make_zero']);
Result := Concat(Result, ProgressArgs);
Result := Concat(Result, ['-f', AFormat, ADst]);
{ Столько кадров, сколько просили: огрызок стыкуется с копией кадр в
кадр, и лишнему взяться неоткуда. }
if AFrames > 0 then
Result := Concat(Result, ['-frames:v', IntToStr(AFrames)]);
Result := Concat(Result, TailArgs(AFormat, ADst, AAt));
end;
function EscapeFilterPath(const APath: string): string;
@ -668,7 +836,7 @@ function ArgsTranscodeSegment(const ASrc, ADst: string; AStart, AEnd: Double;
const AVideoCodec, APixFmt, AAudioCodec: string;
ASampleRate, AChannels: Integer; AVolume: Double;
const AFormat: string; const AFilter: string;
const ASubFile: string; ABitrate: Int64): TArgs;
const ASubFile: string; ABitrate: Int64; AAt: Double): TArgs;
var
Chain: string;
begin
@ -704,9 +872,7 @@ begin
Result := Concat(Result, MasterOutputArgs(0, 0, AFpsNum, AFpsDen,
AVideoCodec, APixFmt, AAudioCodec, ASampleRate, AChannels, ABitrate));
Result := Concat(Result, NoBFramesArgs);
Result := Concat(Result, ['-avoid_negative_ts', 'make_zero']);
Result := Concat(Result, ProgressArgs);
Result := Concat(Result, ['-f', AFormat, ADst]);
Result := Concat(Result, TailArgs(AFormat, ADst, AAt));
end;
function TsSupportsCodecs(const AVideoCodec, AAudioCodec: string): Boolean;
@ -847,6 +1013,56 @@ begin
Result := Concat(Result, [ADst]);
end;
{ Приёмник: один ffmpeg, который читает поток MPEG-TS из трубы и пишет
готовый файл.
-fflags +igndts здесь был и оказался вреден. С ним ffmpeg выбрасывает
метки декодирования, пришедшие с кусками, и считает свои — а материал с
B-кадрами хранится не в том порядке, в каком показывается, и пересчёт
сминает кадры в кучу: замерено, что на стыке появлялись по три кадра с
одной и той же меткой, а картинка расходилась с эталоном (PSNR 18 дБ
при верном числе кадров). Без этого ключа метки на стыке идут ровно,
шаг в шаг, и ни одной жалобы на непоследовательность в журнале.
Прогресс отсюда не берётся: ход работы считается по кускам, а их шлём
мы сами. Лишний поток -progress только мешал бы — его труба заполнялась
бы, пока мы заняты переливом. }
function ArgsStreamMux(const AAudio, ASubFile, ADst: string): TArgs;
var
Next: Integer;
begin
Result := Concat(CommonFfmpegPrefix, [
'-f', 'mpegts',
'-i', 'pipe:0'
]);
Next := 1;
if AAudio <> '' then
Result := Concat(Result, ['-i', AAudio]);
if ASubFile <> '' then
Result := Concat(Result, ['-i', ASubFile]);
Result := Concat(Result, ['-map', '0:v:0']);
if AAudio <> '' then
begin
Result := Concat(Result, ['-map', IntToStr(Next) + ':a:0']);
Inc(Next);
end
else
Result := Concat(Result, ['-map', '0:a:0?']);
if ASubFile <> '' then
Result := Concat(Result, ['-map', IntToStr(Next) + ':s:0', '-c:s', 'ass',
'-metadata:s:s:0', 'title=Надписи',
'-metadata:s:s:0', 'language=rus']);
Result := Concat(Result, ['-c:v', 'copy', '-c:a', 'copy',
'-avoid_negative_ts', 'make_zero', '-nostats']);
if WantsFaststart(ADst) then
Result := Concat(Result, ['-movflags', '+faststart']);
Result := Concat(Result, [ADst]);
end;
function ArgsRemux(const ASrc, ADst: string): TArgs;
begin
Result := Concat(CommonFfmpegPrefix, [
@ -882,6 +1098,24 @@ begin
'-show_entries', 'stream=nb_read_packets', '-of', 'csv=p=0', ASrc];
end;
function ArgsFirstPacketTime(const ASrc: string): TArgs;
begin
Result := ['-v', 'error', '-select_streams', 'a:0',
'-show_entries', 'packet=pts_time', '-of', 'csv=p=0',
'-read_intervals', '%+0.2', ASrc];
end;
function ArgsFirstVideoTimes(const ASrc: string): TArgs;
begin
{ Первые полсекунды картинки: по ним видно, с какой метки файл
начинается. Не один пакет, а несколько — у записи с B-кадрами первый
по порядку хранения кадр показывается не первым, и самая ранняя метка
может оказаться у второго или третьего. }
Result := ['-v', 'error', '-select_streams', 'v:0',
'-show_entries', 'packet=pts_time', '-of', 'csv=p=0',
'-read_intervals', '%+0.5', ASrc];
end;
function ArgsPacketFlags(const ASrc: string): TArgs;
begin
Result := ['-v', 'error', '-select_streams', 'v:0',

View file

@ -119,7 +119,8 @@ function FlattenStream(AProject: TProject; AKind: TTrackKind;
списком кусков, а дальше они складываются его же средствами. }
function FlattenAudioTrack(AProject: TProject; ATrack: TTrack;
const ATotal: Double;
out ASegments: TFlatSegmentArray; out AMissing: TGenRequests): Boolean;
out ASegments: TFlatSegmentArray; out AMissing: TGenRequests;
AWanted: TStrings = nil): Boolean;
{ Длина слышимого материала одного вида дорожек. }
function AudibleEnd(AProject: TProject; AKind: TTrackKind): Double;
@ -166,6 +167,15 @@ function FlattenPreview(AProject: TProject;
правку в готовом файле почти невозможно.
Возвращает, сколько секунд осталось. }
{ Клип, который виден в этот момент: с самой верхней включённой дорожки
нужного вида. Тот же отбор победителя, что и при уплощении.
Нужен предпросмотру: по нему решается, чьё кадрирование показывать
обрезкой на выводе плеера. }
function VisibleClipAtTime(AProject: TProject; AKind: TTrackKind;
const ATime: Double): TClip;
function ClipSegmentsToRange(var ASegments: TFlatSegmentArray;
const AFrom, ATo: Double): Double;
@ -187,9 +197,14 @@ function GeneratedPath(const ACacheDir: string; const AReq: TGenRequest): string
Для сборки ролика это не применяется: там наложение считается заново с
параметрами вывода, и промежуточный файл был бы лишней потерей
качества. }
качества.
AWanted — куда сложить пути всех сведённых кусков, которые этой
расстановке нужны, независимо от того, лежат они уже на диске или ещё
делаются. По списку недостающего этого не узнать: файл появляется в
первую же секунду работы ffmpeg и пишется до конца. }
procedure ResolveLayers(AProject: TProject; var ASegments: TFlatSegmentArray;
var AMissing: TGenRequests);
var AMissing: TGenRequests; AWanted: TStrings = nil);
{ Заготовка чёрной вставки самой ходовой длины.
@ -304,6 +319,66 @@ begin
ALayers[I].KeySimilarity, ALayers[I].KeyBlend]);
end;
{ Границы кусков — на кадровую сетку проекта.
Между кадрами времени не существует, и монтаж не должен просить долей
кадра: картинка всё равно выйдет целым числом кадров, а звук набирается
с точностью до отсчёта — и они разойдутся. Каждая доля кадра, округлённая
в свою сторону, копится от куска к куску.
Квантуются именно границы, а не длины: накопленное начало кладётся на
сетку, а длина считается разностью соседних границ. Тогда куски остаются
встык и невязка не накапливается. Начало в исходнике двигается на ту же
величину — значит показывается тот же материал.
Картинка и звук проходят через одну и ту же меру, поэтому их границы
совпадают до отсчёта. }
procedure QuantizeSegments(var ASegments: TFlatSegmentArray;
AFpsNum, AFpsDen: Integer);
var
I: Integer;
Step, At, Next: Double;
begin
if (AFpsNum <= 0) or (AFpsDen <= 0) then
Exit;
Step := AFpsDen / AFpsNum;
At := 0;
for I := 0 to High(ASegments) do
begin
Next := Round((At + ASegments[I].Duration) / Step) * Step;
if Next <= At then
Next := At + Step;
ASegments[I].SrcStart := Round(ASegments[I].SrcStart / Step) * Step;
ASegments[I].Duration := Next - At;
At := Next;
end;
end;
function VisibleClipAtTime(AProject: TProject; AKind: TTrackKind;
const ATime: Double): TClip;
var
Ti, C: Integer;
Track: TTrack;
begin
Result := nil;
if AProject = nil then
Exit;
for Ti := 0 to AProject.TrackCount - 1 do
begin
Track := AProject.Tracks[Ti];
if Track.Kind <> AKind then
Continue;
{ Выключенные дорожки пропускаются, а если где-то включено «соло» —
то и все несолирующие. }
if not AProject.TrackAudible(Track) then
Continue;
for C := 0 to Track.Count - 1 do
if Track[C].Covers(ATime) then
Exit(Track[C]);
end;
end;
function ClipSegmentsToRange(var ASegments: TFlatSegmentArray;
const AFrom, ATo: Double): Double;
var
@ -347,7 +422,7 @@ begin
end;
procedure ResolveLayers(AProject: TProject; var ASegments: TFlatSegmentArray;
var AMissing: TGenRequests);
var AMissing: TGenRequests; AWanted: TStrings);
var
I: Integer;
Req: TGenRequest;
@ -374,6 +449,16 @@ begin
Req.TlEnd := At + ASegments[I].Duration;
At := Req.TlEnd;
{ Список нужного — это не то же самое, что список недостающего.
Недостающее — чего нет на диске. Но пока кусок делается, файл уже
есть: ffmpeg создаёт его сразу и пишет в него до самого конца.
Спрашивать «нужен ли ещё тот кусок, что сейчас делается» по списку
недостающего бессмысленно — его там не будет, и работа отменится в
разгаре. }
if AWanted <> nil then
AWanted.Add(Req.TargetPath);
if not FileExists(Req.TargetPath) then
begin
SetLength(AMissing, Length(AMissing) + 1);
@ -815,12 +900,21 @@ begin
исходнике. }
Seg.SrcStart := Winner.SrcIn + (SegStart - Winner.TlStart);
{ Точность требуется только там, где кусок начинается с собственного
левого края клипа. Границы, возникшие из-за перекрытия соседней
дорожкой, притягиваются к опорному кадру: помечать их точными
значило бы перекодировать стык при каждом наложении. }
Seg.PreciseIn := (Winner.EdgeIn = edPrecise) and
(Abs(SegStart - Winner.TlStart) < 0.0005);
{ Начало куска всегда точное.
Раньше точность просили только у резов, помеченных точными, а
остальные начала притягивались к ближайшему опорному кадру. Звук
при этом брался из непритянутой точки — и картинка внутри куска
показывала материал на величину притяжки позже, чем он звучал.
Замерено по журналам сборки: средняя притяжка 0.24 с, худшая 0.99 с,
и на длинном куске этот сдвиг держится все его минуты.
Плата за точность — перекодирование от точки реза до ближайшего
опорного кадра, доля секунды на рез. Декодер не умеет начинать с
середины группы кадров, поэтому «резать где угодно и ничего не
перекодировать» не бывает: выбор только между точным резом и
уехавшей точкой реза. }
Seg.PreciseIn := True;
{ Кусок нельзя скопировать по двум причинам: файл не совпадает с
параметрами проекта, либо у клипа своё кадрирование. И то, и
@ -1028,12 +1122,17 @@ begin
Seen.Free;
end;
{ Границы — на кадровую сетку: картинка и звук должны просить одно и то
же время, иначе округление разведёт их на доли кадра за кусок. }
QuantizeSegments(ASegments, AProject.Master.FpsNum, AProject.Master.FpsDen);
Result := True;
end;
function FlattenAudioTrack(AProject: TProject; ATrack: TTrack;
const ATotal: Double;
out ASegments: TFlatSegmentArray; out AMissing: TGenRequests): Boolean;
out ASegments: TFlatSegmentArray; out AMissing: TGenRequests;
AWanted: TStrings): Boolean;
var
C, N: Integer;
At: Double;
@ -1078,6 +1177,11 @@ var
Req.Duration := BucketFor(Take);
Req.TargetPath := GeneratedPath(AProject.CacheDir, Req);
Seg.FilePath := Req.TargetPath;
{ Нужное и недостающее — разные списки: см. пояснение у
ResolveLayers. Кусок, который делается прямо сейчас, на диске уже
есть, и в недостающее он не попадёт. }
if AWanted <> nil then
AWanted.Add(Req.TargetPath);
{ Один и тот же файл не заказываем дважды: длинная тишина набирается
повторами, и без этой проверки минутный кусок делался бы столько
раз, сколько его повторов. }
@ -1134,6 +1238,9 @@ begin
if ATotal > At then
PushSilence(ATotal - At);
{ Той же мерой, что и картинка. }
QuantizeSegments(ASegments, AProject.Master.FpsNum, AProject.Master.FpsDen);
Result := N > 0;
end;

View file

@ -36,6 +36,13 @@ type
FCancelled: Boolean;
FMessage: string;
FCurrent: Integer;
{ Насколько сделан кусок, который считается прямо сейчас: 0..1.
Берётся у самого ffmpeg, а не угадывается. }
FStepFraction: Double;
FStepSeconds: Double;
{ Сказал ли ffmpeg хоть раз, докуда он дошёл по этому куску. Пока не
сказал, настоящей доли нет и показывать нечего. }
FStepKnown: Boolean;
FOnProgress: TNotifyEvent;
FOnLog: TNotifyEvent;
@ -44,6 +51,8 @@ type
procedure Log(const AText: string);
function CancelQuery: Boolean;
procedure ErrorLine(const ALine: string);
{ Строки «ключ=значение», которые ffmpeg шлёт о ходе работы. }
procedure ProgressLine(const ALine: string);
procedure Publish;
protected
procedure Execute; override;
@ -57,6 +66,13 @@ type
работы: по ним он знает, докуда ползти до следующей вехи. }
function Total: Integer;
property Done: Integer read FCurrent;
{ Доля сделанного внутри текущего куска. Показателю работы она нужна,
чтобы полоса ползла по настоящему ходу, а не по догадке: кусок
бывает и на полторы минуты, и всё это время Done не меняется. }
property StepFraction: Double read FStepFraction;
{ Есть ли по текущему куску настоящий замер. Нет — показатель работы
не имеет права рисовать заполненную полосу. }
property StepKnown: Boolean read FStepKnown;
{ Место на монтаже, которое считается прямо сейчас. Ноль в обоих —
значит, к монтажу это отношения не имеет. }
function BusyFrom: Double;
@ -138,6 +154,45 @@ begin
Log('ffmpeg: ' + ALine);
end;
{ Ход работы берётся у самого ffmpeg.
Раньше показатель знал только «кусок такой-то из стольких» и между
кусками полз вслепую. На коротких вставках это проходило, а на
затухании в полторы минуты полоса успевала доползти до края и застыть:
со стороны это выглядело как зависшая программа. Теперь ffmpeg каждые
полсекунды сообщает, до какого места он дошёл, и доля честная. }
procedure TGenerateThread.ProgressLine(const ALine: string);
var
Seconds: Double;
AtEnd: Boolean;
begin
if FStepSeconds <= 0 then
Exit;
{ Отсчёт начинается с уже известного места, а не с нуля.
ffmpeg шлёт ход работы блоками, и последняя строка блока —
«progress=continue». Места в ней нет, она сообщает только, что работа
продолжается, — и разбор оставляет переданное число как было. Начни
мы с нуля, каждый блок заканчивался бы обнулением доли: показатель
стоял бы на месте и дёргался вверх только в те мгновения, когда
опрос попадал между строкой со временем и этой. Со стороны это
выглядело как «висит на пяти процентах, потом скачет до
семидесяти девяти». }
Seconds := FStepFraction * FStepSeconds;
AtEnd := False;
if not ParseProgressLine(ALine, Seconds, AtEnd) then
Exit;
FStepKnown := True;
FStepFraction := Seconds / FStepSeconds;
if FStepFraction < 0 then
FStepFraction := 0;
{ Единицу не отдаём: единица — это «кусок готов», а готовым он станет,
когда ffmpeg закончит и файл окажется на месте. }
if FStepFraction > 0.99 then
FStepFraction := 0.99;
Publish;
end;
function TGenerateThread.Total: Integer;
begin
Result := Length(FRequests);
@ -162,7 +217,7 @@ end;
procedure TGenerateThread.Publish;
begin
if Length(FRequests) > 0 then
FFraction := FCurrent / Length(FRequests)
FFraction := (FCurrent + FStepFraction) / Length(FRequests)
else
FFraction := 1;
Synchronize(@SyncProgress);
@ -214,6 +269,11 @@ begin
end;
FCurrent := I;
{ Длина куска нужна, чтобы перевести «дошёл до такой-то секунды» в
долю. У вставок она и есть длительность заказа. }
FStepFraction := 0;
FStepKnown := False;
FStepSeconds := FRequests[I].Duration;
case FRequests[I].Kind of
gkBlack:
FStageText := Format('Делаю чёрную вставку %d из %d',
@ -262,13 +322,36 @@ begin
Args := ArgsRenderLayers(FRequests[I].Layers,
FRequests[I].TargetPath, FRequests[I].Duration,
FMaster.Width, FMaster.Height, FMaster.FpsNum, FMaster.FpsDen,
FMaster.PixFmt, FMaster.VideoCodec, False, FMaster.VideoBitrate);
FMaster.PixFmt, FMaster.VideoCodec, False, FMaster.VideoBitrate,
'', NotPiped, True);
else
Continue;
end;
Log('> ' + CommandToString(FfmpegExe, Args));
Res := RunProcess(FfmpegExe, Args, nil, @ErrorLine, @CancelQuery);
Res := RunProcess(FfmpegExe, Args, @ProgressLine, @ErrorLine,
@CancelQuery);
{ Распаковка на видеокарте не пошла — повторяем на процессоре.
Она даёт вчетверо на длинном затухании, но зависит от драйвера и
от того, знаком ли видеокарте кодек исходника. Отказываться из-за
неё от сборки нельзя: медленно лучше, чем никак. }
if (FRequests[I].Kind = gkLayers) and (not Res.Cancelled) and
(Res.Started and (Res.ExitCode <> 0)) then
begin
Log('Распаковка на видеокарте не пошла, повторяю на процессоре.');
Args := ArgsRenderLayers(FRequests[I].Layers,
FRequests[I].TargetPath, FRequests[I].Duration,
FMaster.Width, FMaster.Height, FMaster.FpsNum, FMaster.FpsDen,
FMaster.PixFmt, FMaster.VideoCodec, False, FMaster.VideoBitrate,
'', NotPiped, False);
Log('> ' + CommandToString(FfmpegExe, Args));
FStepFraction := 0;
FStepKnown := False;
Res := RunProcess(FfmpegExe, Args, @ProgressLine, @ErrorLine,
@CancelQuery);
end;
if Res.Cancelled then
begin

View file

@ -32,8 +32,8 @@ const
$60, $00, $00, $3A, $98, $00, $00, $17, $70, $9C, $BA, $51, $3C, $00, $00, $00,
$02, $62, $4B, $47, $44, $00, $00, $AA, $8D, $23, $32, $00, $00, $00, $09, $70,
$48, $59, $73, $00, $00, $0B, $13, $00, $00, $0B, $13, $01, $00, $9A, $9C, $18,
$00, $00, $00, $07, $74, $49, $4D, $45, $07, $EA, $08, $1F, $07, $2E, $1B, $C7,
$2C, $48, $09, $00, $00, $01, $3E, $49, $44, $41, $54, $38, $CB, $BD, $92, $31,
$00, $00, $00, $07, $74, $49, $4D, $45, $07, $EA, $09, $1D, $14, $21, $19, $27,
$B3, $C9, $F8, $00, $00, $01, $3E, $49, $44, $41, $54, $38, $CB, $BD, $92, $31,
$4B, $C3, $40, $14, $C7, $7F, $97, $44, $29, $68, $03, $5A, $45, $EA, $D0, $A5,
$6B, $41, $44, $5C, $0A, $0E, $3A, $15, $DC, $1D, $DD, $FB, $51, $74, $71, $29,
$F8, $39, $EC, $62, $06, $17, $5D, $EA, $22, $D2, $88, $93, $E0, $22, $68, $A1,
@ -66,8 +66,8 @@ const
$60, $00, $00, $3A, $98, $00, $00, $17, $70, $9C, $BA, $51, $3C, $00, $00, $00,
$02, $62, $4B, $47, $44, $00, $00, $AA, $8D, $23, $32, $00, $00, $00, $09, $70,
$48, $59, $73, $00, $00, $0B, $13, $00, $00, $0B, $13, $01, $00, $9A, $9C, $18,
$00, $00, $00, $07, $74, $49, $4D, $45, $07, $EA, $08, $1F, $07, $2E, $1B, $C7,
$2C, $48, $09, $00, $00, $01, $2F, $49, $44, $41, $54, $38, $CB, $CD, $D2, $3D,
$00, $00, $00, $07, $74, $49, $4D, $45, $07, $EA, $09, $1D, $14, $21, $19, $27,
$B3, $C9, $F8, $00, $00, $01, $2F, $49, $44, $41, $54, $38, $CB, $CD, $D2, $3D,
$2F, $83, $51, $18, $C6, $F1, $DF, $C3, $63, $31, $34, $11, $B5, $35, $D1, $A9,
$C1, $E0, $25, $4C, $66, $49, $F9, $16, $24, $96, $C6, $37, $A8, $18, $7D, $02,
$31, $98, $18, $99, $25, $44, $F8, $02, $26, $B1, $30, $30, $78, $D9, $90, $18,
@ -99,8 +99,8 @@ const
$60, $00, $00, $3A, $98, $00, $00, $17, $70, $9C, $BA, $51, $3C, $00, $00, $00,
$02, $62, $4B, $47, $44, $00, $00, $AA, $8D, $23, $32, $00, $00, $00, $09, $70,
$48, $59, $73, $00, $00, $0B, $13, $00, $00, $0B, $13, $01, $00, $9A, $9C, $18,
$00, $00, $00, $07, $74, $49, $4D, $45, $07, $EA, $08, $1F, $07, $2E, $1B, $C7,
$2C, $48, $09, $00, $00, $01, $0F, $49, $44, $41, $54, $38, $CB, $B5, $D3, $BF,
$00, $00, $00, $07, $74, $49, $4D, $45, $07, $EA, $09, $1D, $14, $21, $19, $27,
$B3, $C9, $F8, $00, $00, $01, $0F, $49, $44, $41, $54, $38, $CB, $B5, $D3, $BF,
$2E, $83, $51, $18, $C7, $F1, $CF, $5B, $15, $0C, $74, $93, $88, $91, $0B, $E8,
$68, $60, $33, $34, $B1, $1A, $F4, $1E, $0C, $66, $8B, $C5, $05, $B8, $01, $89,
$2B, $30, $18, $24, $4C, $0D, $8B, $41, $13, $46, $62, $90, $18, $A4, $4B, $55,
@ -130,8 +130,8 @@ const
$60, $00, $00, $3A, $98, $00, $00, $17, $70, $9C, $BA, $51, $3C, $00, $00, $00,
$02, $62, $4B, $47, $44, $00, $00, $AA, $8D, $23, $32, $00, $00, $00, $09, $70,
$48, $59, $73, $00, $00, $0B, $13, $00, $00, $0B, $13, $01, $00, $9A, $9C, $18,
$00, $00, $00, $07, $74, $49, $4D, $45, $07, $EA, $08, $1F, $07, $2E, $1B, $C7,
$2C, $48, $09, $00, $00, $01, $67, $49, $44, $41, $54, $38, $CB, $B5, $D3, $3D,
$00, $00, $00, $07, $74, $49, $4D, $45, $07, $EA, $09, $1D, $14, $21, $19, $27,
$B3, $C9, $F8, $00, $00, $01, $67, $49, $44, $41, $54, $38, $CB, $B5, $D3, $3D,
$4F, $93, $61, $14, $C6, $F1, $5F, $AB, $80, $9D, $A8, $A1, $71, $95, $08, $14,
$3F, $80, $71, $D2, $8D, $C5, $D8, $A4, $A6, $61, $31, $74, $32, $31, $1A, $99,
$89, $B3, $8B, $71, $B0, $7C, $0F, $37, $42, $4B, $07, $07, $D0, $98, $30, $E0,
@ -167,8 +167,8 @@ const
$60, $00, $00, $3A, $98, $00, $00, $17, $70, $9C, $BA, $51, $3C, $00, $00, $00,
$02, $62, $4B, $47, $44, $00, $00, $AA, $8D, $23, $32, $00, $00, $00, $09, $70,
$48, $59, $73, $00, $00, $0B, $13, $00, $00, $0B, $13, $01, $00, $9A, $9C, $18,
$00, $00, $00, $07, $74, $49, $4D, $45, $07, $EA, $08, $1F, $07, $2E, $1B, $C7,
$2C, $48, $09, $00, $00, $02, $71, $49, $44, $41, $54, $38, $CB, $95, $D4, $41,
$00, $00, $00, $07, $74, $49, $4D, $45, $07, $EA, $09, $1D, $14, $21, $19, $27,
$B3, $C9, $F8, $00, $00, $02, $71, $49, $44, $41, $54, $38, $CB, $95, $D4, $41,
$68, $DB, $65, $18, $06, $F0, $5F, $E2, $DA, $2A, $2E, $59, $B2, $65, $29, $4B,
$B4, $A7, $C9, $B0, $DB, $A0, $8C, $49, $63, $55, $50, $0F, $56, $31, $ED, $68,
$BA, $CB, $70, $3B, $C8, $F0, $24, $08, $0A, $1E, $44, $99, $4E, $AA, $B2, $5D,
@ -211,6 +211,76 @@ const
$00, $00, $00, $00, $49, $45, $4E, $44, $AE, $42, $60, $82
);
{ icons8-select-all-90.png, 24 на 24 точек }
IconSelectClipsData: array[0..509] of Byte = (
$89, $50, $4E, $47, $0D, $0A, $1A, $0A, $00, $00, $00, $0D, $49, $48, $44, $52,
$00, $00, $00, $18, $00, $00, $00, $18, $08, $04, $00, $00, $00, $4A, $7E, $F5,
$73, $00, $00, $00, $20, $63, $48, $52, $4D, $00, $00, $7A, $26, $00, $00, $80,
$84, $00, $00, $FA, $00, $00, $00, $80, $E8, $00, $00, $75, $30, $00, $00, $EA,
$60, $00, $00, $3A, $98, $00, $00, $17, $70, $9C, $BA, $51, $3C, $00, $00, $00,
$02, $62, $4B, $47, $44, $00, $00, $AA, $8D, $23, $32, $00, $00, $00, $09, $70,
$48, $59, $73, $00, $00, $0B, $13, $00, $00, $0B, $13, $01, $00, $9A, $9C, $18,
$00, $00, $00, $07, $74, $49, $4D, $45, $07, $EA, $09, $1D, $14, $21, $19, $27,
$B3, $C9, $F8, $00, $00, $01, $63, $49, $44, $41, $54, $38, $CB, $B5, $D3, $BD,
$6E, $13, $41, $14, $05, $E0, $6F, $76, $13, $7B, $17, $21, $A4, $98, $48, $34,
$3C, $00, $05, $0A, $52, $78, $05, $AA, $50, $24, $8F, $C1, $1B, $E4, $1D, $D2,
$51, $41, $49, $43, $EB, $CE, $A9, $90, $A5, $28, $D6, $26, $15, $55, $8A, $3C,
$00, $42, $32, $08, $2A, $62, $25, $CE, $7A, $87, $C2, $E3, $3F, $61, $0A, $17,
$39, $53, $CC, $E8, $9E, $39, $33, $F7, $DC, $99, $CB, $43, $23, $A4, $79, $CB,
$36, $EE, $4D, $B4, $05, $DC, $A1, $8D, $E8, $4E, $6E, $1B, $63, $93, $85, $2C,
$77, $EC, $5C, $E5, $C8, $AE, $AE, $4A, $DF, $9E, $3D, $7D, $95, $AE, $5D, $47,
$2A, $E7, $8E, $E5, $90, $81, $96, $03, $FB, $32, $71, $6D, $16, $51, $66, $DF,
$81, $D6, $22, $54, $1A, $B8, $B0, $23, $17, $14, $4A, $A5, $4C, $A6, $54, $2A,
$04, $B9, $1D, $17, $06, $CA, $69, $32, $53, $FC, $34, $70, $65, $82, $5A, $AD,
$16, $C5, $B4, $22, $AA, $FD, $50, $B9, $D6, $CC, $4C, $07, $4F, $F1, $4B, $14,
$74, $3C, $9E, $A7, $16, $FC, $F1, $5B, $5C, $E2, $13, $0A, $5D, $5D, $05, $3A,
$4E, $0D, $7D, $4F, $63, $E8, $54, $67, $85, $B7, $95, $4E, $7A, $96, $4A, $7C,
$EB, $8B, $6F, $9A, $14, $0D, $AE, $DD, $AE, $F0, $49, $30, $AD, $3B, $8C, $BC,
$9F, $C7, $EE, $93, $70, $99, $4F, $1E, $32, $2F, $71, $A5, $F1, $C4, $89, $57,
$1A, $44, $1F, $7C, $4E, $BB, $16, $7C, $7A, $87, $8D, $51, $EA, $EB, $4F, $EB,
$2C, $D7, $4E, $23, $5B, $C7, $CF, $F2, $6D, $A7, $F9, $91, $77, $5E, $AC, $98,
$FE, $68, $B4, $C4, $27, $41, $34, $44, $44, $E1, $8D, $D7, $F3, $6F, $96, $7B,
$EE, $93, $D1, $12, $BF, $F9, $C3, $85, $74, $CF, $5B, $F4, $D4, $FF, $F1, $F8,
$0F, $BF, $C1, $E7, $9B, $09, $CE, $DC, $B8, $74, $B8, $B6, $1F, $0E, $5D, $BA,
$71, $B6, $5C, $A5, $B1, $9E, $4C, $3E, $EF, $BF, $55, $04, $8D, $AF, $7A, $C6,
$0B, $D3, $E4, $5A, $36, $68, $D1, $87, $C4, $5F, $C8, $81, $83, $A2, $85, $86,
$DA, $DD, $00, $00, $00, $00, $49, $45, $4E, $44, $AE, $42, $60, $82
);
{ icons8-width-90.png, 24 на 24 точек }
IconSelectRangeData: array[0..473] of Byte = (
$89, $50, $4E, $47, $0D, $0A, $1A, $0A, $00, $00, $00, $0D, $49, $48, $44, $52,
$00, $00, $00, $18, $00, $00, $00, $18, $08, $04, $00, $00, $00, $4A, $7E, $F5,
$73, $00, $00, $00, $20, $63, $48, $52, $4D, $00, $00, $7A, $26, $00, $00, $80,
$84, $00, $00, $FA, $00, $00, $00, $80, $E8, $00, $00, $75, $30, $00, $00, $EA,
$60, $00, $00, $3A, $98, $00, $00, $17, $70, $9C, $BA, $51, $3C, $00, $00, $00,
$02, $62, $4B, $47, $44, $00, $00, $AA, $8D, $23, $32, $00, $00, $00, $09, $70,
$48, $59, $73, $00, $00, $0B, $13, $00, $00, $0B, $13, $01, $00, $9A, $9C, $18,
$00, $00, $00, $07, $74, $49, $4D, $45, $07, $EA, $09, $1D, $14, $21, $19, $27,
$B3, $C9, $F8, $00, $00, $01, $3F, $49, $44, $41, $54, $38, $CB, $CD, $D2, $BF,
$4A, $1C, $51, $18, $05, $F0, $DF, $8C, $3B, $8E, $53, $64, $70, $94, $2D, $5D,
$B0, $90, $B0, $C5, $DA, $A6, $14, $C1, $28, $E8, $1B, $F8, $0C, $61, $FB, $7D,
$02, $5F, $40, $D8, $07, $91, $6D, $92, $52, $2D, $6C, $BD, $45, $08, $29, $84,
$ED, $C4, $C2, $90, $26, $8C, $6E, $66, $53, $B8, $7F, $26, $82, $85, $0A, $E2,
$B9, $C5, $2D, $CE, $3D, $DF, $F7, $9D, $F3, $5D, $DE, $1D, $16, $26, $77, $2E,
$55, $CA, $7C, $50, $4A, $14, $4A, $91, $15, $23, $63, $85, $C8, $68, $C2, $CF,
$D0, $D0, $D7, $17, $EB, $3A, $91, $DB, $73, $66, $C3, $86, $33, $7B, $72, $27,
$BA, $62, $7D, $7D, $8D, $87, $A7, $90, $D8, $44, $22, $A0, $34, $34, $F0, $0B,
$03, $43, $A5, $6F, $C2, $8C, $1F, $4D, $3B, $64, $CE, $9D, $CB, $9E, $1C, $BC,
$C6, $C7, $CF, $35, $FD, $2A, $C1, $58, $C7, $BE, $C5, $FF, $F8, $45, $FB, $3A,
$C6, $F5, $7C, $A6, $A8, $7C, $72, $AC, $12, $DC, $88, $66, $25, $9A, $8E, $C4,
$BE, $A8, $A6, $A5, $A3, $89, $A9, $AF, $D6, $FD, $D6, $76, $EB, $87, $BF, $35,
$C1, $82, $8F, $0A, $DF, $E5, $AE, $EC, $FA, $F3, $02, $0F, $F3, $D8, $4E, $6D,
$09, $2E, $AD, $59, $92, $4D, $CE, $92, $35, $97, $82, $2D, $A7, $D3, $58, $E7,
$1E, $62, $17, $0E, $B5, $5C, $BB, $AB, $95, $BA, $D6, $33, $F4, $73, $3E, $49,
$A3, $46, $46, $82, $F0, $A8, $F7, $9D, $01, $F5, $95, $BE, $ED, $E2, $2A, $DB,
$BA, $52, $6D, $3D, $4D, $4D, $3D, $6D, $A9, $AE, $6D, $D5, $63, $C1, $48, $10,
$DC, $EB, $D8, $91, $6A, $39, $B0, $6C, $D9, $81, $96, $D4, $67, $1D, $F7, $82,
$30, $FF, $AB, $90, $CB, $91, $29, $44, $12, $AB, $62, $B1, $55, $89, $48, $21,
$9B, $F1, $EF, $13, $FF, $00, $8F, $00, $4E, $D0, $9E, $28, $A1, $C0, $00, $00,
$00, $00, $49, $45, $4E, $44, $AE, $42, $60, $82
);
{ icons8-показ-видеокадров-30.png, значок окна, 32 на 32 точек, формат ico }
IconAppData: array[0..4285] of Byte = (
$00, $00, $01, $00, $01, $00, $20, $20, $00, $00, $01, $00, $20, $00, $A8, $10,

View file

@ -20,10 +20,13 @@
Запасной путь: опрос ffprobe. Медленно, но работает с любым контейнером.
Чего этот разбор пока не учитывает: блок elst (список правок), который
способен сдвинуть всю дорожку по времени. У обычных записей с камер его
нет. Если появится материал, где метки разъезжаются, начинать копать
надо отсюда.
Блок elst (список правок) разбор учитывает — и это не мелочь. У любой
записи с B-кадрами первый кадр показывается позже, чем декодируется, и
контейнер выправляет это списком правок: «показ начинать с такого-то
места дорожки». ffmpeg правку применяет, а мы раньше нет — и весь
индекс оказывался сдвинут на задержку B-кадров, на пробе это два кадра.
Дальше по цепочке: рез просил перекодировать до метки, которая опорным
кадром не была, копия начиналась раньше, и кадры на стыке удваивались.
}
unit uKeyframeIndex;
@ -311,6 +314,9 @@ var
DtsCursor, CttsCursor: TRunCursor;
Pts: Int64;
FoundVideo: Boolean;
EdtsStart, EdtsEnd, ElstStart, ElstEnd: Int64;
MediaTime: Int64;
EntryCount: LongWord;
begin
Result := False;
AError := '';
@ -391,6 +397,40 @@ begin
if Timescale = 0 then
Continue;
{ Список правок. Нас интересует одно число — с какого места
дорожки начинается показ: на него сдвинуты все времена, которые
показывает ffmpeg. У записи с B-кадрами это ровно задержка
показа, у записи без них правки обычно нет вовсе.
Отрицательное значение (-1) означает пустой промежуток в начале —
он времена не сдвигает, поэтому такие записи пропускаем. }
MediaTime := 0;
if FindBox(Stream, TrakStart, TrakEnd, 'edts', EdtsStart, EdtsEnd) then
if FindBox(Stream, EdtsStart, EdtsEnd, 'elst', ElstStart, ElstEnd) then
begin
Stream.Position := ElstStart;
Version := 0;
Stream.ReadBuffer(Version, 1);
Stream.Position := ElstStart + 4;
EntryCount := ReadU32(Stream);
if EntryCount > 0 then
begin
if Version = 1 then
begin
{ версия 1: длительность и время — по восемь байт }
Stream.Position := ElstStart + 8 + 8;
MediaTime := Int64(ReadU64(Stream));
end
else
begin
Stream.Position := ElstStart + 8 + 4;
MediaTime := Int32(ReadU32(Stream));
end;
if MediaTime < 0 then
MediaTime := 0;
end;
end;
if not FindBox(Stream, MdiaStart, MdiaEnd, 'minf', MinfStart, MinfEnd) then
Continue;
if not FindBox(Stream, MinfStart, MinfEnd, 'stbl', StblStart, StblEnd) then
@ -436,6 +476,11 @@ begin
Pts := CursorSumBefore(DtsCursor, Sample);
if HasCtts then
Inc(Pts, CursorValueAt(CttsCursor, Sample));
{ Правка сдвигает начало показа: ffmpeg считает времена от неё, и
индекс должен считать так же. }
Dec(Pts, MediaTime);
if Pts < 0 then
Pts := 0;
AIndex.Times[I] := Pts / Timescale;
end;
@ -636,6 +681,7 @@ begin
AIndex.Times[I] := Arr.Floats[I];
end;
Result := AIndex.AllFrames or (Length(AIndex.Times) > 0);
finally
Root.Free;

View file

@ -97,11 +97,21 @@ object MainForm: TMainForm
OnClick = btnRippleMenuClick
TabStop = False
end
object btnSelectMode: TThemedButton
Left = 448
Height = 28
Top = 6
Width = 28
Icon = tiSelectClips
OnClick = btnSelectModeClick
ShowHint = True
TabStop = False
end
object lblStatus: TLabel
Left = 488
Left = 518
Height = 20
Top = 10
Width = 780
Width = 750
Anchors = [akTop, akLeft, akRight]
AutoSize = False
Caption = 'Готов.'

File diff suppressed because it is too large Load diff

View file

@ -70,6 +70,10 @@ type
{ Номера подключённых звуковых дорожек, в порядке подключения. }
FAudioIds: array of string;
FLastGraph: string;
{ Что сейчас выставлено обрезкой на выводе. Хранится, чтобы не
переустанавливать свойство на каждом кадре. }
FLastCrop: string;
FLastRotate: string;
FSubtitlePath: string;
{ Что произошло с выбором звуковой дорожки. Читается окном для
журнала: без этого понять, откуда взялся звук, нечем. }
@ -147,6 +151,18 @@ type
громкость текущего куска и есть громкость всего вывода. }
procedure SetVolume(const APercent: Double);
{ Обрезка на выводе: показывать только этот кусок кадра исходника.
Делает её сама видеокарта, цепочки фильтров тут нет и кадры в
обычную память не копируются. Годится на то, чего цепочкой дорого:
показать собственное кадрирование клипа. Действует на весь вывод
сразу, поэтому включать и снимать её обязан тот, кто знает, где
сейчас курсор.
Пустой прямоугольник снимает обрезку. Поворот задаётся тем же
способом — отдельным свойством, а не фильтром. }
procedure SetOutputCrop(const ARect: TRect; ARotation: Integer);
{ Цепочка фильтров ffmpeg поверх всего, что играет.
Нужна ровно для одного: показать кадр проекта, а не кадр исходника.
@ -727,6 +743,32 @@ begin
mpv_set_property(FHandle, 'volume', MPV_FORMAT_DOUBLE, @V);
end;
procedure TMpvPlayer.SetOutputCrop(const ARect: TRect; ARotation: Integer);
var
Value, Rot: string;
begin
if FHandle = nil then
Exit;
if (ARect.Right > ARect.Left) and (ARect.Bottom > ARect.Top) then
Value := Format('%dx%d+%d+%d',
[ARect.Right - ARect.Left, ARect.Bottom - ARect.Top,
ARect.Left, ARect.Top])
else
Value := '';
Rot := IntToStr(((ARotation mod 360) + 360) mod 360);
{ Лишний раз не трогаем: смена свойства перестраивает вывод, и на
каждом кадре это было бы заметно глазом. }
if (Value = FLastCrop) and (Rot = FLastRotate) then
Exit;
FLastCrop := Value;
FLastRotate := Rot;
mpv_set_property_string(FHandle, 'video-crop', PAnsiChar(Value));
mpv_set_property_string(FHandle, 'video-rotate', PAnsiChar(Rot));
end;
procedure TMpvPlayer.SetVideoFilter(const AFilter: string);
var
Value: string;

View file

@ -71,6 +71,8 @@ type
у заготовки для файла, у которой клипа ещё нет), и кнопки не
будет. }
FKey: PColorKey;
{ Какое место исходника показывать в окне. }
FAtTime: Double;
FSourceInfo: TMediaInfo;
FDstWidth: Integer;
FDstHeight: Integer;
@ -106,11 +108,18 @@ type
procedure WriteControls;
procedure ScheduleRefresh;
procedure RefreshPreview(Sender: TObject);
{ Время кадра в исходнике: заданное снаружи или своё. }
function FrameTime: Double;
public
{ Готовит окно. ADstWidth и ADstHeight — кадр проекта. }
{ AAtTime — какое место исходника показывать. Меньше нуля — выбрать
самим: десятая часть от начала файла. Для клипа передаётся его
собственное время, иначе прицелиться нечем: клип вырезан из середины
съёмки, а в окне показывалась совсем другая сцена. }
procedure Setup(const APath: string; const AInfo: TMediaInfo;
ADstWidth, ADstHeight: Integer; const ATransform: TMediaTransform;
const APreviewFile: string; AKey: PColorKey = nil);
const APreviewFile: string; AKey: PColorKey = nil;
AAtTime: Double = -1);
property Transform: TMediaTransform read FTransform;
end;
@ -124,7 +133,7 @@ type
function EditTransform(AOwner: TComponent; const APath: string;
const AInfo: TMediaInfo; ADstWidth, ADstHeight: Integer;
var ATransform: TMediaTransform; const APreviewFile: string;
AKey: PColorKey = nil): Boolean;
AKey: PColorKey = nil; AAtTime: Double = -1): Boolean;
implementation
@ -201,6 +210,21 @@ begin
end;
{ Какое место исходника показывать.
Задано снаружи — берём его: у клипа это его собственное время, и только
по нему можно прицелиться кадрированием. Не задано (кадрирование файла
целиком из списка) — десятая часть от начала, но не дальше пятой
секунды: первые кадры съёмки часто пустые. }
function TPanCropForm.FrameTime: Double;
begin
if FSourceInfo.Kind = mkImage then
Exit(0);
if FAtTime >= 0 then
Exit(Max(0, Min(FAtTime, Max(0, FSourceInfo.Duration - 0.05))));
Result := Min(5, Max(0, FSourceInfo.Duration * 0.1));
end;
procedure TPanCropForm.FormDestroy(Sender: TObject);
begin
FreeAndNil(FSourceBmp);
@ -211,8 +235,9 @@ end;
procedure TPanCropForm.Setup(const APath: string; const AInfo: TMediaInfo;
ADstWidth, ADstHeight: Integer; const ATransform: TMediaTransform;
const APreviewFile: string; AKey: PColorKey);
const APreviewFile: string; AKey: PColorKey; AAtTime: Double);
begin
FAtTime := AAtTime;
FKey := AKey;
btnKey.Visible := AKey <> nil;
FSourcePath := APath;
@ -321,10 +346,7 @@ var
begin
if FKey = nil then
Exit;
if FSourceInfo.Kind = mkImage then
AtTime := 0
else
AtTime := Min(5, Max(0, FSourceInfo.Duration * 0.1));
AtTime := FrameTime;
EditColorKey(FKey^, FSourcePath, AtTime, FPreviewFile + '.key.png');
end;
@ -407,45 +429,14 @@ end;
{ Рамка по настройке. Обратное к TransformFromSel: там же и объяснение,
откуда берутся числа. }
procedure TPanCropForm.SelFromTransform;
var
KFit, KFill, K: Double;
SW, SH, CW, CH, CX, CY: Double;
W, H: Double;
begin
UpdateRotatedSize;
if (FRotW <= 0) or (FRotH <= 0) or (FDstWidth <= 0) or (FDstHeight <= 0) then
Exit;
KFit := Min(FDstWidth / FRotW, FDstHeight / FRotH);
KFill := Max(FDstWidth / FRotW, FDstHeight / FRotH);
if FTransform.Fit = fmFill then
K := KFill
else
K := KFit;
if FTransform.Fit = fmStretch then
begin
{ Растяжение рамкой не описывается: берётся весь кадр. }
FSel := Rect(0, 0, FRotW, FRotH);
Exit;
end;
if FTransform.Zoom > 0 then
K := K * FTransform.Zoom;
if K <= 0 then
Exit;
SW := FRotW * K;
SH := FRotH * K;
CW := Min(SW, FDstWidth);
CH := Min(SH, FDstHeight);
CX := (SW - CW) / 2 + FTransform.OffsetX * FDstWidth;
CY := (SH - CH) / 2 + FTransform.OffsetY * FDstHeight;
CX := Max(0, Min(SW - CW, CX));
CY := Max(0, Min(SH - CH, CY));
W := CW / K;
H := CH / K;
FSel := Rect(Round(CX / K), Round(CY / K),
Round(CX / K + W), Round(CY / K + H));
{ Считает uTransform: той же рамкой пользуется предпросмотр, отдавая её
плееру как обрезку на выводе. Двух расчётов быть не должно — они
разойдутся, и окно начнёт обещать не то, что покажет плеер. }
TransformSourceRect(FTransform, FRotW, FRotH, FDstWidth, FDstHeight, FSel);
end;
{ Настройка по рамке.
@ -515,10 +506,7 @@ begin
Exit;
Path := FPreviewFile + '.src.png';
if FSourceInfo.Kind = mkImage then
AtTime := 0
else
AtTime := Min(5, Max(0, FSourceInfo.Duration * 0.1));
AtTime := FrameTime;
{ Пустая цепочка фильтров: нужен именно исходный кадр, без вписывания и
полей — иначе рамку не по чему тянуть. }
@ -677,10 +665,7 @@ begin
PrevW, PrevH);
lblFilter.Caption := Filter;
if FSourceInfo.Kind = mkImage then
AtTime := 0
else
AtTime := Min(5, Max(0, FSourceInfo.Duration * 0.1));
AtTime := FrameTime;
Res := RunProcess(FfmpegExe,
ArgsPreviewFrame(FSourcePath, FPreviewFile, AtTime, Filter),
@ -720,14 +705,14 @@ end;
function EditTransform(AOwner: TComponent; const APath: string;
const AInfo: TMediaInfo; ADstWidth, ADstHeight: Integer;
var ATransform: TMediaTransform; const APreviewFile: string;
AKey: PColorKey): Boolean;
AKey: PColorKey; AAtTime: Double = -1): Boolean;
var
Form: TPanCropForm;
begin
Form := TPanCropForm.Create(AOwner);
try
Form.Setup(APath, AInfo, ADstWidth, ADstHeight, ATransform, APreviewFile,
AKey);
AKey, AAtTime);
Result := Form.ShowModal = mrOK;
if Result then
ATransform := Form.Transform;

View file

@ -34,6 +34,25 @@ type
несколько раз в секунду. }
TProcCancelQuery = function: Boolean of object;
{ Накопитель, который режет поток байтов на строки.
Отдельный класс нужен потому, что ffmpeg заканчивает строки по-разному:
вывод -progress использует перевод строки, а строку состояния в потоке
ошибок он перерисовывает возвратом каретки. Оба символа считаются
концом строки, пустые строки отбрасываются.
Наружу вынесен не для чужого пользования, а потому что его держит
приёмник ниже. }
TLineSplitter = class
private
FBuffer: string;
FOnLine: TProcLineEvent;
public
constructor Create(AOnLine: TProcLineEvent);
procedure Feed(const AData: string);
procedure Flush;
end;
TProcResult = record
Started: Boolean;
Cancelled: Boolean;
@ -42,6 +61,45 @@ type
ErrorText: string;
end;
{ Принимающий процесс: его ввод — труба, в которую мы льём то, что
вышло из процессов-отправителей.
Зачем класс, а не ещё одна функция: приёмник живёт дольше любого
отправителя. Один ffmpeg пишет готовый файл, а куски в него шлёт
десяток других, запускаемых по очереди.
Перелив идёт через нашу память, а не настоящей трубой между двумя
чужими процессами: собственные трубы у дочерних процессов есть в
любой системе, а наследование чужого дескриптора — нет.
Вывод приёмника вычитывается на каждом шаге перелива. Иначе труба
его журнала заполнилась бы, приёмник встал бы на записи в неё, а мы
ждали бы, пока он прочтёт — и оба ждали бы друг друга. }
TProcSink = class
private
FProc: TProcessUTF8;
FOutSplit, FErrSplit: TLineSplitter;
FStarted: Boolean;
FClosed: Boolean;
FError: string;
public
constructor Create(const AExe: string; const AArgs: array of string;
AOnStdOut, AOnStdErr: TProcLineEvent);
destructor Destroy; override;
{ Вычитывает журнал приёмника, не дожидаясь его конца. }
procedure Pump;
{ Отдаёт приёмнику очередной кусок. False — труба оборвалась,
то есть приёмник умер и слать дальше некуда. }
function Feed(const ABuf; ACount: Integer): Boolean;
{ Закрывает ввод: для приёмника это конец материала. }
procedure CloseInput;
{ Ждёт конца работы и отдаёт код возврата. }
function Wait: Integer;
procedure Stop;
property Started: Boolean read FStarted;
property Error: string read FError;
end;
{ Запускает процесс и ждёт его завершения, разбирая вывод на строки.
AOnStdOut получает поток вывода (туда ffmpeg пишет -progress),
@ -50,6 +108,15 @@ type
function RunProcess(const AExe: string; const AArgs: array of string;
AOnStdOut, AOnStdErr: TProcLineEvent; ACancel: TProcCancelQuery): TProcResult;
{ Запускает процесс и переливает его вывод в приёмник.
Вывод в трубу, а не в файл: ASink получает байты, а не строки. Поток
ошибок по-прежнему разбирается построчно — туда ffmpeg пишет и журнал,
и ход работы, если его об этом попросить (-progress pipe:2). }
function RunProcessInto(const AExe: string; const AArgs: array of string;
ASink: TProcSink; AOnStdErr: TProcLineEvent;
ACancel: TProcCancelQuery): TProcResult;
{ Путь к исполняемому файлу по имени. Пустая строка — не найден. }
function FindTool(const AName: string): string;
@ -66,23 +133,6 @@ const
ReadChunk = 16 * 1024;
type
{ Накопитель, который режет поток байтов на строки.
Отдельный класс нужен потому, что ffmpeg заканчивает строки по-разному:
вывод -progress использует перевод строки, а строку состояния в потоке
ошибок он перерисовывает возвратом каретки. Оба символа считаются
концом строки, пустые строки отбрасываются. }
TLineSplitter = class
private
FBuffer: string;
FOnLine: TProcLineEvent;
public
constructor Create(AOnLine: TProcLineEvent);
procedure Feed(const AData: string);
procedure Flush;
end;
constructor TLineSplitter.Create(AOnLine: TProcLineEvent);
begin
inherited Create;
@ -172,6 +222,246 @@ begin
end;
end;
{ --- Приёмник --- }
constructor TProcSink.Create(const AExe: string; const AArgs: array of string;
AOnStdOut, AOnStdErr: TProcLineEvent);
var
I: Integer;
begin
inherited Create;
FOutSplit := TLineSplitter.Create(AOnStdOut);
FErrSplit := TLineSplitter.Create(AOnStdErr);
FProc := TProcessUTF8.Create(nil);
if not FileExists(AExe) then
begin
FError := 'Не найден исполняемый файл: ' + AExe;
Exit;
end;
FProc.Executable := AExe;
for I := 0 to High(AArgs) do
FProc.Parameters.Add(AArgs[I]);
FProc.Options := [poUsePipes, poNoConsole];
FProc.ShowWindow := swoHIDE;
try
FProc.Execute;
FStarted := True;
except
on E: Exception do
FError := 'Не удалось запустить ' + AExe + ': ' + E.Message;
end;
end;
destructor TProcSink.Destroy;
begin
Stop;
FProc.Free;
FErrSplit.Free;
FOutSplit.Free;
inherited Destroy;
end;
procedure TProcSink.Pump;
begin
if not FStarted then
Exit;
DrainPipe(FProc.Output, FOutSplit);
DrainPipe(FProc.Stderr, FErrSplit);
end;
function TProcSink.Feed(const ABuf; ACount: Integer): Boolean;
begin
Result := False;
if (not FStarted) or FClosed or (ACount <= 0) then
Exit;
{ Приёмник мог умереть — на разбитой трубе запись даёт исключение или
сигнал. Молча считаем это концом работы: настоящую причину скажет код
возврата и журнал. }
if not FProc.Running then
Exit;
try
Result := FProc.Input.Write(ABuf, ACount) = ACount;
except
Result := False;
end;
{ Журнал приёмника вычитывается после каждой порции: его труба невелика,
и переполнившись, она остановила бы самого приёмника. }
Pump;
end;
procedure TProcSink.CloseInput;
begin
if (not FStarted) or FClosed then
Exit;
FClosed := True;
try
FProc.CloseInput;
except
{ Труба уже разбита — значит приёмника нет, и закрывать нечего. }
end;
end;
function TProcSink.Wait: Integer;
begin
if not FStarted then
Exit(-1);
CloseInput;
while FProc.Running do
begin
Pump;
Sleep(IdleSleepMs);
end;
while DrainPipe(FProc.Output, FOutSplit) +
DrainPipe(FProc.Stderr, FErrSplit) > 0 do
;
FOutSplit.Flush;
FErrSplit.Flush;
Result := FProc.ExitStatus;
end;
procedure TProcSink.Stop;
begin
if not FStarted then
Exit;
CloseInput;
if FProc.Running then
try
FProc.Terminate(1);
except
{ Уже завершился сам. }
end;
end;
{ Перелив идёт порциями: прочитали у отправителя — отдали приёмнику.
Порция небольшая по той же причине, по которой мал буфер трубы: между
записями надо успевать вычитывать журналы обоих процессов и смотреть,
не просят ли нас остановиться. }
function RunProcessInto(const AExe: string; const AArgs: array of string;
ASink: TProcSink; AOnStdErr: TProcLineEvent;
ACancel: TProcCancelQuery): TProcResult;
var
P: TProcessUTF8;
ErrSplit: TLineSplitter;
I, Avail, Got: Integer;
Terminated, Broken: Boolean;
Buf: array[0..ReadChunk - 1] of Byte;
{ Забирает у отправителя всё, что уже готово, и отдаёт приёмнику.
Возвращает число перелитых байт. }
function MoveData: Integer;
begin
Result := 0;
if P.Output = nil then
Exit;
Avail := P.Output.NumBytesAvailable;
while Avail > 0 do
begin
if Avail > SizeOf(Buf) then
Avail := SizeOf(Buf);
Got := P.Output.Read(Buf, Avail);
if Got <= 0 then
Break;
if not ASink.Feed(Buf, Got) then
begin
Broken := True;
Break;
end;
Inc(Result, Got);
Avail := P.Output.NumBytesAvailable;
end;
end;
begin
Result.Started := False;
Result.Cancelled := False;
Result.ExitCode := -1;
Result.ErrorText := '';
if ASink = nil then
begin
Result.ErrorText := 'Некуда переливать: приёмник не задан.';
Exit;
end;
if not ASink.Started then
begin
Result.ErrorText := ASink.Error;
if Result.ErrorText = '' then
Result.ErrorText := 'Приёмник не запущен.';
Exit;
end;
if not FileExists(AExe) then
begin
Result.ErrorText := 'Не найден исполняемый файл: ' + AExe;
Exit;
end;
P := TProcessUTF8.Create(nil);
ErrSplit := TLineSplitter.Create(AOnStdErr);
Broken := False;
try
P.Executable := AExe;
for I := 0 to High(AArgs) do
P.Parameters.Add(AArgs[I]);
P.Options := [poUsePipes, poNoConsole];
P.ShowWindow := swoHIDE;
try
P.Execute;
except
on E: Exception do
begin
Result.ErrorText := 'Не удалось запустить ' + AExe + ': ' + E.Message;
Exit;
end;
end;
Result.Started := True;
Terminated := False;
while P.Running do
begin
I := MoveData + DrainPipe(P.Stderr, ErrSplit);
if Broken then
begin
{ Приёмник кончился — отправителю работать не для кого. }
if not Terminated then
begin
Terminated := True;
P.Terminate(1);
end;
Break;
end;
if (not Terminated) and Assigned(ACancel) and ACancel() then
begin
Result.Cancelled := True;
Terminated := True;
P.Terminate(1);
end;
if I = 0 then
Sleep(IdleSleepMs);
end;
{ Хвост: после завершения в трубах обычно остаётся непрочитанное. }
if not Broken then
while MoveData + DrainPipe(P.Stderr, ErrSplit) > 0 do
;
ErrSplit.Flush;
Result.ExitCode := P.ExitStatus;
if Broken and (Result.ExitCode = 0) then
Result.ExitCode := -1;
finally
ErrSplit.Free;
P.Free;
end;
end;
function RunProcess(const AExe: string; const AArgs: array of string;
AOnStdOut, AOnStdErr: TProcLineEvent; ACancel: TProcCancelQuery): TProcResult;
var

View file

@ -30,13 +30,12 @@ object ProgressForm: TProgressForm
Caption = 'Готовлю'
ParentColor = False
end
object pbBar: TProgressBar
object pbBar: TThemedProgress
Left = 12
Height = 22
Top = 40
Height = 10
Top = 46
Width = 736
Anchors = [akTop, akLeft, akRight]
TabOrder = 0
end
object lblEta: TLabel
Left = 12

View file

@ -28,7 +28,11 @@ type
lblStage: TLabel;
lblEta: TLabel;
memoLog: TLogView;
pbBar: TProgressBar;
{ Полоса своя, а не системная: TProgressBar под gtk2 рисуется
ступеньками, по восемь кусков на всю ширину, и ход работы в ней
виден рывками. Эта заполняется сплошь, как та, что внизу главного
окна. }
pbBar: TThemedProgress;
pnlTop: TPanel;
procedure btnActionClick(Sender: TObject);
procedure FormCloseQuery(Sender: TObject; var CanClose: Boolean);
@ -59,11 +63,6 @@ implementation
{$R *.lfm}
const
{ Прогресс держим в целых долях от десяти тысяч: с шагом в один процент
полоса на длинной операции выглядит замершей. }
BarScale = 10000;
{ TProgressForm }
procedure TProgressForm.FormCreate(Sender: TObject);
@ -71,9 +70,7 @@ begin
{ Цвета задаём сами: системная тема может быть светлой, и тогда
тёмный таймлайн оказался бы в белой рамке. }
ApplyDarkTheme(Self);
pbBar.Min := 0;
pbBar.Max := BarScale;
pbBar.Position := 0;
pbBar.Fraction := 0;
memoLog.Clear;
btnAction.Caption := 'Отмена';
FStartedAt := Now;
@ -121,7 +118,7 @@ begin
AValue := 0;
if AValue > 1 then
AValue := 1;
pbBar.Position := Round(AValue * BarScale);
pbBar.Fraction := AValue;
FLastFraction := AValue;
Elapsed := (Now - FStartedAt) * SecsPerDay;

View file

@ -377,6 +377,9 @@ type
{ --- Файл проекта --- }
function SaveToFile(const APath: string; out AError: string): Boolean;
{ Копия проекта в другой файл: имя проекта и признак «изменён» не
трогаются. Для автосохранения. }
function SaveCopyTo(const APath: string; out AError: string): Boolean;
function LoadFromFile(const APath: string; out AError: string): Boolean;
property Pool[AIndex: Integer]: TMediaItem read GetPoolItem;
@ -419,6 +422,19 @@ type
такой нет — заводится новая. Время клипа не меняется: человек ставил
его именно туда. }
function FixMisplacedClips: Integer;
{ Сколько клипов стоит мимо кадровой сетки.
Доля кадра в монтаже — это не «почти точно»: картинка всё равно
выйдет целым числом кадров, а звук набирается с точностью до
отсчёта. Пока монтаж просит доли кадра, предпросмотр и готовый ролик
не могут совпасть в точности. }
function OffGridClipCount: Integer;
{ Ставит клипы на кадровую сетку: начало на монтаже, длину и точку
входа в исходник. Возвращает, сколько клипов подвинулось.
Сдвиг не превышает полкадра — на глаз и на слух незаметен, но
монтаж перестаёт хранить невозможные времена. }
function SnapClipsToFrameGrid: Integer;
{ Автоматическое схлопывание при перетаскивании клипов. }
property RippleEnabled: Boolean read FRippleEnabled write FRippleEnabled;
property FileName: string read FFileName write FFileName;
@ -441,6 +457,14 @@ function SameColorKey(const A, B: TColorKey): Boolean;
центру — то, что читается на любой картинке. }
function DefaultTextStyle: TTextStyle;
{ Время на кадровую сетку проекта.
Между кадрами времени не существует: доля кадра в монтаже означает, что
картинка и звук округлят её каждый по-своему и разойдутся. Мера общая
для монтажа, уплощения и подрезки по участку — чтобы все считали
одинаково. }
function FrameAlign(const ATime: Double; const AMaster: TMediaInfo): Double;
{ Шрифт по умолчанию. Разный по системам: одинаковых имён у них нет. }
const
DefaultTextFont =
@ -1122,6 +1146,81 @@ begin
end;
end;
function TProject.OffGridClipCount: Integer;
var
T, C: Integer;
Track: TTrack;
Clip: TClip;
function OffGrid(const ATime: Double): Boolean;
begin
Result := Abs(FrameAlign(ATime, FMaster) - ATime) > 0.0005;
end;
begin
Result := 0;
if (FMaster.FpsNum <= 0) or (FMaster.FpsDen <= 0) then
Exit;
for T := 0 to TrackCount - 1 do
begin
Track := Tracks[T];
for C := 0 to Track.Count - 1 do
begin
Clip := Track[C];
if OffGrid(Clip.TlStart) or OffGrid(Clip.Duration) or
OffGrid(Clip.SrcIn) then
Inc(Result);
end;
end;
end;
function TProject.SnapClipsToFrameGrid: Integer;
var
T, C: Integer;
Track: TTrack;
Clip: TClip;
NewStart, NewDur, NewSrc, Step: Double;
begin
Result := 0;
if (FMaster.FpsNum <= 0) or (FMaster.FpsDen <= 0) then
Exit;
Step := FMaster.FpsDen / FMaster.FpsNum;
for T := 0 to TrackCount - 1 do
begin
Track := Tracks[T];
for C := 0 to Track.Count - 1 do
begin
Clip := Track[C];
NewStart := FrameAlign(Clip.TlStart, FMaster);
NewDur := FrameAlign(Clip.Duration, FMaster);
{ Клип короче кадра оставить нельзя: он исчезнет. }
if NewDur < Step then
NewDur := Step;
NewSrc := FrameAlign(Clip.SrcIn, FMaster);
if NewSrc < 0 then
NewSrc := 0;
if (Abs(NewStart - Clip.TlStart) > 0.0000001) or
(Abs(NewDur - Clip.Duration) > 0.0000001) or
(Abs(NewSrc - Clip.SrcIn) > 0.0000001) then
begin
Clip.TlStart := NewStart;
Clip.Duration := NewDur;
Clip.SrcIn := NewSrc;
Inc(Result);
end;
end;
{ Порядок мог измениться на доли кадра — пересортируем, иначе дальше
всё, что полагается на порядок, будет считать неверно. }
Track.Sort;
end;
{ Отметки участка — той же мерой: по ним режется сборка. }
FRegionStart := FrameAlign(FRegionStart, FMaster);
FRegionEnd := FrameAlign(FRegionEnd, FMaster);
end;
function TProject.FixMisplacedClips: Integer;
var
T, C, K: Integer;
@ -1426,6 +1525,17 @@ end;
{ Цветовой ключ по умолчанию: выключен, пурпурный, с небольшим запасом по
сходству — сжатие видео размывает чистый цвет, и точное совпадение
оставило бы кайму. }
function FrameAlign(const ATime: Double; const AMaster: TMediaInfo): Double;
var
Step: Double;
begin
Result := ATime;
if (AMaster.FpsNum <= 0) or (AMaster.FpsDen <= 0) then
Exit;
Step := AMaster.FpsDen / AMaster.FpsNum;
Result := Round(ATime / Step) * Step;
end;
function DefaultColorKey: TColorKey;
begin
Result.Enabled := False;
@ -1843,7 +1953,10 @@ begin
end;
end;
function TProject.SaveToFile(const APath: string; out AError: string): Boolean;
{ Запись проекта в произвольный файл, не трогая ни имя проекта, ни признак
«изменён». Нужна для автосохранения: копия пишется сама собой, и проект
от этого не должен считаться сохранённым. }
function TProject.SaveCopyTo(const APath: string; out AError: string): Boolean;
var
Text: TStringList;
begin
@ -1851,23 +1964,75 @@ begin
Result := False;
Text := TStringList.Create;
try
{ Имя файла назначаем до записи: от него зависит путь к кэшу, который
попадает внутрь проекта косвенно — через сгенерированные клипы. }
FFileName := ExpandFileName(APath);
Text.Text := Serialize;
try
Text.SaveToFile(FFileName);
FModified := False;
Text.SaveToFile(APath);
Result := True;
except
on E: Exception do
AError := 'Не удалось сохранить проект: ' + E.Message;
AError := 'Не удалось записать ' + APath + ': ' + E.Message;
end;
finally
Text.Free;
end;
end;
{ Сохранение проекта.
Пишем сперва во временный файл рядом, и только когда он записан целиком,
подставляем его на место настоящего. Прежний файл при этом становится
запасным (.bak). Так падение или полный диск посреди записи не оставляют
от проекта огрызок, а испорченное сохранение всегда можно откатить.
Признак «изменён» снимается только после того, как файл на месте: если
запись не удалась, проект остаётся несохранённым и программа спросит
о нём при выходе. }
function TProject.SaveToFile(const APath: string; out AError: string): Boolean;
var
Full, Temp, Backup: string;
begin
AError := '';
Result := False;
{ Имя файла назначаем до записи: от него зависит путь к кэшу, который
попадает внутрь проекта косвенно — через сгенерированные клипы. }
Full := ExpandFileName(APath);
FFileName := Full;
Temp := Full + '.tmp';
Backup := Full + '.bak';
if not SaveCopyTo(Temp, AError) then
Exit;
try
if FileExists(Full) then
begin
if FileExists(Backup) then
DeleteFile(Backup);
if not RenameFile(Full, Backup) then
begin
AError := 'Не удалось отложить прежний файл проекта: ' + Backup;
Exit;
end;
end;
if not RenameFile(Temp, Full) then
begin
{ Подстановка не удалась — возвращаем прежний файл на место, чтобы
не остаться вообще без проекта. }
if FileExists(Backup) and (not FileExists(Full)) then
RenameFile(Backup, Full);
AError := 'Не удалось записать проект: ' + Full;
Exit;
end;
FModified := False;
Result := True;
except
on E: Exception do
AError := 'Не удалось сохранить проект: ' + E.Message;
end;
end;
function TProject.LoadFromFile(const APath: string; out AError: string): Boolean;
var
Text: TStringList;

View file

@ -70,7 +70,9 @@ type
tiRipple, // схлопывание: дыра между кусками закрывается
tiLog, // журнал: полоса с сообщениями
tiGlue, // курсор липнет к границам клипов
tiTarget // курсор держится середины монтажа
tiTarget, // курсор держится середины монтажа
tiSelectClips, // выделение рамкой: что задело, то и выделено
tiSelectRange // выделение по времени: от и до, по всем дорожкам
);
{ Полоска выполнения, которая рисует себя сама.
@ -91,6 +93,13 @@ type
public
constructor Create(AOwner: TComponent); override;
property Fraction: Double read FFraction write SetFraction;
published
{ Опубликовано ради файлов форм: читатель формы берёт только
опубликованные свойства, и без этого «Anchors» в .lfm роняет окно
при открытии — не при сборке. }
property Anchors;
property Align;
property Visible;
end;
{ Кнопка, которая рисует себя сама.
@ -130,6 +139,9 @@ type
X, Y: Integer); override;
procedure KeyDown(var Key: Word; Shift: TShiftState); override;
procedure KeyUp(var Key: Word; Shift: TShiftState); override;
{ Если двойной щелчок всё же дойдёт (виджетсет может послать его и
без csDoubleClicks), считаем его обычным нажатием. }
procedure DblClick; override;
procedure TextChanged; override;
class function GetControlClassDefaultSize: TSize; override;
public
@ -161,6 +173,10 @@ type
property Flat: Boolean read FFlat write SetFlat default False;
property Font;
property Hint;
{ Наведение и уход: главному окну они нужны, чтобы показывать
подсказку строкой состояния, а не всплывающим окном поверх кнопки. }
property OnMouseEnter;
property OnMouseLeave;
property ModalResult: TModalResult read FModalResult write FModalResult
default mrNone;
property ParentFont;
@ -390,6 +406,11 @@ begin
{ csHasDefaultAction говорит форме, что нашу кнопку осмысленно
вызывать по Enter; без этого Enter до неё не дойдёт. }
ControlStyle := ControlStyle + [csHasDefaultAction, csHasCancelAction];
{ Двойной щелчок кнопке не нужен, а вреден. Второе нажатие подряд
приходит как двойное и обычным щелчком не считается — нажатие просто
пропадало. Со стороны это выглядело как «нажимаю быстро — не
срабатывает, нажимаю медленно — срабатывает». }
ControlStyle := ControlStyle - [csDoubleClicks];
TabStop := True;
DoubleBuffered := True;
{ Без явного значения образец был бы чёрным (ноль — это clBlack), и
@ -460,6 +481,10 @@ begin
tiLog: Stream.WriteBuffer(IconLogData[0], Length(IconLogData));
tiGlue: Stream.WriteBuffer(IconGlueData[0], Length(IconGlueData));
tiTarget: Stream.WriteBuffer(IconTargetData[0], Length(IconTargetData));
tiSelectClips: Stream.WriteBuffer(IconSelectClipsData[0],
Length(IconSelectClipsData));
tiSelectRange: Stream.WriteBuffer(IconSelectRangeData[0],
Length(IconSelectRangeData));
else
Exit;
end;
@ -727,6 +752,11 @@ begin
с клавиатуры давало одну. }
end;
procedure TThemedButton.DblClick;
begin
Click;
end;
procedure TThemedButton.KeyDown(var Key: Word; Shift: TShiftState);
begin
inherited KeyDown(Key, Shift);

View file

@ -41,9 +41,20 @@ type
taFadeOut, // тянем уход за верхний правый угол
taRegionIn, // тянем начало отмеченного участка
taRegionOut, // тянем конец отмеченного участка
taDragTrack // тянем дорожку за заголовок, меняя порядок
taDragTrack, // тянем дорожку за заголовок, меняя порядок
taRubberBand // обтягиваем рамкой клипы по пустому месту
);
{ Как понимать протяжку мышью по пустому месту.
smClips — что рамка задела, то и выделено: привычное выделение
прямоугольником.
smRange — отмечается промежуток времени по всем дорожкам сразу, от и
до. Нужен, чтобы вырезать кусок фильма целиком: отметил промежуток,
нажал S — и все дорожки разрезаны в двух местах, по краям промежутка. }
TSelectionMode = (smClips, smRange);
{ Что находится под указателем в полосе заголовков дорожек. }
THeaderPart = (hpNone, hpBody, hpMute, hpSolo, hpUp, hpDown, hpName);
@ -126,6 +137,26 @@ type
клипы, которые уже сдвинулись. }
FRippleClips: array of TClip;
FRippleOrigStarts: array of Double;
{ Имя дорожки, где схлопнули дыру в последний раз. }
FLastGapTrack: string;
{ Рамка выделения: угол, где нажали, и угол, где мышь сейчас.
По горизонтали храним время, а не точку экрана: рамку можно тянуть
за край окна, монтаж при этом прокручивается, и запомненная точка
экрана означала бы уже другое место. По вертикали храним отступ от
начала дорожек, а не от верха окна, — по той же причине. }
FBandFromTime: Double;
FBandToTime: Double;
FBandFromY: Integer;
FBandToY: Integer;
{ Режим выделения и отмеченный им промежуток времени.
Промежуток живёт до следующего выделения или щелчка: по нему режет
клавиша S, и человек должен видеть, где именно это произойдёт. }
FSelectionMode: TSelectionMode;
FRangeFrom: Double;
FRangeTo: Double;
FOnSelectionModeChange: TNotifyEvent;
FGroupClips: array of TClip;
FGroupOrigStarts: array of Double;
@ -180,11 +211,20 @@ type
procedure DrawFades(ACanvas: TCanvas; AClip: TClip; const ABody: TRect);
{ Ручка затухания. AIn = True — появление (левый угол), иначе уход. }
function FadeHandleRect(AClip: TClip; ATop: Integer; AIn: Boolean): TRect;
{ Зона, в которой ручку затухания можно взять. Она шире нарисованной
ручки: пока затухание нулевое, ручка стоит в углу клипа и целиться
есть куда, а после протяжки она висит посреди клипа сама по себе, и
попасть в девять точек почти нельзя. }
function FadeGrabRect(AClip: TClip; ATop: Integer; AIn: Boolean): TRect;
{ Какую из двух ручек затухания берут в этой точке. Пусто — ни одну. }
function FadeAtPoint(AClip: TClip; const AX, AY, ATop: Integer;
out AIn: Boolean): Boolean;
{ Полоса перекрытия двух клипов одной дорожки: там сам собой выходит
переход, и это надо видеть. }
procedure DrawOverlaps(ACanvas: TCanvas; ATrack: TTrack; ATop: Integer);
{ Полоса поверх участка, который прямо сейчас пересобирается. }
procedure DrawBusyRange(ACanvas: TCanvas);
procedure DrawRubberBand(ACanvas: TCanvas);
{ Сдвиг всего, что правее дыры, влево до первого касания. }
procedure RippleAfterDelete(const AHoleEnd: Double;
const ATracks: TStringList);
@ -224,6 +264,22 @@ type
const AFromTime: Double);
procedure ApplyRipple(const ADelta: Double);
function IsRippling(AClip: TClip): Boolean;
{ Ближайшая к ATime дыра на дорожке: конец предыдущего клипа и начало
следующего. Дыра считается найденной, если ATime отстоит от неё не
дальше чем на ATolerance; внутри дыры расстояние нулевое.
Допуск нужен потому, что курсор прилипает к краям клипов по всем
дорожкам сразу, а края картинки и её звука совпадают не всегда:
курсор, поставленный в дыру, может оказаться на десяток миллисекунд
левее её начала. Требовать точного попадания — значит заставлять
человека целиться. }
function NearestGapOnTrack(ATrackIndex: Integer;
const ATime, ATolerance: Double; out AFrom, ATo: Double): Boolean;
{ Допуск прилипания в секундах: те же экранные точки, что у краёв. }
function SnapTolerance: Double;
procedure SetSelectionMode(AValue: TSelectionMode);
{ Рез в указанном месте. Возвращает, сколько клипов разрезано. }
function SplitAt(const ATime: Double; APrecise: Boolean): Integer;
protected
procedure Paint; override;
procedure DragOver(Source: TObject; X, Y: Integer; State: TDragState;
@ -262,13 +318,20 @@ type
function TrackIndexAtPoint(const AY: Integer): Integer;
function TimeAtPoint(const AX: Integer): Double;
{ Разрезать клипы под курсором воспроизведения.
{ Разрезать клипы под курсором воспроизведения — на всех дорожках
сразу. Возвращает, сколько клипов разрезано: ноль тоже ответ, и
окно должен о нём сказать, иначе клавиша выглядит сломанной.
Раньше резалась только дорожка под указателем мыши, и стоило
указателю оказаться над пустым местом, как рез молча не делал
ничего. Дорожка под мышью и дорожка, с которой работают, — разные
вещи, и связывать их не следует.
APrecise помечает новые края как требующие точного реза: при сборке
огрызок до опорного кадра будет перекодирован. Без этого рез
притянется к опорному кадру, и точка съедет на величину до
расстояния между опорными кадрами. }
procedure SplitAtPlayhead(APrecise: Boolean; AOnlyCurrentTrack: Boolean);
function SplitAtPlayhead(APrecise: Boolean): Integer;
procedure DeleteSelected;
{ Связать выделенные клипы или развязать, если они уже связаны.
@ -286,6 +349,33 @@ type
окно скажет об этом, чтобы команда не выглядела сломанной. }
function RippleCloseGap: Boolean;
procedure SelectAll;
{ Выделить клипы, попавшие в область: дорожки с ATrackFrom по ATrackTo,
время с AFrom по ATo.
Попавшим считается клип, который областью задет, а не целиком в ней
лежит: обтянуть длинный клип целиком на общем плане невозможно, а
убрать его хотят так же, как короткий.
AAdd — добавить к уже выделенному (Ctrl), иначе прежнее выделение
заменяется. Открыто наружу ещё и затем, чтобы проверять числами. }
procedure SelectInRange(const AFrom, ATo: Double;
ATrackFrom, ATrackTo: Integer; AAdd: Boolean);
{ Режим выделения: рамкой по клипам или промежутком времени по всем
дорожкам. Переключается клавишей D и кнопкой внизу. }
property SelectionMode: TSelectionMode read FSelectionMode
write SetSelectionMode;
procedure ToggleSelectionMode;
{ Отмечен ли промежуток времени и какой. }
function HasSelectionRange: Boolean;
property RangeFrom: Double read FRangeFrom;
property RangeTo: Double read FRangeTo;
{ Отметить промежуток. Мышь зовёт это же, и проверки тоже: так
поведение реза проверяется числами, без беготни указателем. }
procedure SetSelectionRange(const AFrom, ATo: Double);
procedure ClearSelectionRange;
property OnSelectionModeChange: TNotifyEvent read FOnSelectionModeChange
write FOnSelectionModeChange;
{ Номер дорожки под указателем мыши, минус один если ни одной. }
property HoverTrack: Integer read FHoverTrack;
@ -296,6 +386,10 @@ type
{ Куда встанет курсор, если поставить его в это время. Открыто, чтобы
расчёт можно было проверить числами, не гоняя мышь по окну. }
function SnapPlayhead(const ATime: Double): Double;
{ Имя дорожки, на которой схлопнули дыру в последний раз. Нужно окну
для строки состояния: дыра могла найтись не на той дорожке, где
стоял указатель, и молча двигать чужую дорожку нельзя. }
property LastGapTrack: string read FLastGapTrack;
{ Округление к кадровой сетке проекта: между кадрами времени нет. }
function QuantizeTime(const ATime: Double): Double;
@ -411,6 +505,7 @@ const
ColorFadeFill = TColor($505050); // серый: срезанный угол клипа
ColorOverlap = TColor($30C0FF); // оранжевый: полоса перехода
ColorBusy = TColor($30C0FF); // оранжевый: участок в работе
ColorBand = TColor($E0E0E0); // белый: рамка выделения
ColorHeaderActive = TColor($3A3A3A); // подсветка выбранной дорожки
ColorButtonOff = TColor($3A3A3A);
ColorMuteOn = TColor($4040D0); // красный: дорожка выключена
@ -705,6 +800,9 @@ begin
{ Отметка занятого участка идёт после клипов и до линейки: она должна
лежать поверх клипов, но не наползать на подписи времени. }
DrawBusyRange(FBuffer.Canvas);
{ Рамка выделения — поверх клипов, но под линейкой и заголовками: она
про клипы, и наползать на подписи времени ей незачем. }
DrawRubberBand(FBuffer.Canvas);
DrawRuler(FBuffer.Canvas);
DrawHeaders(FBuffer.Canvas);
DrawPlayhead(FBuffer.Canvas);
@ -1102,6 +1200,52 @@ begin
Exit(Rect(0, 0, 0, 0));
end;
function TTimelineControl.FadeGrabRect(AClip: TClip; ATop: Integer;
AIn: Boolean): TRect;
const
{ По сторонам — чтобы не целиться по горизонтали в девять точек. Вниз —
потому что ручка сидит на верхней грани клипа, и рука уводит курсор
внутрь клипа, а не выше него. }
PadX = 6;
PadDown = 7;
begin
Result := FadeHandleRect(AClip, ATop, AIn);
if Result.Right <= Result.Left then
Exit;
Dec(Result.Left, PadX);
Inc(Result.Right, PadX);
Inc(Result.Bottom, PadDown);
if Result.Left < FHeaderWidth then
Result.Left := FHeaderWidth;
end;
function TTimelineControl.FadeAtPoint(AClip: TClip; const AX, AY,
ATop: Integer; out AIn: Boolean): Boolean;
var
RIn, ROut: TRect;
HitIn, HitOut: Boolean;
begin
AIn := False;
RIn := FadeGrabRect(AClip, ATop, True);
ROut := FadeGrabRect(AClip, ATop, False);
HitIn := (RIn.Right > RIn.Left) and PtInRect(RIn, Point(AX, AY));
HitOut := (ROut.Right > ROut.Left) and PtInRect(ROut, Point(AX, AY));
{ Зоны расширены, и на коротком клипе они могут налезть друг на друга.
Тогда берём ту ручку, к которой ближе: другого разумного ответа нет. }
if HitIn and HitOut then
HitOut := Abs(AX - (ROut.Left + ROut.Right) div 2) <
Abs(AX - (RIn.Left + RIn.Right) div 2);
if HitOut then
begin
AIn := False;
Exit(True);
end;
Result := HitIn;
AIn := HitIn;
end;
procedure TTimelineControl.DrawFades(ACanvas: TCanvas; AClip: TClip;
const ABody: TRect);
var
@ -1323,6 +1467,65 @@ begin
Invalidate;
end;
{ Рамка выделения. Пунктиром и без заливки: клипы внутри и так
подсвечиваются выделением, а заливка поверх них мешала бы это увидеть. }
procedure TTimelineControl.DrawRubberBand(ACanvas: TCanvas);
var
L, R, T, B: Integer;
Pulling: Boolean;
begin
{ Имя не Dragging: так называется метод самого элемента управления. }
Pulling := (FAction = taRubberBand) and FMovedWhileDown;
{ Отмеченный промежуток виден и после протяжки: по нему режет клавиша S,
и человек должен знать, где это произойдёт. }
if (not Pulling) and HasSelectionRange then
begin
L := Max(TimeToX(FRangeFrom), FHeaderWidth);
R := Min(TimeToX(FRangeTo), ClientWidth);
if R <= L then
Exit;
ACanvas.Pen.Color := ColorBand;
ACanvas.Pen.Style := psDot;
ACanvas.Brush.Style := bsClear;
ACanvas.Rectangle(L, FRulerHeight, R, ClientHeight);
ACanvas.Pen.Style := psSolid;
Exit;
end;
if not Pulling then
Exit;
L := Min(TimeToX(FBandFromTime), TimeToX(FBandToTime));
R := Max(TimeToX(FBandFromTime), TimeToX(FBandToTime));
if FSelectionMode = smRange then
begin
{ По времени отмечают промежуток, а не прямоугольник: рамка идёт во
всю высоту дорожек, сколько бы их ни было. }
T := FRulerHeight;
B := ClientHeight;
end
else
begin
T := Min(FBandFromY, FBandToY) - FScrollY;
B := Max(FBandFromY, FBandToY) - FScrollY;
end;
L := Max(L, FHeaderWidth);
R := Min(R, ClientWidth);
T := Max(T, FRulerHeight);
B := Min(B, ClientHeight);
if (R <= L) or (B <= T) then
Exit;
ACanvas.Pen.Color := ColorBand;
ACanvas.Pen.Style := psDot;
ACanvas.Brush.Style := bsClear;
ACanvas.Rectangle(L, T, R, B);
ACanvas.Pen.Style := psSolid;
end;
{ Участок, который сейчас пересобирается.
Помечается косой штриховкой во всю высоту дорожек: сплошная заливка
@ -1820,6 +2023,54 @@ begin
Result := Best;
end;
function TTimelineControl.SnapTolerance: Double;
begin
if FPixelsPerSecond > 0 then
Result := SnapPixels / FPixelsPerSecond
else
Result := 0;
end;
function TTimelineControl.NearestGapOnTrack(ATrackIndex: Integer;
const ATime, ATolerance: Double; out AFrom, ATo: Double): Boolean;
var
Track: TTrack;
I: Integer;
PrevEnd, Dist, BestDist: Double;
begin
Result := False;
AFrom := 0;
ATo := 0;
if (FProject = nil) or (ATrackIndex < 0) or
(ATrackIndex >= FProject.TrackCount) then
Exit;
Track := FProject.Tracks[ATrackIndex];
BestDist := ATolerance;
PrevEnd := 0;
for I := 0 to Track.Count - 1 do
begin
if Track[I].TlStart > PrevEnd + 0.0005 then
begin
{ Расстояние до отрезка дыры: внутри неё — ноль. }
if ATime < PrevEnd then
Dist := PrevEnd - ATime
else if ATime > Track[I].TlStart then
Dist := ATime - Track[I].TlStart
else
Dist := 0;
if Dist <= BestDist then
begin
BestDist := Dist;
AFrom := PrevEnd;
ATo := Track[I].TlStart;
Result := True;
end;
end;
PrevEnd := Max(PrevEnd, Track[I].TlEnd);
end;
end;
{ --- Мышь --- }
procedure TTimelineControl.MouseDown(Button: TMouseButton; Shift: TShiftState;
@ -1831,7 +2082,7 @@ var
Part: THeaderPart;
VolHandle: TRect;
CropRect: TRect;
FadeHandle: TRect;
FadeIsIn: Boolean;
begin
inherited MouseDown(Button, Shift, X, Y);
SetFocus;
@ -1956,16 +2207,21 @@ begin
if Clip = nil then
begin
{ По пустому месту дорожки — тоже ставим курсор: так удобнее, чем
тянуться к линейке. }
if not (ssCtrl in Shift) then
begin
FProject.ClearSelection;
if Assigned(FOnSelectionChange) then
FOnSelectionChange(Self);
end;
FAction := taScrub;
Playhead := Max(0, SnapPlayhead(XToTime(X)));
{ По пустому месту дорожек — рамка выделения. Что с ней делать,
решается при отпускании: не сдвинули мышь — это щелчок, и он, как и
раньше, ставит курсор (тянуться к линейке каждый раз неудобно).
Сдвинули — выделяем всё, что рамка задела.
Выделение пока не трогаем: снимать его на нажатии значило бы терять
его от случайного щелчка мимо клипа. }
FAction := taRubberBand;
FPressX := X;
FPressY := Y;
FMovedWhileDown := False;
FBandFromTime := XToTime(X);
FBandToTime := FBandFromTime;
FBandFromY := Y + FScrollY;
FBandToY := FBandFromY;
Exit;
end;
@ -2002,23 +2258,12 @@ begin
{ Ручки затухания стоят в верхних углах, а там же начинается зона
растягивания за край. Приоритет у ручек: они мелкие, промахнуться
мимо них легче, а растянуть клип можно, взявшись за край ниже. }
FadeHandle := FadeHandleRect(Clip, TrackTop(TrackIndexAt(Y)), True);
if (FadeHandle.Right > FadeHandle.Left) and
PtInRect(FadeHandle, Point(X, Y)) then
if FadeAtPoint(Clip, X, Y, TrackTop(TrackIndexAt(Y)), FadeIsIn) then
begin
FAction := taFadeIn;
FProject.BeginChange;
FPressX := X;
FPressY := Y;
FMovedWhileDown := False;
Invalidate;
Exit;
end;
FadeHandle := FadeHandleRect(Clip, TrackTop(TrackIndexAt(Y)), False);
if (FadeHandle.Right > FadeHandle.Left) and
PtInRect(FadeHandle, Point(X, Y)) then
begin
FAction := taFadeOut;
if FadeIsIn then
FAction := taFadeIn
else
FAction := taFadeOut;
FProject.BeginChange;
FPressX := X;
FPressY := Y;
@ -2076,6 +2321,7 @@ var
Clip: TClip;
Track, TargetTrack: TTrack;
VolHandle: TRect;
FadeIsIn: Boolean;
begin
inherited MouseMove(Shift, X, Y);
VolHandle := Rect(0, 0, 0, 0);
@ -2105,6 +2351,12 @@ begin
(X >= VolHandle.Left) and (X <= VolHandle.Right) and
(Y >= VolHandle.Top - 3) and (Y <= VolHandle.Bottom + 3) then
Cursor := crSizeNS
{ Ручка затухания мелкая и после протяжки висит посреди клипа, где
её ничем не выдать. Пусть выдаёт курсор: он меняется над зоной
захвата, и становится видно, где взять. }
else if (Clip <> nil) and (Idx >= 0) and
FadeAtPoint(Clip, X, Y, TrackTop(Idx), FadeIsIn) then
Cursor := crSizeWE
else if Edge <> 0 then
Cursor := crSizeWE
else if Clip <> nil then
@ -2129,6 +2381,28 @@ begin
на стык, соскакивал бы с него от малейшего движения мыши. }
Playhead := Max(0, SnapPlayhead(XToTime(X)));
taRubberBand:
begin
if (Abs(X - FPressX) > ClickSlopPixels) or
(Abs(Y - FPressY) > ClickSlopPixels) then
FMovedWhileDown := True;
{ Тянут за край окна — прокручиваем следом: иначе рамкой нельзя
захватить больше, чем помещается на экране. Прокрутка идёт на
каждое движение мыши, само по себе время не течёт: пока мышь
стоит, ничего не происходит. }
if FPixelsPerSecond > 0 then
begin
if X < FHeaderWidth then
FScrollOffset := FScrollOffset - (FHeaderWidth - X) / FPixelsPerSecond
else if X > ClientWidth then
FScrollOffset := FScrollOffset + (X - ClientWidth) / FPixelsPerSecond;
ClampScroll;
end;
FBandToTime := XToTime(Max(FHeaderWidth, Min(ClientWidth, X)));
FBandToY := Max(FRulerHeight, Y) + FScrollY;
Invalidate;
end;
taDragTrack:
if (FProject <> nil) and (FDragTrackIndex >= 0) then
begin
@ -2456,6 +2730,45 @@ begin
Exit;
end;
{ Рамка выделения. Щелчок без движения — это не рамка, а щелчок по
пустому месту: снимаем выделение и ставим курсор, как было всегда. }
if FAction = taRubberBand then
begin
FAction := taNone;
if FMovedWhileDown then
begin
if FSelectionMode = smRange then
begin
{ Промежуток времени: по всем дорожкам, от и до. Края кладутся на
кадровую сетку — по ним потом режет клавиша S. }
SetSelectionRange(FBandFromTime, FBandToTime);
if (FProject <> nil) and (FRangeTo > FRangeFrom) then
SelectInRange(FRangeFrom, FRangeTo, 0, FProject.TrackCount - 1,
ssCtrl in Shift);
end
else
SelectInRange(FBandFromTime, FBandToTime,
(FBandFromY - FRulerHeight) div FTrackHeight,
(FBandToY - FRulerHeight) div FTrackHeight,
ssCtrl in Shift);
end
else
begin
if (FProject <> nil) and not (ssCtrl in Shift) then
begin
FProject.ClearSelection;
if Assigned(FOnSelectionChange) then
FOnSelectionChange(Self);
end;
{ Щелчок снимает и отметку промежутка: она про прошлое выделение. }
ClearSelectionRange;
Playhead := Max(0, SnapPlayhead(XToTime(X)));
end;
FMovedWhileDown := False;
Invalidate;
Exit;
end;
WasEditing := FAction in [taDragClip, taTrimIn, taTrimOut, taVolume,
taFadeIn, taFadeOut];
WasTrimIn := FAction = taTrimIn;
@ -2495,7 +2808,17 @@ function TTimelineControl.DoMouseWheel(Shift: TShiftState; WheelDelta: Integer;
var
Pt: TPoint;
begin
Pt := ScreenToClient(MousePos);
{ Точка берётся у самой мыши, а не из MousePos.
MousePos по описанию приходит в координатах экрана, но под gtk2 в нём
уже клиентские: ScreenToClient переводил их второй раз, и точка опоры
уезжала влево ровно на левый край таймлайна. Замерено на невидимом
экране: мышь в 700, 1000 и 1300 — опора в 260, 560 и 860, то есть
всегда на 440 левее, а 440 — это и есть левый край. При мыши слева
опора и вовсе уходила за пределы окна.
Mouse.CursorPos экранный в любой системе, и перевод остаётся один. }
Pt := ScreenToClient(Mouse.CursorPos);
if ssCtrl in Shift then
begin
@ -2539,51 +2862,43 @@ end;
{ --- Правки --- }
procedure TTimelineControl.SplitAtPlayhead(APrecise: Boolean;
AOnlyCurrentTrack: Boolean);
{ Рез в указанном месте.
Место кладётся на кадровую сетку: между кадрами времени нет, и рез на
доле кадра оставил бы в монтаже время, которого не бывает. Сдвиг не
больше полкадра. }
function TTimelineControl.SplitAt(const ATime: Double;
APrecise: Boolean): Integer;
var
T: Integer;
Track: TTrack;
Clip, Tail: TClip;
AnyChange: Boolean;
Offset: Double;
HoverGroup: string;
Offset, At: Double;
{ Старый номер связки — новый. Одна связка при резе становится двумя:
голова со своей парой и хвост со своей. }
NewGroups: TStringList;
Idx: Integer;
begin
Result := 0;
if FProject = nil then
Exit;
AnyChange := False;
At := QuantizeTime(ATime);
FProject.BeginChange;
NewGroups := TStringList.Create;
try
{ Связка режется целиком. Резать только картинку, оставив звук цельным,
значит развалить пару: дальше они поедут порознь. }
HoverGroup := '';
if AOnlyCurrentTrack and (FHoverTrack >= 0) and
(FHoverTrack < FProject.TrackCount) then
begin
Clip := FProject.Tracks[FHoverTrack].ClipAt(FPlayhead);
if Clip <> nil then
HoverGroup := Clip.GroupId;
end;
{ Режем все дорожки, где под курсором есть клип. Связка при этом
остаётся целой сама собой: картинка и её звук лежат под одним и тем
же временем, значит режутся оба. }
for T := 0 to FProject.TrackCount - 1 do
begin
Track := FProject.Tracks[T];
Clip := Track.ClipAt(FPlayhead);
Clip := Track.ClipAt(At);
if Clip = nil then
Continue;
if AOnlyCurrentTrack and (FHoverTrack >= 0) and (T <> FHoverTrack) then
if (HoverGroup = '') or (Clip.GroupId <> HoverGroup) then
Continue;
Offset := FPlayhead - Clip.TlStart;
Offset := At - Clip.TlStart;
{ Рез вплотную к краю ничего не даёт, только плодит клипы нулевой длины. }
if (Offset < 0.001) or (Offset > Clip.Duration - 0.001) then
Continue;
@ -2592,7 +2907,7 @@ begin
Tail.Id := Clip.Id + '_b';
Tail.MediaId := Clip.MediaId;
Tail.SrcIn := Clip.SrcIn + Offset;
Tail.TlStart := FPlayhead;
Tail.TlStart := At;
Tail.Duration := Clip.Duration - Offset;
Tail.EdgeOut := Clip.EdgeOut;
{ Хвост — тот же клип, только вторая его половина. Всё настроенное
@ -2639,20 +2954,104 @@ begin
Clip.Duration := Offset;
Track.Add(Tail);
AnyChange := True;
Inc(Result);
end;
finally
NewGroups.Free;
end;
if AnyChange then
if Result > 0 then
StructureChanged
else
{ Ничего не разрезали — снимок отмены не нужен, откатываем его. }
FProject.Undo;
end;
{ Режим выделения: рамкой по клипам или промежутком по времени.
Смена режима снимает отмеченный промежуток: он принадлежит режиму «по
времени», и оставлять его в другом режиме значило бы резать по невидимой
метке. }
procedure TTimelineControl.SetSelectionMode(AValue: TSelectionMode);
begin
if FSelectionMode = AValue then
Exit;
FSelectionMode := AValue;
ClearSelectionRange;
Invalidate;
if Assigned(FOnSelectionModeChange) then
FOnSelectionModeChange(Self);
end;
procedure TTimelineControl.ToggleSelectionMode;
begin
if FSelectionMode = smClips then
SelectionMode := smRange
else
SelectionMode := smClips;
end;
function TTimelineControl.HasSelectionRange: Boolean;
begin
Result := (FSelectionMode = smRange) and (FRangeTo - FRangeFrom > 0.0005);
end;
procedure TTimelineControl.SetSelectionRange(const AFrom, ATo: Double);
begin
FRangeFrom := Max(0, QuantizeTime(Min(AFrom, ATo)));
FRangeTo := Max(0, QuantizeTime(Max(AFrom, ATo)));
Invalidate;
end;
procedure TTimelineControl.ClearSelectionRange;
begin
if (FRangeFrom = 0) and (FRangeTo = 0) then
Exit;
FRangeFrom := 0;
FRangeTo := 0;
Invalidate;
end;
{ Рез по команде человека.
Обычно — по курсору. Если отмечен промежуток времени (режим выделения
«по времени»), режем по обоим его краям: это и есть «вырезать кусок
фильма» — два реза, между ними то, что уйдёт.
Курсор после реза встаёт ровно на место реза: дальше с этим местом и
работают — удаляют кусок, схлопывают дыру, ставят следующий рез. }
function TTimelineControl.SplitAtPlayhead(APrecise: Boolean): Integer;
var
At: Double;
begin
Result := 0;
if FProject = nil then
Exit;
if HasSelectionRange then
begin
{ Сперва дальний край: рез не двигает то, что левее, но порядок
всё равно понятнее от конца к началу. }
Result := SplitAt(FRangeTo, APrecise);
Result := Result + SplitAt(FRangeFrom, APrecise);
Playhead := Max(0, QuantizeTime(FRangeFrom));
{ После реза выделенной остаётся ровно середина — то, что между
краями. Иначе Delete убрал бы не её: выделение осталось бы на
клипах, какими они были до реза, а середина оказалась бы новой,
невыделенной половиной. }
if Result > 0 then
SelectInRange(FRangeFrom, FRangeTo, 0, FProject.TrackCount - 1, False);
Exit;
end;
At := QuantizeTime(FPlayhead);
Result := SplitAt(At, APrecise);
if Result > 0 then
Playhead := Max(0, At);
end;
{ Расширяет выделение на всю связку: клипы, связанные с уже выделенными,
тоже считаются выделенными. Иначе удаление или перенос разорвали бы
связку пополам. }
@ -2808,7 +3207,7 @@ var
T, C: Integer;
Track: TTrack;
AnyChange: Boolean;
HoleEnd: Double;
HoleEnd, HoleStart: Double;
Touched: TStringList;
begin
if FProject = nil then
@ -2820,6 +3219,7 @@ begin
AnyChange := False;
HoleEnd := 0;
HoleStart := -1;
FProject.BeginChange;
Touched := TStringList.Create;
try
@ -2831,6 +3231,9 @@ begin
begin
{ Правый край дыры: дальше него всё поедет влево. }
HoleEnd := Max(HoleEnd, Track[C].TlEnd);
{ Левый край — место будущего стыка: туда встанет курсор. }
if (HoleStart < 0) or (Track[C].TlStart < HoleStart) then
HoleStart := Track[C].TlStart;
if Touched.IndexOf(IntToStr(T)) < 0 then
Touched.Add(IntToStr(T));
Track.Remove(Track[C]);
@ -2848,7 +3251,17 @@ begin
end;
if AnyChange then
StructureChanged
begin
{ Курсор — на место, где был вырезанный кусок: при схлопывании там
теперь стык, без схлопывания — начало дыры. С этим местом и работают
дальше, и искать его мышью заново незачем. }
if HoleStart >= 0 then
Playhead := Max(0, HoleStart);
{ Отметка промежутка пережила бы удаление и резала бы потом по
пустому месту. }
ClearSelectionRange;
StructureChanged;
end
else
FProject.Undo;
end;
@ -2857,33 +3270,11 @@ function TTimelineControl.RippleCloseGap: Boolean;
var
T, C, Source: Integer;
Track: TTrack;
GapStart, GapEnd, Shift: Double;
GapStart, GapEnd, Shift, Tolerance: Double;
AnyChange: Boolean;
{ Находит дыру под курсором на дорожке: конец предыдущего клипа и
начало следующего. }
function FindGap(ATrack: TTrack; out AFrom, ATo: Double): Boolean;
var
I: Integer;
PrevEnd: Double;
begin
Result := False;
PrevEnd := 0;
for I := 0 to ATrack.Count - 1 do
begin
if ATrack[I].TlStart > PrevEnd + 0.0005 then
if (FPlayhead >= PrevEnd) and (FPlayhead <= ATrack[I].TlStart) then
begin
AFrom := PrevEnd;
ATo := ATrack[I].TlStart;
Exit(True);
end;
PrevEnd := Max(PrevEnd, ATrack[I].TlEnd);
end;
end;
begin
Result := False;
FLastGapTrack := '';
if FProject = nil then
Exit;
@ -2895,14 +3286,31 @@ begin
клавиатуре, — берём выбранную дорожку. Раньше в этом случае команда
молча не делала ничего, и выглядело это как «горячая клавиша не
работает». }
Tolerance := Max(SnapTolerance, 0.001);
Source := FHoverTrack;
if (Source < 0) or (Source >= FProject.TrackCount) then
Source := FActiveTrack;
if (Source < 0) or (Source >= FProject.TrackCount) then
Exit;
if not FindGap(FProject.Tracks[Source], GapStart, GapEnd) then
Exit;
if (Source < 0) or (Source >= FProject.TrackCount) or
(not NearestGapOnTrack(Source, FPlayhead, Tolerance, GapStart, GapEnd)) then
begin
{ На своей дорожке дыры нет — ищем на остальных сверху вниз. Указатель
мыши может стоять где угодно, а дыра под курсором обычно одна на весь
монтаж. Какую дорожку схлопнули, окно потом скажет: молча двигать не
ту дорожку, на которую смотрели, нельзя. }
Source := -1;
for T := 0 to FProject.TrackCount - 1 do
if NearestGapOnTrack(T, FPlayhead, Tolerance, GapStart, GapEnd) then
begin
Source := T;
Break;
end;
if Source < 0 then
Exit;
end;
FLastGapTrack := FProject.Tracks[Source].Name;
Shift := GapEnd - GapStart;
if Shift <= 0 then
@ -2934,6 +3342,9 @@ begin
if AnyChange then
begin
{ Курсор — на стык: дыры больше нет, а работать дальше будут именно с
этим местом. }
Playhead := Max(0, GapStart);
StructureChanged;
Result := True;
end
@ -2955,6 +3366,46 @@ begin
FOnSelectionChange(Self);
end;
procedure TTimelineControl.SelectInRange(const AFrom, ATo: Double;
ATrackFrom, ATrackTo: Integer; AAdd: Boolean);
var
T, C, T1, T2: Integer;
From_, To_, Eps: Double;
Clip: TClip;
begin
if FProject = nil then
Exit;
{ Рамку тянут в любую сторону — приводим её к возрастающему виду. }
From_ := Min(AFrom, ATo);
To_ := Max(AFrom, ATo);
{ Нужно настоящее перекрытие, а не касание краями. Сосед, кончающийся
ровно там, где рамка начинается, в неё не попал — но сложение долей
даёт разницу в последнем знаке, и «больше» срабатывает. Порог —
полкадра: перекрытия короче кадра не бывает. }
Eps := 0.0005;
if (FProject.Master.FpsNum > 0) and (FProject.Master.FpsDen > 0) then
Eps := FProject.Master.FpsDen / FProject.Master.FpsNum / 2;
T1 := Max(0, Min(ATrackFrom, ATrackTo));
T2 := Min(FProject.TrackCount - 1, Max(ATrackFrom, ATrackTo));
if not AAdd then
FProject.ClearSelection;
for T := T1 to T2 do
for C := 0 to FProject.Tracks[T].Count - 1 do
begin
Clip := FProject.Tracks[T][C];
if (Clip.TlEnd > From_ + Eps) and (Clip.TlStart < To_ - Eps) then
Clip.Selected := True;
end;
Invalidate;
if Assigned(FOnSelectionChange) then
FOnSelectionChange(Self);
end;
procedure TTimelineControl.KeyDown(var Key: Word; Shift: TShiftState);
begin
inherited KeyDown(Key, Shift);
@ -2996,6 +3447,7 @@ begin
SelectAll;
Key := 0;
end;
VK_HOME:
begin
{ К началу самого раннего клипа, а не просто в ноль: если монтаж

View file

@ -82,6 +82,27 @@ function OverlayFilter(const AT: TMediaTransform;
ASrcWidth, ASrcHeight, ADstWidth, ADstHeight: Integer;
out AX, AY, AWidth, AHeight: Integer): string;
{ Какая часть исходника видна в кадре проекта — прямоугольник в точках
самого исходника.
Та же настройка, записанная с другой стороны: вместо «как вписать» —
«что останется». Окно кадрирования рисует по ней рамку, а плеер отдаёт
её mpv как обрезку на выводе, и оба обязаны считать одинаково — иначе
окно обещает одно, а предпросмотр показывает другое.
False — прямоугольником не описать: при растяжении берётся весь кадр и
сжимается неравномерно. }
function TransformSourceRect(const AT: TMediaTransform;
ASrcWidth, ASrcHeight, ADstWidth, ADstHeight: Integer;
out ARect: TRect): Boolean;
{ Можно ли показать это преобразование обрезкой на выводе плеера.
Обрезка умеет только выбрать кусок кадра. Поворот у mpv задаётся
отдельным свойством и потому разрешён, а отражение — нет: свойства для
него нет, и такой клип по-прежнему показывается как есть. }
function TransformFitsOutputCrop(const AT: TMediaTransform): Boolean;
{ Фильтр цветового ключа: этот цвет становится прозрачным.
Один на всех: и сборка, и предпросмотр должны выбивать одинаково, иначе
@ -153,6 +174,56 @@ begin
(ASrcWidth = ADstWidth) and (ASrcHeight = ADstHeight);
end;
function TransformSourceRect(const AT: TMediaTransform;
ASrcWidth, ASrcHeight, ADstWidth, ADstHeight: Integer;
out ARect: TRect): Boolean;
var
KFit, KFill, K: Double;
SW, SH, CW, CH, CX, CY: Double;
begin
ARect := Rect(0, 0, ASrcWidth, ASrcHeight);
Result := False;
if (ASrcWidth <= 0) or (ASrcHeight <= 0) or
(ADstWidth <= 0) or (ADstHeight <= 0) then
Exit;
{ Растяжение рамкой не описывается: берётся весь кадр и жмётся
неравномерно. }
if AT.Fit = fmStretch then
Exit;
KFit := Min(ADstWidth / ASrcWidth, ADstHeight / ASrcHeight);
KFill := Max(ADstWidth / ASrcWidth, ADstHeight / ASrcHeight);
if AT.Fit = fmFill then
K := KFill
else
K := KFit;
if AT.Zoom > 0 then
K := K * AT.Zoom;
if K <= 0 then
Exit;
{ Кадр, растянутый до рабочего масштаба, и та его часть, что помещается
в кадр проекта. Смещение задаётся в долях кадра проекта и прижимается
к краям: выйти за пределы картинки нельзя. }
SW := ASrcWidth * K;
SH := ASrcHeight * K;
CW := Min(SW, ADstWidth);
CH := Min(SH, ADstHeight);
CX := (SW - CW) / 2 + AT.OffsetX * ADstWidth;
CY := (SH - CH) / 2 + AT.OffsetY * ADstHeight;
CX := Max(0, Min(SW - CW, CX));
CY := Max(0, Min(SH - CH, CY));
ARect := Rect(Round(CX / K), Round(CY / K),
Round((CX + CW) / K), Round((CY + CH) / K));
Result := True;
end;
function TransformFitsOutputCrop(const AT: TMediaTransform): Boolean;
begin
Result := (not AT.FlipH) and (not AT.FlipV) and (AT.Fit <> fmStretch);
end;
{ Дробное число через точку: у ffmpeg разделитель всегда точка, а в
русской локали Format вставил бы запятую и развалил бы всю цепочку. }
function FilterNum(const AValue: Double): string;

56
tests/buttonclicks.sh Executable file
View file

@ -0,0 +1,56 @@
#!/bin/sh
# Проверка того, что каждый быстрый щелчок по кнопке нижней полосы
# доходит до неё.
#
# Кнопки там — переключатели: щелчок меняет вид полосы. Значит, N щелчков
# подряд должны дать N переключений, и снимки с одинаковой чётностью
# числа щелчков обязаны совпасть, с разной — разойтись. Раньше второй
# щелчок подряд приходил как двойной и пропадал.
#
# Запуск (редактор уже должен идти на невидимом экране :99):
# tests/buttonclicks.sh <окно> <x> <y>
# где окно — из `DISPLAY=:99 xdotool search --name nlve`, а x и y —
# точка внутри кнопки.
set -e
cd "$(dirname "$0")/.."
WIN=$1
X=$2
Y=$3
DISP=${DISP:-:99}
SHOT=${SHOT:-test/кадры-кнопок}
rm -rf "$SHOT"; mkdir -p "$SHOT"
# Снимается низ окна: там и полоса сообщений, и сами кнопки.
H=$(DISPLAY=$DISP xwininfo -id "$WIN" | awk '/Height:/ {print $2}')
W=$(DISPLAY=$DISP xwininfo -id "$WIN" | awk '/Width:/ {print $2}')
CROP="${W}x200+0+$((H - 200))"
# Мышь уводится с кнопки перед снимком: под курсором подсказка идёт в
# строку состояния, и её текст сам по себе отличал бы снимки.
snap() {
DISPLAY=$DISP xdotool mousemove $((W / 2)) $((H / 3))
sleep 0.4
DISPLAY=$DISP import -window "$WIN" -crop "$CROP" +repage "$SHOT/$1.png"
}
same() { compare -metric AE "$SHOT/$1.png" "$SHOT/$2.png" null: 2>&1 | cut -d' ' -f1; }
snap base
TOTAL=0
for N in 1 2 3 4 5; do
I=0
while [ $I -lt $N ]; do
DISPLAY=$DISP xdotool mousemove "$X" "$Y" click 1
I=$((I + 1))
sleep 0.12
done
TOTAL=$((TOTAL + N))
sleep 1.2
snap "после$TOTAL"
echo "залпом $N, всего щелчков $TOTAL: отличий от исходной $(same base "после$TOTAL")"
done
# Полоса сообщений прирастает строками сама по себе, поэтому сверяемся не
# с исходной картинкой, а снимками между собой.
echo " 3 и 15 щелчков (оба нечётные, должны совпасть): $(same после3 после15)"
echo " 6 и 10 щелчков (оба чётные, должны совпасть): $(same после6 после10)"
echo " 6 и 15 щелчков (чётное и нечётное, должны разойтись): $(same после6 после15)"

27
tests/clicktimes.py Normal file
View file

@ -0,0 +1,27 @@
#!/usr/bin/env python3
"""Времена щелчков в звуковой дорожке — по самим отсчётам.
Читает сырой звук (16 бит, моно, 48000) со стандартного ввода и печатает
время начала каждого громкого куска. Отсчёты, а не окна анализа: любой
оконный способ уплывает по времени, потому что окно двигается кратно
своему размеру, а не секунде, и на замере синхронности это выглядит как
накапливающийся рассинхрон, которого нет.
"""
import sys
import struct
RATE = 48000
THRESHOLD = 3000 # из 32767: щелчок заметно громче тишины
GAP = int(RATE * 0.5) # полсекунды тишины разделяет соседние щелчки
data = sys.stdin.buffer.read()
count = len(data) // 2
samples = struct.unpack('<%dh' % count, data[:count * 2])
last_loud = -GAP * 2
for i, value in enumerate(samples):
if abs(value) < THRESHOLD:
continue
if i - last_loud > GAP:
print('%.6f' % (i / RATE))
last_loud = i

67
tests/fadegrab.sh Executable file
View file

@ -0,0 +1,67 @@
#!/bin/sh
# Повторный захват рампы затухания.
#
# Первый раз ручка стоит в правом верхнем углу клипа — целиться есть куда.
# Второй раз она висит посреди клипа сама по себе, и целятся в неё на
# глаз: чуть ниже и чуть в стороне от середины. Оба захвата обязаны
# сработать.
# Запуск (редактор должен собираться в этом дереве, экран :99 поднят):
# tests/fadegrab.sh [<путь к nlve>]
BIN=${1:-$PWD/nlve}
cd "$(dirname "$0")/.."
BIN=${1:-$PWD/nlve}
PROJ=$PWD/test/поток/рампа.nlve
# Монтаж для проверки: два клипа без затуханий. В репозиторий не входит —
# ссылается на съёмку, которой здесь нет.
[ -f test/поток/пара.nlve ] || { echo "нет монтажа test/поток/пара.nlve"; exit 2; }
cp test/поток/пара.nlve "$PROJ"
for p in $(pgrep -x nlve); do
tr '\0' '\n' < /proc/$p/environ 2>/dev/null | grep -qx "DISPLAY=:99" && kill $p
done
sleep 1
rm -rf test/поток/профиль-рампа; mkdir -p test/поток/профиль-рампа
rm -f test/поток/*.autosave
SP=$PWD/test/поток/профиль-рампа
XDG_CONFIG_HOME=$SP XDG_CACHE_HOME=$SP XDG_DATA_HOME=$SP DISPLAY=:99 \
setsid "$BIN" "$PROJ" >/dev/null 2>&1 < /dev/null &
sleep 14
X="timeout 8 env DISPLAY=:99 xdotool"
$X windowmove 2097206 0 0 windowsize 2097206 1500 860 2>/dev/null
sleep 2
fade() {
$X key ctrl+s
sleep 2
python3 -c "
import json
d=json.load(open('$PROJ'))
for t in d['tracks']:
for c in t['clips']:
if c['kind']=='video' and c['tl_start']<0.001:
print('%.3f' % c.get('fade_out',0)); raise SystemExit
print('нет клипа')
"
}
echo " затухание до правок: $(fade) с"
$X mousemove 783 490 mousedown 1; sleep 0.3
$X mousemove 745 490; sleep 0.2; $X mousemove 720 490; sleep 0.2
$X mouseup 1; sleep 1.5
A=$(fade)
echo " после первого захвата (ручка в углу): $A с"
$X mousemove 724 495 mousedown 1; sleep 0.3
$X mousemove 700 495; sleep 0.2; $X mousemove 680 495; sleep 0.2
$X mouseup 1; sleep 1.5
B=$(fade)
echo " после второго захвата (ручка посреди клипа, мимо центра): $B с"
python3 -c "
a, b = $A, $B
if a <= 0.01:
print(' ПЛОХО: не сработал даже первый захват')
elif b <= a + 0.01:
print(' ПЛОХО: второй захват не сработал')
else:
print(' ok: обе протяжки сработали')
"

130
tests/genrestart.sh Executable file
View file

@ -0,0 +1,130 @@
#!/bin/sh
# Сборка сведённого куска не должна начинаться заново от каждой правки.
#
# Затухание, наплыв, прозрачность — всё это предпросмотр не умеет играть
# как есть: такой кусок заранее сводится в отдельный файл. На затухании в
# семьдесят секунд файл считается около полутора минут, и всё это время
# человек продолжает работать.
#
# Проверяется два обратных случая:
# 1. Режем монтаж в стороне от затухания. Сам кусок от этого не
# меняется — значит сборка обязана продолжаться, а не начинаться
# заново.
# 2. Удаляем всё. Кусок стал не нужен — сборка обязана прекратиться.
#
# Раньше первый случай отменял работу: «нужен ли ещё этот кусок»
# спрашивалось по списку недостающего, а у куска, который делается прямо
# сейчас, файл уже есть — ffmpeg создаёт его сразу. Кусок в список не
# попадал, работа отменялась в разгаре и начиналась с нуля.
#
# Запуск: tests/genrestart.sh [<путь к nlve>]
# Проект берётся из test/поток/длинный-фейд.nlve (см. README, раздел
# проверок): один клип с затуханием в семьдесят секунд.
set -e
cd "$(dirname "$0")/.."
BIN=${1:-$PWD/nlve}
DISP=${NLVE_DISPLAY:-:99}
# Проект для проверки в репозиторий не входит: он ссылается на съёмку,
# которой в нём нет. Годится любой с одним клипом и долгим затуханием —
# путь можно задать переменной.
PROJ=${NLVE_FADE_PROJECT:-$PWD/test/поток/длинный-фейд.nlve}
LOG=$(dirname "$BIN")/nlve.log
[ -f "$PROJ" ] || { echo "нет проекта для проверки: $PROJ"; exit 2; }
start() {
pkill -x nlve 2>/dev/null || true
# Закрытая программа не уносит с собой свой ffmpeg: дочерний процесс
# остаётся и продолжает писать в кэш. Убираем только его — по пути
# этого самого проекта, чтобы не задеть чужую работу.
pkill -f "длинный-фейд.nlve-cache" 2>/dev/null || true
sleep 1
rm -rf "$PWD/test/поток/длинный-фейд.nlve-cache" "$PWD/test/поток/профиль-$1"
rm -f "$PROJ.autosave"
SP=$PWD/test/поток/профиль-$1
mkdir -p "$SP"
: > "$LOG"
XDG_CONFIG_HOME=$SP XDG_CACHE_HOME=$SP XDG_DATA_HOME=$SP DISPLAY=$DISP \
"$BIN" "$PROJ" >/dev/null 2>&1 &
sleep 16
WIN=$(DISPLAY=$DISP xdotool search --name "длинный-фейд" | tail -1)
DISPLAY=$DISP xdotool windowmove "$WIN" 0 0 windowsize "$WIN" 1500 860
sleep 2
}
runs() { grep -c filter_complex "$LOG" || true; }
stops() { grep -c 'Расстановка изменилась' "$LOG" || true; }
BAD=0
echo "1. Правки в стороне от затухания"
start правки
echo " сборка началась: запусков $(runs)"
X=650
I=0
while [ $I -lt 5 ]; do
DISPLAY=$DISP xdotool mousemove $X 640 click 1
sleep 0.6
DISPLAY=$DISP xdotool key s
sleep 3
X=$((X + 40))
I=$((I + 1))
done
sleep 5
N=$(runs)
S=$(stops)
echo " после пяти резов: запусков $N, отмен $S"
case "$(DISPLAY=$DISP xdotool getwindowname "$WIN")" in
*\*) ;;
*) echo " ПЛОХО: монтаж не изменился, правки не дошли — проверка ничего не значит"
BAD=$((BAD + 1)) ;;
esac
if [ "$N" = "1" ] && [ "$S" = "0" ]; then
echo " ok: работа не сбрасывалась"
else
echo " ПЛОХО: сборка начиналась заново"
BAD=$((BAD + 1))
fi
echo
echo "2. Кусок стал не нужен"
start отмена
echo " сборка началась: запусков $(runs)"
DISPLAY=$DISP xdotool mousemove 700 600 click 1
sleep 0.5
DISPLAY=$DISP xdotool key ctrl+a
sleep 0.8
DISPLAY=$DISP xdotool key Delete
# Ждём, пока работа прекратится, а не спим наугад: ffmpeg получает сигнал
# и ещё дописывает то, что у него в руках. Именно -x у pgrep: иначе под
# раздачу попадёт и сама эта оболочка, у которой слово ffmpeg есть в
# командной строке.
ALIVE=1
WAITED=0
while [ "$WAITED" -lt 30 ]; do
sleep 1
WAITED=$((WAITED + 1))
ALIVE=$(pgrep -a -x ffmpeg | grep -c "длинный-фейд.nlve-cache" || true)
[ "$ALIVE" = "0" ] && break
done
S=$(stops)
echo " отмен $S, живых ffmpeg $ALIVE, ждали $WAITED с"
if [ "$S" -ge 1 ] && [ "$ALIVE" = "0" ]; then
echo " ok: ненужная работа прекращена"
else
echo " ПЛОХО: работа продолжается впустую"
BAD=$((BAD + 1))
fi
pkill -x nlve 2>/dev/null || true
pkill -f "длинный-фейд.nlve-cache" 2>/dev/null || true
rm -f "$PROJ.autosave"
echo
if [ "$BAD" = "0" ]; then
echo "ВЕРДИКТ: сборка вставок не сбрасывается зря и не идёт впустую"
else
echo "ВЕРДИКТ: не пройдено случаев: $BAD"
exit 1
fi

View file

@ -14,7 +14,8 @@ program lfmcheck;
uses
{ Interfaces нужен, чтобы связалась вся LCL и заработала загрузка
файлов форм. }
Interfaces, Forms, Classes, SysUtils, Math, TypInfo, uTheme,
Interfaces, Forms, Controls, Classes, SysUtils, Math, TypInfo, uTheme,
uFfmpegCmd, uAppTypes,
uMediaProbe, uProject, uTransform, uTimelineCtrl,
uPanCropForm, uDepsForm, uMismatchForm, uProgressForm, uProjectInfoForm,
uRemuxForm;
@ -209,7 +210,7 @@ begin
Timeline.Project := Proj;
Timeline.PixelsPerSecond := 100;
Timeline.Playhead := 4;
Timeline.SplitAtPlayhead(False, False);
Timeline.SplitAtPlayhead(False);
CheckTrue('клипов стало два', Proj.Tracks[0].Count = 2);
if Proj.Tracks[0].Count <> 2 then
@ -374,6 +375,592 @@ begin
end;
end;
{ Общая заготовка: проект с одним файлом и заданной сеткой кадров. }
function TestInfo: TMediaInfo;
begin
Result := Default(TMediaInfo);
Result.Valid := True;
Result.Kind := mkVideo;
Result.Duration := 600;
Result.HasVideo := True;
Result.VideoCodec := 'hevc';
Result.Width := 1920;
Result.Height := 1080;
Result.FpsNum := 30;
Result.FpsDen := 1;
Result.PixFmt := 'yuv420p';
end;
{ Выделение областью.
Проверяется расчётом, а не мышью: сама протяжка проверена на невидимом
экране, а вот что попадает в рамку — числами, иначе первая же правка
захвата пройдёт незамеченной. }
procedure CheckRubberBand;
var
Proj: TProject;
Info: TMediaInfo;
Media: TMediaItem;
Timeline: TTimelineControl;
begin
WriteLn;
WriteLn('Выделение областью');
WriteLn;
Info := TestInfo;
Proj := TProject.Create;
Timeline := TTimelineControl.Create(nil);
try
Proj.Clear;
Proj.AddTrack('V1');
Proj.AddTrack('V2');
Proj.AddTrack('V3');
Media := Proj.AddMedia('/несуществующий/видео.mp4', Info);
Proj.Master := Info;
{ V1: клип 0..5 и клип 20..25. V2: длинный клип 0..30.
V3: клип 0..5 — дорожка вне рамки. }
Proj.AddClip(Proj.Tracks[0], Media, 0, 5, 0);
Proj.AddClip(Proj.Tracks[0], Media, 100, 5, 20);
Proj.AddClip(Proj.Tracks[1], Media, 0, 30, 0);
Proj.AddClip(Proj.Tracks[2], Media, 0, 5, 0);
Timeline.Project := Proj;
Timeline.PixelsPerSecond := 100;
{ Рамка по дорожкам 0 и 1, время 2..10. }
Timeline.SelectInRange(2, 10, 0, 1, False);
CheckTrue('задетый клип выделен', Proj.Tracks[0][0].Selected);
CheckTrue('клип за рамкой не выделен', not Proj.Tracks[0][1].Selected);
CheckTrue('длинный клип задет и выделен', Proj.Tracks[1][0].Selected);
CheckTrue('чужая дорожка не тронута', not Proj.Tracks[2][0].Selected);
{ Рамку тянут и справа налево — это та же рамка. }
Timeline.SelectInRange(25, 18, 0, 0, False);
CheckTrue('обратная протяжка выделила второй клип',
Proj.Tracks[0][1].Selected);
CheckTrue('прежнее выделение снято', not Proj.Tracks[0][0].Selected);
{ С Ctrl прежнее выделение остаётся. }
Timeline.SelectInRange(2, 4, 0, 0, True);
CheckTrue('добавление сохранило прежнее', Proj.Tracks[0][1].Selected);
CheckTrue('добавление выделило новое', Proj.Tracks[0][0].Selected);
{ Рамка, не задевшая ничего, просто снимает выделение. }
Timeline.SelectInRange(6, 8, 0, 0, False);
CheckTrue('пустая рамка ничего не выделила',
Proj.SelectedCount = 0);
finally
Timeline.Free;
Proj.Free;
end;
end;
{ Рез по курсору режет все дорожки.
Раньше резалась только дорожка под указателем мыши, и стоило указателю
оказаться над пустым местом, как клавиша S молча не делала ничего. }
procedure CheckSplitAllTracks;
var
Proj: TProject;
Info: TMediaInfo;
Media: TMediaItem;
Timeline: TTimelineControl;
begin
WriteLn;
WriteLn('Рез по курсору на всех дорожках');
WriteLn;
Info := TestInfo;
Proj := TProject.Create;
Timeline := TTimelineControl.Create(nil);
try
Proj.Clear;
Proj.AddTrack('V1');
Proj.AddTrack('V2');
Proj.AddTrack('A1');
Media := Proj.AddMedia('/несуществующий/видео.mp4', Info);
Proj.Master := Info;
{ Клипы 0..10 на первой и третьей дорожках, на второй — 20..30:
под курсором её нет, и резать там нечего. }
Proj.AddClip(Proj.Tracks[0], Media, 0, 10, 0);
Proj.AddClip(Proj.Tracks[1], Media, 0, 10, 20);
Proj.AddClip(Proj.Tracks[2], Media, 0, 10, 0);
Timeline.Project := Proj;
Timeline.PixelsPerSecond := 100;
Timeline.ActiveTrack := 1;
Timeline.Playhead := 4;
CheckTrue('разрезаны обе задетые дорожки',
Timeline.SplitAtPlayhead(False) = 2);
CheckTrue('на первой дорожке два клипа', Proj.Tracks[0].Count = 2);
CheckTrue('на звуковой дорожке два клипа', Proj.Tracks[2].Count = 2);
CheckTrue('нетронутая дорожка осталась с одним клипом',
Proj.Tracks[1].Count = 1);
{ Курсор ровно на стыке — резать нечего, и клипов нулевой длины
появиться не должно. }
CheckTrue('на стыке рез отвечает нулём',
Timeline.SplitAtPlayhead(False) = 0);
CheckTrue('лишних клипов не появилось',
(Proj.Tracks[0].Count = 2) and (Proj.Tracks[2].Count = 2));
finally
Timeline.Free;
Proj.Free;
end;
end;
{ Схлопывание дыры не требует попадать в тайминг.
Курсор может стоять не точно на краю дыры: во время воспроизведения он
останавливается там, где успел, а после перезагрузки предпросмотра —
где оказался плеер. Раньше дыра при этом не находилась. }
procedure CheckGapTolerance;
var
Proj: TProject;
Info: TMediaInfo;
Media: TMediaItem;
Timeline: TTimelineControl;
begin
WriteLn;
WriteLn('Схлопывание дыры с допуском');
WriteLn;
Info := TestInfo;
Proj := TProject.Create;
Timeline := TTimelineControl.Create(nil);
try
Proj.Clear;
Proj.AddTrack('V1');
Proj.AddTrack('A1');
Media := Proj.AddMedia('/несуществующий/видео.mp4', Info);
Proj.Master := Info;
{ Дыра на картинке с 5 по 12. У звука дыры нет вовсе. }
Proj.AddClip(Proj.Tracks[0], Media, 0, 5, 0);
Proj.AddClip(Proj.Tracks[0], Media, 20, 5, 12);
Proj.AddClip(Proj.Tracks[1], Media, 0, 17, 0);
Timeline.Project := Proj;
Timeline.PixelsPerSecond := 100;
{ Указатель и выбранная дорожка — звуковая, дыры на ней нет. }
Timeline.ActiveTrack := 1;
{ Курсор не в дыре, а на четыре сотых раньше её начала. }
Timeline.Playhead := 4.96;
CheckTrue('дыра найдена, хотя курсор чуть раньше края',
Timeline.RippleCloseGap);
CheckTrue('названа дорожка, где схлопнули',
Timeline.LastGapTrack = 'V1');
CheckTrue('клип придвинут вплотную',
Abs(Proj.Tracks[0][1].TlStart - 5) < 0.001);
{ Курсор далеко от дыры — ничего не двигаем. }
Proj.Tracks[0][1].TlStart := 12;
Timeline.Playhead := 2;
CheckTrue('далеко от дыры — не схлопываем',
not Timeline.RippleCloseGap);
finally
Timeline.Free;
Proj.Free;
end;
end;
{ Починка проекта: клипы на кадровую сетку.
Между кадрами времени нет, и монтаж не должен хранить доли кадра:
картинка выйдет целым числом кадров, звук наберётся до отсчёта, и
округление разведёт их. Правка двигает клип не больше чем на полкадра,
но сама сетка обязана получиться ровной. }
procedure CheckFrameGridRepair;
var
Proj: TProject;
Info: TMediaInfo;
Media: TMediaItem;
Step: Double;
I: Integer;
Clip: TClip;
OnGrid: Boolean;
begin
WriteLn;
WriteLn('Починка: клипы на кадровую сетку');
WriteLn;
Info := TestInfo;
{ 59.94 к/с — та самая сетка, на которой доли кадра особенно заметны. }
Info.FpsNum := 60000;
Info.FpsDen := 1001;
Proj := TProject.Create;
try
Proj.Clear;
Proj.AddTrack('V1');
Media := Proj.AddMedia('/несуществующий/видео.mp4', Info);
Proj.Master := Info;
Step := Info.FpsDen / Info.FpsNum;
{ Времена как в настоящем проекте: ни одно не попадает на кадр. }
Proj.AddClip(Proj.Tracks[0], Media, 48.558521, 2.457599, 0);
Proj.AddClip(Proj.Tracks[0], Media, 56.054198, 1.469934, 2.457599);
{ И клип короче кадра: его нельзя схлопнуть в ничто. }
Proj.AddClip(Proj.Tracks[0], Media, 62.710196, 0.004, 3.927533);
CheckTrue('все три клипа мимо сетки', Proj.OffGridClipCount = 3);
CheckTrue('починка подвинула три клипа', Proj.SnapClipsToFrameGrid = 3);
CheckTrue('мимо сетки не осталось', Proj.OffGridClipCount = 0);
OnGrid := True;
for I := 0 to Proj.Tracks[0].Count - 1 do
begin
Clip := Proj.Tracks[0][I];
if Abs(Clip.TlStart / Step - Round(Clip.TlStart / Step)) > 0.001 then
OnGrid := False;
if Abs(Clip.Duration / Step - Round(Clip.Duration / Step)) > 0.001 then
OnGrid := False;
if Abs(Clip.SrcIn / Step - Round(Clip.SrcIn / Step)) > 0.001 then
OnGrid := False;
end;
CheckTrue('начала, длины и точки входа — целые кадры', OnGrid);
CheckTrue('короткий клип остался длиной в кадр',
Abs(Proj.Tracks[0][2].Duration - Step) < 0.0001);
CheckTrue('клипы не разъехались больше чем на полкадра',
Abs(Proj.Tracks[0][0].TlStart - 0) < Step);
CheckTrue('повторная починка ничего не двигает',
Proj.SnapClipsToFrameGrid = 0);
finally
Proj.Free;
end;
end;
{ Рез и курсор: где режет S и куда после этого встаёт курсор.
Раньше курсор оставался там, где был, а рез мог попасть на долю кадра.
Теперь рез кладётся на кадровую сетку, а курсор встаёт ровно на место
реза — с ним и работают дальше. }
procedure CheckSplitAndPlayhead;
var
Proj: TProject;
Info: TMediaInfo;
Media: TMediaItem;
Timeline: TTimelineControl;
Step: Double;
I, N: Integer;
begin
WriteLn;
WriteLn('Рез, промежуток и курсор');
WriteLn;
Info := TestInfo;
Info.FpsNum := 60000;
Info.FpsDen := 1001;
Step := Info.FpsDen / Info.FpsNum;
Proj := TProject.Create;
Timeline := TTimelineControl.Create(nil);
try
Proj.Clear;
Proj.AddTrack('V1');
Proj.AddTrack('A1');
Media := Proj.AddMedia('/несуществующий/видео.mp4', Info);
Proj.Master := Info;
{ По два клипа на каждой дорожке, встык: 0..10 и 10..20. }
Proj.AddClip(Proj.Tracks[0], Media, 0, 10, 0);
Proj.AddClip(Proj.Tracks[0], Media, 20, 10, 10);
Proj.AddClip(Proj.Tracks[1], Media, 0, 10, 0);
Proj.AddClip(Proj.Tracks[1], Media, 20, 10, 10);
Timeline.Project := Proj;
Timeline.PixelsPerSecond := 100;
{ Рез по курсору: место кладётся на кадр, курсор встаёт на него. }
Timeline.Playhead := 3.3177;
CheckTrue('разрезаны обе дорожки', Timeline.SplitAtPlayhead(False) = 2);
CheckTrue('курсор встал на место реза',
Abs(Timeline.Playhead - Round(3.3177 / Step) * Step) < 0.0001);
CheckTrue('курсор на кадровой сетке',
Abs(Timeline.Playhead / Step - Round(Timeline.Playhead / Step)) < 0.001);
N := 0;
for I := 0 to Proj.Tracks[0].Count - 1 do
if Abs(Proj.Tracks[0][I].TlStart - Timeline.Playhead) < 0.0001 then
Inc(N);
CheckTrue('рез пришёлся ровно туда, где курсор', N = 1);
{ Промежуток по времени: рез по обоим краям, курсор — на начало. }
Timeline.SelectionMode := smRange;
Timeline.SetSelectionRange(12.13, 16.77);
CheckTrue('промежуток отмечен', Timeline.HasSelectionRange);
CheckTrue('разрезано по два клипа на дорожке',
Timeline.SplitAtPlayhead(False) = 4);
CheckTrue('курсор — на начале промежутка',
Abs(Timeline.Playhead - Timeline.RangeFrom) < 0.0001);
N := 0;
for I := 0 to Proj.Tracks[1].Count - 1 do
if (Abs(Proj.Tracks[1][I].TlStart - Timeline.RangeFrom) < 0.0001) or
(Abs(Proj.Tracks[1][I].TlStart - Timeline.RangeTo) < 0.0001) then
Inc(N);
CheckTrue('на звуковой дорожке резы по обоим краям', N = 2);
{ После реза выделена середина — то, что между краями: следующим
действием её обычно удаляют. }
N := 0;
for I := 0 to Proj.Tracks[0].Count - 1 do
if Proj.Tracks[0][I].Selected then
begin
Inc(N);
CheckTrue('выделенное лежит внутри промежутка',
(Proj.Tracks[0][I].TlStart >= Timeline.RangeFrom - 0.0001) and
(Proj.Tracks[0][I].TlEnd <= Timeline.RangeTo + 0.0001));
end;
CheckTrue('на дорожке картинки выделена одна середина', N = 1);
{ Смена режима снимает промежуток: резать по невидимой метке нельзя. }
Timeline.SelectionMode := smClips;
CheckTrue('смена режима сняла промежуток',
not Timeline.HasSelectionRange);
{ Удаление со схлопыванием: курсор встаёт на место стыка. }
Proj.Clear;
Proj.AddTrack('V1');
Media := Proj.AddMedia('/несуществующий/видео.mp4', Info);
Proj.Master := Info;
Proj.RippleEnabled := True;
Proj.AddClip(Proj.Tracks[0], Media, 0, 5, 0);
Proj.AddClip(Proj.Tracks[0], Media, 10, 5, 5);
Proj.AddClip(Proj.Tracks[0], Media, 20, 5, 10);
Timeline.Playhead := 0;
Proj.Tracks[0][1].Selected := True;
Timeline.DeleteSelected;
CheckTrue('после удаления клипов стало два', Proj.Tracks[0].Count = 2);
CheckTrue('курсор — на месте вырезанного куска',
Abs(Timeline.Playhead - 5) < 0.0001);
CheckTrue('хвост придвинут к стыку',
Abs(Proj.Tracks[0][1].TlStart - 5) < 0.0001);
{ Схлопывание дыры с клавиатуры: курсор тоже на стык. }
Proj.Clear;
Proj.AddTrack('V1');
Media := Proj.AddMedia('/несуществующий/видео.mp4', Info);
Proj.Master := Info;
Proj.AddClip(Proj.Tracks[0], Media, 0, 5, 0);
Proj.AddClip(Proj.Tracks[0], Media, 20, 5, 12);
Timeline.ActiveTrack := 0;
Timeline.Playhead := 8;
CheckTrue('дыра схлопнута', Timeline.RippleCloseGap);
CheckTrue('курсор — на стыке', Abs(Timeline.Playhead - 5) < 0.0001);
finally
Timeline.Free;
Proj.Free;
end;
end;
{ Кнопка нижней панели и быстрые щелчки.
Второй щелчок подряд оконная библиотека отдаёт не как щелчок, а как
двойной — и обработчик кнопки его не видел. Человек нажимал кнопку
дважды, а срабатывало один раз.
Проверяется двумя способами: у кнопки нет csDoubleClicks (тогда
двойных щелчков к ней вообще не приходит), и даже если двойной
придёт, он считается за щелчок. }
type
TButtonProbe = class(TThemedButton)
public
procedure Twice;
end;
procedure TButtonProbe.Twice;
begin
DblClick;
end;
var
ClickCount: Integer = 0;
type
TClickCounter = class
procedure Clicked(Sender: TObject);
end;
procedure TClickCounter.Clicked(Sender: TObject);
begin
Inc(ClickCount);
end;
{ Рамка источника и фильтр сборки должны говорить об одном.
Одна и та же настройка записана двумя способами: сборка растягивает
кадр и вырезает из него кусок, а окно кадрирования и предпросмотр
говорят, какая часть исходника видна. Разойдутся — окно начнёт обещать
не то, что покажет плеер, а плеер не то, что даст сборка. }
procedure CheckSourceRect;
var
T: TMediaTransform;
R: TRect;
Chain: string;
begin
WriteLn;
WriteLn('Рамка источника и фильтр сборки');
WriteLn;
T := DefaultTransform;
T.Zoom := 2.77;
CheckTrue('увеличение описывается рамкой',
TransformSourceRect(T, 2688, 1512, 2688, 1512, R));
{ Растянули в 2.77 раза — значит видна 1/2.77 часть кадра, посередине. }
CheckTrue('ширина рамки: ' + IntToStr(R.Right - R.Left),
Abs((R.Right - R.Left) - Round(2688 / 2.77)) <= 2);
CheckTrue('высота рамки: ' + IntToStr(R.Bottom - R.Top),
Abs((R.Bottom - R.Top) - Round(1512 / 2.77)) <= 2);
CheckTrue('рамка посередине по горизонтали',
Abs((R.Left + R.Right) - 2688) <= 3);
CheckTrue('рамка посередине по вертикали',
Abs((R.Top + R.Bottom) - 1512) <= 3);
{ Тот же расчёт с другой стороны: сборка растягивает до 7446x4188 и
вырезает 2688x1512 с отступом 2379:1338. Делим отступ на масштаб —
обязано выйти начало рамки. }
Chain := TransformFilter(T, 2688, 1512, 2688, 1512);
CheckTrue('сборка растягивает и вырезает: ' + Chain,
(Pos('scale=7446:4188', Chain) > 0) and
(Pos('crop=2688:1512:2379:1338', Chain) > 0));
CheckTrue('начало рамки совпадает с отступом обрезки',
Abs(R.Left - Round(2379 / 2.77)) <= 2);
{ Растяжение рамкой не описывается: там кадр жмётся неравномерно. }
T := DefaultTransform;
T.Fit := fmStretch;
CheckTrue('растяжение рамкой не описывается',
not TransformSourceRect(T, 2688, 1512, 1920, 1080, R));
{ Отражение обрезкой на выводе не показать — свойства для него нет. }
T := DefaultTransform;
T.FlipH := True;
CheckTrue('отражение обрезкой на выводе не показывается',
not TransformFitsOutputCrop(T));
T := DefaultTransform;
T.Rotation := 90;
CheckTrue('поворот показать можно: у него своё свойство',
TransformFitsOutputCrop(T));
end;
{ Сведение слоёв просит распаковку у видеокарты.
На длинном затухании это разница между тремя минутами и одной, а
ключ входной: ffmpeg принимает его только перед тем входом, к которому
он относится. Забыть его легко и незаметно — команда останется рабочей,
просто медленной. }
{ Разбор строк о ходе работы ffmpeg.
Ход приходит блоками, и последняя строка блока — «progress=continue».
Места в ней нет: она сообщает только, что работа продолжается. Разбор
обязан оставить переданное число как было, а вызывающий — передавать
туда уже известное место, а не ноль. Иначе каждый блок заканчивается
обнулением доли, и показатель стоит на месте, дёргаясь вверх лишь
тогда, когда опрос попадёт между строкой со временем и этой. }
procedure CheckProgressParsing;
var
Seconds: Double;
AtEnd: Boolean;
begin
WriteLn;
WriteLn('Разбор хода работы ffmpeg');
WriteLn;
Seconds := 0;
AtEnd := False;
CheckTrue('время узнаётся', ParseProgressLine('out_time_us=2500000',
Seconds, AtEnd));
CheckTrue('и разбирается верно: ' + FloatToStrF(Seconds, ffFixed, 8, 3),
Abs(Seconds - 2.5) < 0.001);
CheckTrue('«продолжается» — тоже знакомая строка',
ParseProgressLine('progress=continue', Seconds, AtEnd));
CheckTrue('и она не трогает время: ' + FloatToStrF(Seconds, ffFixed, 8, 3),
Abs(Seconds - 2.5) < 0.001);
CheckTrue('и не объявляет конец', not AtEnd);
CheckTrue('конец узнаётся', ParseProgressLine('progress=end',
Seconds, AtEnd) and AtEnd);
CheckTrue('время и тут осталось прежним',
Abs(Seconds - 2.5) < 0.001);
CheckTrue('незнакомая строка не узнаётся',
not ParseProgressLine('bitrate= 12.3kbits/s', Seconds, AtEnd));
end;
procedure CheckLayerHwDecode;
var
Layers: array[0..0] of TSegmentLayer;
A: TArgs;
I, Hw, Input: Integer;
begin
WriteLn;
WriteLn('Распаковка слоёв видеокартой');
WriteLn;
Layers[0] := Default(TSegmentLayer);
Layers[0].FilePath := '/несуществующий/видео.mp4';
Layers[0].SrcStart := 10;
Layers[0].FadeFrom := 1;
Layers[0].FadeTo := 0;
A := ArgsRenderLayers(Layers, '/выход.mkv', 70, 2688, 1512, 60000, 1001,
'yuv420p', 'hevc', False, 0, '', NotPiped, False);
Hw := -1;
for I := 0 to High(A) do
if A[I] = '-hwaccel' then
Hw := I;
CheckTrue('без просьбы ключа нет', Hw < 0);
A := ArgsRenderLayers(Layers, '/выход.mkv', 70, 2688, 1512, 60000, 1001,
'yuv420p', 'hevc', False, 0, '', NotPiped, True);
Hw := -1;
Input := -1;
for I := 0 to High(A) do
begin
if A[I] = '-hwaccel' then
Hw := I;
if (A[I] = '-i') and (Input < 0) then
Input := I;
end;
{ Просьба выполняется только там, где кодировать будет та же карта. На
машине без неё проверять нечего, и это не поломка. }
if Pos('nvenc', PreferredEncoder('hevc')) = 0 then
WriteLn(' -- видеокарты для кодирования нет, проверять нечего')
else
begin
CheckTrue('по просьбе ключ появляется', Hw >= 0);
CheckTrue('и стоит перед входом, а не после', (Hw >= 0) and (Hw < Input));
end;
end;
procedure CheckButtonClicks;
var
Btn: TButtonProbe;
Counter: TClickCounter;
begin
WriteLn;
WriteLn('Кнопки нижней панели');
WriteLn;
Counter := TClickCounter.Create;
Btn := TButtonProbe.Create(nil);
try
Btn.OnClick := @Counter.Clicked;
CheckTrue('двойные щелчки кнопке не приходят',
not (csDoubleClicks in Btn.ControlStyle));
ClickCount := 0;
Btn.Twice;
CheckTrue('двойной щелчок считается за щелчок', ClickCount = 1);
finally
Btn.Free;
Counter.Free;
end;
end;
begin
WriteLn('Свойства из файлов форм');
WriteLn;
@ -404,6 +991,15 @@ begin
CheckSplitKeepsSettings;
CheckRippleWithoutMouse;
CheckClipCopy;
CheckRubberBand;
CheckFrameGridRepair;
CheckSplitAndPlayhead;
CheckSplitAllTracks;
CheckGapTolerance;
CheckSourceRect;
CheckProgressParsing;
CheckLayerHwDecode;
CheckButtonClicks;
WriteLn;
if Failed = 0 then

View file

@ -20,9 +20,13 @@
build <проект.nlve> <видео> [<картинка>]
собрать проект-образец: видео внизу, картинка
сверху посередине, дыра в начале
render <проект.nlve> <выход>
render <проект.nlve> <выход> [burn|keep|resume|cache]
[--cancel-after=<сек>]
уплотить проект, создать вставки, собрать ролик
и сверить число кадров
и сверить число кадров; cache — собрать
по-старому, через кэш кусков; --cancel-after
оборвёт сборку на полпути, а resume продолжит
её с недописанного файла
subs <проект.nlve>
собрать разметку субтитров из текстовых клипов
и напечатать её
@ -285,6 +289,7 @@ var
I, FirstArg: Integer;
Shift, CancelAfter, Waited: Double;
ExitCodeValue: Integer;
WantSum: Int64;
Cancelled: Boolean;
begin
{ Необязательный флаг остановки идёт первым, чтобы не мешать разбору
@ -379,6 +384,23 @@ begin
WriteLn(' сумма пакетов кусков: ', Rep.ExpectedPackets);
WriteLn(' пакетов в готовом файле: ', Rep.ActualPackets);
{ Сверка с монтажом: столько ли кадров, сколько он просил. Звук набран по
монтажу с точностью до отсчёта, поэтому лишний кадр картинки — это и
есть расхождение звука с картинкой. }
WantSum := 0;
for I := 0 to High(Rep.Segments) do
Inc(WantSum, Rep.Segments[I].WantFrames);
if WantSum > 0 then
begin
WriteLn(' картинка: ', Rep.ActualPackets, ' кадров, монтаж просил ', WantSum);
if Rep.ActualPackets = WantSum then
WriteLn(' ЗВУК И КАРТИНКА СХОДЯТСЯ')
else
WriteLn(' ЗВУК И КАРТИНКА РАСХОДЯТСЯ на ',
Rep.ActualPackets - WantSum, ' кадров');
end;
WriteLn(' результат: ', Rep.Message);
ExitCodeValue := 0;
@ -568,6 +590,7 @@ var
Key: TColorKey;
Tr: TMediaTransform;
I, N: Integer;
Step, At, Total: Double;
TempFile, Err: string;
begin
WriteLn('Проверка уплощения монтажа');
@ -1004,6 +1027,47 @@ begin
end;
WriteLn;
{ --- 16. Границы кусков лежат на кадровой сетке --- }
WriteLn(' 16. Кадровая сетка и точный рез');
Proj := MakeProject(2, Media);
try
Proj.Master := Media.Info;
Proj.EnsureAudioTracks;
{ Времена намеренно «некруглые»: столько кадров не бывает. }
Proj.AddClipPair(Proj.Tracks[0], Media, 3.3177, 4.4413, 0);
Proj.AddClipPair(Proj.Tracks[0], Media, 12.1301, 5.2077, 4.4413);
FlattenStream(Proj, tkVideo, 0, Segs, Missing);
DumpSegments(Segs);
Step := Proj.Master.FpsDen / Proj.Master.FpsNum;
At := 0;
for I := 0 to High(Segs) do
begin
Check(Abs(Segs[I].Duration / Step - Round(Segs[I].Duration / Step)) < 0.001,
Format('кусок %d — целое число кадров: %s с', [I, SecondsToStr(Segs[I].Duration)]));
Check(Abs(Segs[I].SrcStart / Step - Round(Segs[I].SrcStart / Step)) < 0.001,
Format('кусок %d — начало на кадре: %s с', [I, SecondsToStr(Segs[I].SrcStart)]));
if Segs[I].Kind = skVideo then
Check(Segs[I].PreciseIn,
Format('кусок %d режется точно', [I]));
At := At + Segs[I].Duration;
end;
{ Звук уплощается той же мерой: границы обязаны совпасть с картинкой,
иначе звук поедет относительно неё. }
FlattenAudioTrack(Proj, Proj.Tracks[2], At, AudioSegs, Missing);
Total := 0;
for I := 0 to High(AudioSegs) do
Total := Total + AudioSegs[I].Duration;
Check(Abs(Total - At) < 0.0005,
Format('звук и картинка одной длины: %s и %s',
[SecondsToStr(Total), SecondsToStr(At)]));
finally
Proj.Free;
end;
WriteLn;
WriteLn;
if FailCount = 0 then
begin
@ -1014,6 +1078,67 @@ begin
Halt(1);
end;
{ --- Проект для замера синхронности звука и картинки --- }
{ Монтаж из кусков, начала которых заведомо не попадают на опорные кадры.
Зачем отдельная команда: обычный проект-образец берёт два куска, а тут
нужно много резов подряд и в разных местах группы кадров — иначе не
видно, накапливается ли расхождение и зависит ли оно от того, далеко ли
до опорного кадра. }
procedure CmdSyncProj;
var
Proj: TProject;
Info: TMediaInfo;
Video: TMediaItem;
Err: string;
I: Integer;
At, Len, SrcAt: Double;
const
{ Начала кусков в исходнике. Опорные кадры в образце стоят через две
секунды, поэтому точки выбраны с разным отступом от них: почти на
опорном кадре, посередине группы, у самого её конца. }
Starts: array[0..5] of Double = (3.3, 7.77, 12.13, 18.95, 25.02, 31.61);
Piece = 4.0;
begin
Proj := TProject.Create;
try
Proj.Clear;
Proj.AddTrack('V1');
Proj.EnsureAudioTracks;
if not ProbeMedia(ExpandFileName(ParamStr(3)), Info, Err) then
begin
WriteLn('Не удалось прочитать видео: ', Err);
Halt(1);
end;
Video := Proj.AddMedia(ExpandFileName(ParamStr(3)), Info);
Proj.Master := Info;
WriteLn('видео: ', DescribeMedia(Info));
At := 0;
for I := Low(Starts) to High(Starts) do
begin
SrcAt := Starts[I];
Len := Piece;
Proj.AddClipPair(Proj.Tracks[0], Video, SrcAt, Len, At);
WriteLn(Format(' кусок %d: из %s, длина %s, на монтаже с %s',
[I + 1, SecondsToStr(SrcAt), SecondsToStr(Len), SecondsToStr(At)]));
At := At + Len;
end;
if not Proj.SaveToFile(ExpandFileName(ParamStr(2)), Err) then
begin
WriteLn('Не удалось записать проект: ', Err);
Halt(1);
end;
WriteLn('проект записан: ', Proj.FileName);
WriteLn('длина монтажа: ', SecondsToStr(Proj.EndTime), ' с');
finally
Proj.Free;
end;
end;
{ --- Сборка проекта-образца и его отрисовка --- }
procedure CmdBuild;
@ -1402,8 +1527,35 @@ var
Watcher: TExportWatcher;
Rep: TExportReport;
I: Integer;
Ok: Boolean;
WantSum: Int64;
Ok, Stopped: Boolean;
CancelAfter, Waited: Double;
{ Слова после выхода идут в любом порядке и сочетаются: cache resume —
это «по-старому и продолжая». }
function HasWord(const AWord: string): Boolean;
var
J: Integer;
begin
Result := False;
for J := 4 to ParamCount do
if LowerCase(ParamStr(J)) = AWord then
Exit(True);
end;
begin
{ Отдельно от слов может стоять --cancel-after=<сек>: оборвать сборку
на полпути, чтобы было что продолжать. }
CancelAfter := 0;
for I := 4 to ParamCount do
if Copy(ParamStr(I), 1, 15) = '--cancel-after=' then
if not TryStrToFloat(Copy(ParamStr(I), 16, MaxInt), CancelAfter,
InvariantFormat) then
begin
WriteLn('Не удалось разобрать значение --cancel-after');
Halt(2);
end;
Proj := TProject.Create;
try
if not Proj.LoadFromFile(ExpandFileName(ParamStr(2)), Err) then
@ -1465,7 +1617,7 @@ begin
{ Вжигание текста — последним, уже по окончательному списку кусков:
повторное уплощение стёрло бы отметки о разметке. Так же устроено и
окно сборки. }
if (ParamCount >= 4) and (LowerCase(ParamStr(4)) = 'burn') then
if HasWord('burn') then
WriteLn('вжигаю текст, пересобрать придётся ',
SecondsToStr(BurnTextIntoSegments(Proj, Segs, Proj.CacheDir)), ' с');
@ -1474,18 +1626,34 @@ begin
чего собрался ролик. }
Thread := TExportThread.Create(Segs, AudioSegs,
ExpandFileName(ParamStr(3)),
(ParamCount >= 4) and (LowerCase(ParamStr(4)) = 'keep'),
HasWord('keep'),
Proj.Master, Default(TEncodeProfile));
{ Словом resume просят продолжить прерванную сборку: готовые куски
берутся с диска, а не считаются заново. }
Thread.Resume := (ParamCount >= 4) and (LowerCase(ParamStr(4)) = 'resume');
Thread.Resume := HasWord('resume');
{ Словом cache просят собрать по-старому, через кэш кусков: так два
способа сверяются между собой на одном и том же монтаже. }
Thread.AllowStream := not HasWord('cache');
Watcher := TExportWatcher.Create(Thread);
try
Thread.OnProgress := @Watcher.Progress;
Thread.OnLog := @Watcher.LogLine;
Thread.Start;
{ Остановка на полпути: так получается недописанный файл, с
которого потом просят продолжить. }
Waited := 0;
Stopped := False;
while not Thread.Finished do
begin
CheckSynchronize(100);
Waited := Waited + 0.1;
if (CancelAfter > 0) and (not Stopped) and (Waited >= CancelAfter) then
begin
Stopped := True;
WriteLn('[тест] запрашиваю остановку');
Thread.RequestCancel;
end;
end;
CheckSynchronize(0);
Rep := Thread.Report;
finally
@ -1506,6 +1674,23 @@ begin
SecondsToStr(Rep.Segments[I].Duration), Rep.Segments[I].Packets]));
WriteLn(' сумма пакетов кусков: ', Rep.ExpectedPackets);
WriteLn(' пакетов в готовом файле: ', Rep.ActualPackets);
{ Сверка с монтажом: столько ли кадров, сколько он просил. Звук набран по
монтажу с точностью до отсчёта, поэтому лишний кадр картинки — это и
есть расхождение звука с картинкой. }
WantSum := 0;
for I := 0 to High(Rep.Segments) do
Inc(WantSum, Rep.Segments[I].WantFrames);
if WantSum > 0 then
begin
WriteLn(' картинка: ', Rep.ActualPackets, ' кадров, монтаж просил ', WantSum);
if Rep.ActualPackets = WantSum then
WriteLn(' ЗВУК И КАРТИНКА СХОДЯТСЯ')
else
WriteLn(' ЗВУК И КАРТИНКА РАСХОДЯТСЯ на ',
Rep.ActualPackets - WantSum, ' кадров');
end;
WriteLn(' результат: ', Rep.Message);
if not Rep.Succeeded then
@ -1544,6 +1729,8 @@ begin
CmdFlatTest
else if (Cmd = 'build') and (ParamCount >= 3) then
CmdBuild
else if (Cmd = 'syncproj') and (ParamCount >= 3) then
CmdSyncProj
else if (Cmd = 'keytest') and (ParamCount >= 4) then
CmdKeyTest
else if (Cmd = 'buildcut') and (ParamCount >= 3) then

View file

@ -99,6 +99,22 @@ begin
end;
Check('ход монотонный', V >= Prev);
{ 7. Одна-единственная веха — и та конец.
Так выглядит сборка одного куска: сделано ноль из одного. Полоса
ползёт к единице и раньше подбиралась к ней вплотную — заполненная
полоса у работы, которая ещё идёт, читается как зависшая программа.
Теперь догадка не закрывает промежуток целиком, и остаток виден. }
V := 0;
for I := 1 to 1000 do
V := AdvanceProgress(V, 0, 1, Step);
Check('без замеров полоса не доходит до края: ' +
FloatToStrF(V, ffFixed, 8, 3), V <= 0.91);
{ А как только приходит настоящий замер, полоса слушается его. }
V := AdvanceProgress(V, 0.95, 1, Step);
Check('настоящий замер перекрывает догадку', V >= 0.95);
WriteLn;
if Failed = 0 then
WriteLn('ВЕРДИКТ: все проверки пройдены')

123
tests/syncprobe.sh Executable file
View file

@ -0,0 +1,123 @@
#!/bin/sh
# Замер расхождения звука и картинки в собранном ролике.
#
# Как меряется. Делается исходник с метками: ровно на каждой секунде в
# кадре белая вспышка, а в звуке щелчок. Из него собирается монтаж, у
# которого начала кусков заведомо не попадают на опорные кадры. В готовом
# ролике ищутся времена вспышек и времена щелчков; если звук и картинка
# сходятся, каждая вспышка совпадает со щелчком.
#
# Сравнение идёт с поправкой на сам способ измерения: щелчок в сжатом
# звуке размазывается на десяток-другой миллисекунд, и эта поправка
# берётся замером того же исходника.
#
# Запуск: tests/syncprobe.sh — весь прогон
# tests/syncprobe.sh measure ФАЙЛ — только замер готового файла
set -e
HERE=$(cd "$(dirname "$0")" && pwd)
ROOT=$(dirname "$HERE")
WORK="$ROOT/test/sync"
SRC="$WORK/метки.mp4"
PROJ="$WORK/синхро.nlve"
OUT="$WORK/синхро.mkv"
# Времена вспышек: кадры, у которых средняя яркость выше порога. Берётся
# первый кадр каждой вспышки — она длится несколько кадров.
flashes() {
ffprobe -v error -f lavfi "movie=$1,signalstats" \
-show_entries frame=pts_time:frame_tags=lavfi.signalstats.YAVG \
-of csv=p=0 2>/dev/null |
awk -F, '$2>150 { if ($1 - prev > 0.5 || NR == 1) print $1; prev = $1 }'
}
# Времена щелчков: по самим отсчётам, без оконного анализа. Оконный
# уплывает (окно двигается кратно своему размеру), и на замере это
# выглядит как накапливающийся рассинхрон, которого нет.
clicks() {
ffmpeg -v error -i "$1" -vn -ac 1 -ar 48000 -f s16le - |
python3 "$HERE/clicktimes.py"
}
# Сводит два списка времён и печатает расхождение по каждой метке.
#
# Пара ищется по близости, а не по порядку: при резе не на опорном кадре
# в куске может оказаться лишняя вспышка без своего щелчка, и сравнение
# «первый с первым» после такого куска поехало бы всё.
compare() {
awk '
FNR == NR { v[++nv] = $1; next }
{
a = $1; best = 1e9;
for (i = 1; i <= nv; i++) {
d = a - v[i]; ad = (d < 0) ? -d : d;
if (ad < best) { best = ad; bd = d }
}
{ n++ }
{ # Щелчок, у которого вспышки рядом нет вообще, — это край куска:
# метка попала в звук, а её кадр остался за резом. Такую пару
# считать нельзя, иначе итог перевирает сам себя.
if (best > 0.5) { skipped++; next }
}
printf " метка %2d: звук %8.3f ближайшая вспышка %+7.3f с\n", n, a, -bd;
paired++;
s += bd; if (best > worst) { worst = best; wi = n }
}
END {
if (paired == 0) { print " пар не нашлось"; exit 1 }
printf " ИТОГ: среднее расхождение %+.3f с, худшее %.3f с (метка %d)",
s / paired, worst, wi
if (skipped > 0) printf ", одиноких меток %d", skipped
printf "\n"
}' "$1" "$2"
}
measure() {
FILE="$1"
flashes "$FILE" > "$WORK/вспышки.txt"
clicks "$FILE" > "$WORK/щелчки.txt"
echo " вспышек: $(wc -l < "$WORK/вспышки.txt"), щелчков: $(wc -l < "$WORK/щелчки.txt")"
compare "$WORK/вспышки.txt" "$WORK/щелчки.txt"
}
if [ "$1" = "measure" ]; then
mkdir -p "$WORK"
measure "$2"
exit 0
fi
mkdir -p "$WORK"
if [ ! -f "$SRC" ]; then
echo "=== Исходник с метками ==="
# Опорные кадры через две секунды: тогда рез почти всегда приходится на
# середину группы, как на настоящей съёмке с камеры.
ffmpeg -y -v error \
-f lavfi -i "color=c=black:s=640x360:r=60000/1001:d=60" \
-f lavfi -i "aevalsrc=sin(1000*2*PI*t)*lt(mod(t\,1)\,0.05):s=48000:c=stereo:d=60" \
-vf "drawbox=x=0:y=0:w=640:h=360:color=white:t=fill:enable='lt(mod(t\,1)\,0.02)'" \
-c:v libx264 -preset veryfast -g 120 -keyint_min 120 -sc_threshold 0 \
-pix_fmt yuv420p -c:a aac -b:a 128k "$SRC"
echo "готово: $SRC"
fi
echo "=== Поправка на способ измерения (сам исходник) ==="
measure "$SRC"
echo "=== Монтаж с резами не на опорных кадрах ==="
"$HERE/nlvetool" syncproj "$PROJ" "$SRC"
echo "=== Сборка ==="
rm -f "$OUT"
"$HERE/nlvetool" render "$PROJ" "$OUT" | tail -5
echo "=== Замер готового ролика ==="
measure "$OUT"
echo "=== Длительности потоков готового ролика ==="
ffprobe -v error -select_streams v:0 -count_packets \
-show_entries stream=r_frame_rate,nb_read_packets -of csv=p=0 "$OUT" |
awk -F, '{ split($1, r, "/"); printf " картинка: %s кадров = %.3f с\n", $2, $2 * r[2] / r[1] }'
ffprobe -v error -show_entries format=duration -of csv=p=0 "$OUT" |
awk -F, '{ printf " файл целиком: %.3f с\n", $2 }'

107
tests/zoomanchor.py Executable file
View file

@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""Проверка того, что масштаб таймлайна берётся от точки под мышкой.
Ctrl с колесом меняет масштаб. Точка монтажа, на которую смотрит мышь,
обязана остаться на месте: иначе прицелиться масштабом нельзя — картинка
уезжает из-под курсора.
Как меряется. Курсор воспроизведения ставится в известное место и служит
меткой: он привязан ко времени, а не к пикселям, и при смене масштаба
едет вместе с монтажом. Зная, что один щелчок колеса меняет масштаб ровно
в 1.25 раза, неподвижную точку считаем по двум положениям метки:
p1 - a = k * (p0 - a) => a = (k * p0 - p1) / (k - 1)
Ошибку это не прячет: если опора уедет, a разойдётся с положением мыши.
Запуск (редактор уже должен идти на невидимом экране :99):
tests/zoomanchor.py <окно>
где окно — из `DISPLAY=:99 xdotool search --name nlve`.
"""
import os
import subprocess
import sys
import time
from PIL import Image
DISP = os.environ.get('ЭКРАН', ':99')
ENV = {'DISPLAY': DISP, 'PATH': '/usr/bin:/bin'}
# Один щелчок колеса. Должен совпадать с ZoomStep в uTimelineCtrl.
STEP = 1.25
# Полоса окна, по которой всё и меряется: линейка и первая дорожка.
BAND = '1500x120+0+450'
SHOT = os.path.join(os.path.dirname(os.path.abspath(__file__)),
'кадры-зума.png')
def run(*args):
subprocess.run(list(args), env=ENV, check=True)
def playhead(win):
"""Где сейчас курсор воспроизведения. Он единственный красный."""
run('import', '-window', win, '-crop', BAND, '+repage', SHOT)
im = Image.open(SHOT).convert('RGB')
px = im.load()
w, h = im.size
best = (0, None)
for x in range(10, w - 10):
red = sum(1 for y in range(h)
if px[x, y][0] > 120
and px[x, y][0] - px[x, y][1] > 40
and px[x, y][0] - px[x, y][2] > 40)
if red > best[0]:
best = (red, x)
return best[1] if best[0] > 60 else None
def measure(win, mouse_x):
before = playhead(win)
run('xdotool', 'mousemove', str(mouse_x), '520')
time.sleep(0.4)
run('xdotool', 'keydown', 'ctrl', 'click', '4', 'keyup', 'ctrl')
time.sleep(0.9)
after = playhead(win)
run('xdotool', 'keydown', 'ctrl', 'click', '5', 'keyup', 'ctrl')
time.sleep(0.9)
if before is None or after is None:
return None
return (STEP * before - after) / (STEP - 1)
def main():
if len(sys.argv) < 2:
print(__doc__)
return 2
win = sys.argv[1]
# Метку ставим щелчком по пустому месту под дорожками.
run('xdotool', 'mousemove', '900', '600', 'click', '1')
time.sleep(0.8)
bad = 0
for mouse_x in (700, 1000, 1300):
anchor = measure(win, mouse_x)
if anchor is None:
print('мышь %4d: метку не видно, замер не вышел' % mouse_x)
bad += 1
continue
off = anchor - mouse_x
ok = abs(off) <= 2
print('мышь %4d: опора %6.0f, промах %+.0f точек — %s'
% (mouse_x, anchor, off, 'ok' if ok else 'ПЛОХО'))
if not ok:
bad += 1
print()
if bad:
print('ВЕРДИКТ: масштаб берётся не от мыши, случаев: %d' % bad)
return 1
print('ВЕРДИКТ: точка под мышкой остаётся на месте')
return 0
if __name__ == '__main__':
sys.exit(main())

View file

@ -31,6 +31,8 @@ ICONS = [
('Log', 'icons8-property-30.png', 24),
('Glue', 'icons8-клей-30.png', 24),
('Target', 'icons8-цель-30.png', 24),
('SelectClips', 'icons8-select-all-90.png', 24),
('SelectRange', 'icons8-width-90.png', 24),
]
# Значок самой программы: отдельно, потому что окну нужен формат ico —