Compare commits

...

48 commits

Author SHA1 Message Date
82929a8d5b В репозитории только исходники
Из истории убраны собранный ролик, скачанные зависимости под Windows,
отладочные символы macOS, пробники замеров, изначальная постановка и
рабочие бумаги — планы с логами сессий. Всё это осталось на диске, но
больше не отслеживается и закрыто .gitignore: они попадали в коммиты
через git add -A по корню.
2026-09-02 02:08:33 +03:00
1ed479739d Значок цели: исходник и запись в генераторе значков 2026-09-02 02:06:05 +03:00
45777bbdbb Ролик больше не хранится на диске дважды
Картинка собиралась в промежуточный файл, а потом сшивалась со звуком в
конечный. На часовом ролике это 91 ГБ в кэше плюс 91 ГБ рядом и лишний
полный проход копирования — ради того, чтобы приписать звуковую дорожку.

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

Куски после удачной сборки удаляются, и в журнале сказано, сколько места
освободилось: на часовой съёмке их набирается больше сотни гигабайт, а
нужны они только чтобы продолжить прерванную сборку. После неудачной
сборки они остаются — с них и продолжают. Настройка keep_parts оставляет
их всегда.
2026-08-31 17:39:40 +03:00
a28955fa2e Свойства клипа, порядок дорожек, оценка времени и продолжение сборки
Копирование оформления: Ctrl+Shift+C берёт с выделенного клипа
кадрирование, поворот, увеличение, смещение, прозрачность, ключ,
затухания и громкость, Ctrl+Shift+V применяет их ко всем выделенным. Вид
клипа и текст не переносятся: свойства копируют между разными клипами.

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

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

Прерванная сборка продолжается с готовых кусков. Каждый доведённый до
конца кусок помечается в списке рядом с ними: одного наличия файла мало —
сборку могли прервать посреди записи. Первая строка списка — отпечаток
плана; не совпал, значит монтаж менялся, и всё собирается заново.
Проверено: четыре куска взяты готовыми, два пересобраны, склейка не
потеряла ни одного кадра; при изменённом монтаже готовые куски отвергнуты.
2026-08-31 14:55:19 +03:00
0bbff345ba Оверлей по порядку дорожек, текст в окне, имена дорожек
Миникарта была видна в окне, но не в ролике. Предпросмотр рисовал оверлей
поверх всего, не глядя на порядок дорожек, а сборка живёт по правилу
«верхний клип побеждает целиком»: фотография лежала выше оверлея и в
ролике его закрывала. Теперь окно считает так же — оверлей показывается
только там, где его не закрывает сплошной клип с дорожки выше, а закрытый
посередине разбивается на несколько окон показа.

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

Две дорожки Т2. Имя новой дорожке давалось по счёту дорожек своего вида
плюс единица, и после удаления средней счёт совпадал с занятым именем.
Имя подбирается свободное, а повторы из старых проектов разводятся при
чтении.

Проверки 11 и 12 в nlvetool flattest.
2026-08-31 12:41:26 +03:00
59c778d2f6 Текст вжигается и в фотографию; дорожки и полоса участка
Вжигание не доходило до сгенерированных вставок. Картинки и чёрное
отбирались первой же веткой сборки — до проверки на пересборку — и
копировались целиком вместе с отметкой о разметке. Галка вжигания стояла,
кусок в журнале был помечен «с текстом», а в ролике надписи не было.
Причину показал журнал сборки рядом с файлом: помечено «пересобирается»,
а команда — копирование.

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

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

Заголовок дорожки: правый щелчок открывает меню — поднять, опустить,
удалить (с клипами — с подтверждением). Заголовок можно и потащить:
порядок дорожек есть порядок перекрытия, и менять его перетаскиванием
привычнее, чем через меню.
2026-08-31 12:01:12 +03:00
07aee86631 Клип не попадает на дорожку чужого вида
В живом проекте фотография оказалась на текстовой дорожке, а надпись — на
дорожке картинки. Уплощение обходит дорожки по видам, и такой клип не
видит ни сборка, ни предпросмотр: на монтаже он нарисован, в готовом
ролике его нет, и ни одной жалобы по дороге. Именно это и было причиной
пропавших фотографии и текста, а не ошибка со стоп-кадром.

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

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

Проверка 10 в nlvetool flattest: картинка на текстовой дорожке в
уплощение не попадает, после переноса попадает и остаётся на своём месте.
2026-08-31 11:12:05 +03:00
96672341d2 Уголки участка, курсор посередине и журнал сборки рядом с роликом
Отметки участка стали видимыми уголками на линейке — жёлтые треугольники
по краям, за них можно тянуть. Клавишами это выходило долго: поставил,
посмотрел, промахнулся, ставь заново. Край прилипает к границам клипов,
как и курсор, и края не пропускаются друг за друга.

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

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

Журнал сборки пишется рядом с готовым файлом и под тем же именем. Общий
журнал программы перезаписывается следующим запуском, а разбираться,
почему в ролике не то, приходится потом. В журнале: время, проект,
участок, кадр и битрейт, что с текстом, и полный список кусков — с
пометками, где слои, где текст и что пересобирается. Строка пишется
сразу, чтобы после падения осталась последняя.
2026-08-31 11:00:30 +03:00
71381bb774 Участок сборки, качество пересборки и пропавшие куски
Отмеченный участок. Клавиши I и O ставят начало и конец по курсору,
полоса под линейкой показывает отмеченное, сборка собирает только его.
Без этого проверить правку на длинном монтаже нельзя: каждая проверка
стоит столько же, сколько идёт весь ролик. Отметки живут в проекте.
Разметка текста при этом берётся с начала участка, иначе надписи легли бы
со сдвигом.

Фотография пропадала из ролика. Кусок с наложением подаёт слои в ffmpeg
как есть, а одиночная картинка даёт ровно один кадр: кусок в три секунды
получался длиной 0.016667 с. Теперь у слоя есть признак «это картинка», и
она повторяется ключом -loop с частотой кадров проекта.

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

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

Проверки: обрезка списка кусков по участку (nlvetool flattest, 9),
сборка фотографии под наложением и вжигание текста поверх наложения
(nlvetool render ... burn).
2026-08-31 10:37:25 +03:00
d9bbffcaac Записан разрыв: предпросмотр не показывает кадрирование обычного клипа 2026-08-31 01:15:42 +03:00
5bd4e822d9 Рез больше не теряет настройки клипа
После разреза хвост оставался без цветового ключа: перенос настроек был
написан перечислением полей, а ключ появился позже и в перечень не попал.
Хвост переставал быть наложением, становился обычным победителем и
закрывал собой всё, что ниже — вместо компаса в углу окно показывало
пурпурную плашку во весь кадр.

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

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

Проекты, разрезанные прежней сборкой, правка задним числом не чинит:
у хвоста ключ надо включить заново.
2026-08-31 01:08:02 +03:00
d098f1621e Ключ и прозрачность видно прямо в предпросмотре
Оверлей показывался как есть, со своим фоном: чтобы увидеть, что выйдет
после сборки, приходилось собирать. Теперь выбитый цвет и приглушение
считаются в той же цепочке плеера, на лету.

Строки фильтров стали общими со сборкой (ColorKeyFilter, OpacityFilter
в uTransform): окно и готовый ролик больше не могут разойтись по тому,
как записан цвет или порог.

Переключатель в меню «Вид», состояние переживает перезапуск. Замер на
2688x1512, 600 кадров: основа 2.07 с, с наложением 2.43 с, с ключом и
прозрачностью 2.66 с — вчетверо быстрее реального времени, но на слабой
машине это заметнее, поэтому выключить можно.

В бэклог записано окно настроек: переключателей, которые стоят машинного
времени, набралось много, и живут они кто где — кнопками внизу, в меню,
а выбор кодировщика вообще нигде.
2026-08-31 00:46:58 +03:00
4c422ef57d Курсор прилипает к границам клипов
Курсор воспроизведения по щелчку вставал ровно туда, куда попала мышь.
В стык двух кусков так не попасть: на общем плане одна точка экрана —
это секунды материала, а ставят курсор чаще всего именно на стык.

Теперь он притягивается к границам клипов на любой дорожке и к началу
монтажа. Работает и при щелчке по клипу, и по линейке, и по пустому
месту, и при протяжке. Shift меняет решение на противоположное — как у
прилипания клипов.

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

Расчёт открыт наружу и проверяется числами: семь проверок в lfmcheck,
от «стык рядом — притянуло» до «выключено — не притягивает».
2026-08-31 00:32:44 +03:00
fb6b60bc3f Свой тип файла для проектов: *.nlve больше не текст
Внутри проект — обычный JSON, и система считала его просто текстом:
открывала редактором и рисовала значок текстового файла.

Теперь у него свой тип application/x-nlve-project, свой значок (лист с
загнутым углом и знаком программы) и открытие двойным щелчком. Тип
объявлен наследником application/json, но не простого текста — иначе
редактор снова оказался бы программой по умолчанию.

Всё, что нужно для встраивания в рабочий стол, лежит в packaging и
ставится скриптом tools/install-desktop.sh в домашний каталог, без прав
администратора. Раньше пункт меню и значки существовали только на одной
машине и нигде не были записаны.
2026-08-31 00:13:50 +03:00
acb921d5f7 Цветовой ключ и живое наложение в предпросмотре
Клипу задаётся цвет, который при сборке становится прозрачным. Окно
ключа показывает кадр: цвет берётся щелчком по нему, выбитое показано
клеткой, допуск и смягчение края — ползунками. Открывается из окна
кадрирования и из меню «Правка», на монтаже клип помечен квадратиком
выбиваемого цвета.

Предпросмотр перестал сводить такие участки в файлы. Плеер играет две
дорожки одна поверх другой: оверлей во весь ролик больше не заставляет
пересобирать весь фильм после каждой правки. Замерено: наложение стоит
0.4 с на 10 с материала 2688x1512, вчетверо быстрее реального времени.

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

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

Проверки форм теперь создают каждое окно из файла формы: элемент,
забытый в классе, вскрывается на сборке, а не при открытии окна.
2026-08-31 00:10:28 +03:00
1e28dd098b Схлопывание при удалении, разбор внутренних ошибок, дорожки по номерам
Схлопывание при удалении. При включённом автосхлопывании удаление куска
сдвигает всё, что правее, влево до первого касания — дыра не остаётся, и
сборке не приходится заполнять её чернотой.

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

Проверено: клип 0..4 с парным звуком разрезан на 2 с, удалён первый
кусок. Хвост уехал в ноль, следующий клип встал вплотную, дыры нет.

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

Звуковые дорожки предпросмотра хранятся номерами, а не указателями.
Указатели были ловушкой: отмена и открытие проекта пересоздают все
дорожки, а список продолжал показывать на освобождённую память —
и обращаются к нему на каждое обновление времени, то есть десятки раз в
секунду.
2026-08-30 21:59:26 +03:00
36ccbd3015 Рез даёт хвосту свою связку: удаление куска больше не уносит весь клип
Del удалял весь исходный клип вместо выделенного куска, и звук вместе с
ним. Причина: при резе хвост наследовал номер связки головы, а удаление
расширяет выделение на всю связку — обе половины оказывались одной
связкой и умирали вместе. Дальнейшие резы наследовали тот же номер, так
что от удаления одного куска исчезали все.

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

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

Проекты, разрезанные прежней сборкой, останутся со сцепленными
половинами: у них один номер связки на всё. Развязать их можно Ctrl+G.
2026-08-30 18:43:21 +03:00
62ba75ff2f Сборка ролика с текстом: вжиганием или отдельной дорожкой
Окно сборки теперь спрашивает не только имя файла: куда собирать, в каком
контейнере и что делать с текстом — это один вопрос, ответ на любую часть
меняет допустимое в остальных.

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

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

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

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

Проверено сборкой двух роликов из одного монтажа с надписью на 2..6
секунде. С вжиганием: кадры на 1 и 7 секундах чистые, на 3 и 5 — с
надписью, предупреждение назвало 8 секунд из 15.5. Отдельной дорожкой: в
файле три потока, третий ass с названием «Надписи», картинка чистая.

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

Окно хода сборки переименовано в «Ход сборки»: два окна с заголовком
«Сборка ролика» путали и меня, и человека.
2026-08-30 16:34:59 +03:00
d55b624560 Текст на видео: модель, разметка ASS, предпросмотр и редактор
ffmpeg умеет рисовать текст, но не тем фильтром, о котором думают.
drawtext в местной сборке отсутствует: с версии 7.1 ему нужен harfbuzz.
Зато есть libass — и он для задачи лучше. Проверено отрисовкой: цвет,
обводка, тень, шрифт по имени, размер, поля и девять положений. Девять
положений — родное понятие ASS, ровно та сетка, которую просили.

Чего libass не делает — не уменьшает шрифт, чтобы текст влез. Это считаем
мы: сами переносим по словам и сами подбираем размер, а libass получает
готовые переводы строк, чтобы нарисовать ровно то, что мы померили.

Размер ищется делением отрезка пополам. Прикидка «уменьшить во столько же
раз, во сколько не влезли» давала слишком мелко: после переноса строк
становится меньше, и места остаётся вдоволь — на длинной надписи вместо
29 помещался 37. Замер готового блока: ровно 144 точки при разрешённых
144, то есть пятая часть кадра 720p.

Текст живёт на своей дорожке, как звук: правило «верхний клип побеждает»
на него не распространяется, он лежит поверх и ничего не закрывает.

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

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

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

Сборки ролика с текстом пока нет — это следующий этап, он расписан в
IMPLEMENTATION_PLAN.md.
2026-08-30 15:30:02 +03:00
e4a77d1a72 Программа находит свой каталог через символьную ссылку
Ссылка в ~/.local/bin и ярлык в меню запускают программу не по её
настоящему пути. ExtractFilePath(ParamStr(0)) при этом указывал на
каталог ссылки, и рядом с ней оказались бы и журнал, и файл
зависимостей, и поиск ffmpeg «рядом с программой».

Появился ProgramDir в uAppTypes: он раскрывает ссылки, пока они не
кончатся, с ограничением на случай закольцованных. Под Windows ссылок
такого рода нет, и путь берётся как есть.

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

Проверено: запуск через ~/.local/bin/nlve из домашнего каталога кладёт
журнал в каталог программы, а не рядом со ссылкой.
2026-08-22 13:41:07 +03:00
b792b81a00 Сборка под macOS отдельной целью, ограничения записаны в бэклог
./build.sh macos делает две сборки: nlve_intel для Intel и nlve_arm для
Apple Silicon. Имя цели в проекте одно на все системы, и сборка под
darwin затирает линуксовую — поэтому каждый файл переименовывается сразу,
а сборка под эту систему в конце делается заново.

Обёртку .app, которую делает lazbuild, удаляем: внутри неё ссылка на файл
с именем nlve, которого после переименования уже нет.

BACKLOG.md — то, что известно и сознательно не сделано. Про macOS там
записано главное: собирается, но предпросмотра не будет, пока встраивание
mpv не научится работать через NSView; окно зависимостей советует маку
apt вместо brew; проверить нечем, потому что мака нет. Заодно туда
переехали лицензионный вопрос с libmpv и три мелочи: значок exe, кэш
наложений и четыре секунды на открытие окна кадрирования.
2026-08-22 12:53:05 +03:00
e12d0df974 Напоминание о зависимостях и починка падения под Wine
Падение. Окно зависимостей роняло программу под Wine: «stack overflow»,
шестнадцать мегабайт стека съедались целиком. Причина — качели между
таблицей и своими полосами прокрутки: появление вертикальной полосы
сужает таблицу, от этого перестаёт помещаться содержимое по ширине,
появляется горизонтальная, от неё убавляется высота — и решение о
вертикальной принимается заново. В комментарии стояло обещание, что
второго круга не будет; под Wine круг не сходился никогда.

Теперь глубина пересчёта ограничена тремя кругами. Лишняя полоса
прокрутки не беда, падение — беда.

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

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

Высота надписи в обоих диалогах считалась по общей ширине текста и не
видела переводов строк — сообщение обрезалось снизу. Теперь каждая
строка считается отдельно.
2026-08-22 12:37:35 +03:00
83c56d334f Окно «О программе»: свой показ текста с переносом и живыми ссылками
Текст переносится сам, по ширине окна, и переливается заново при
изменении размера. Считать переносы заранее нельзя: текст когда-нибудь
поедет в переводы, а там длина строки другая.

Появился uArticleView — страница текста со ссылками. Журнал для этого не
годился: он показывает сообщения программы, где перенос сбил бы
выравнивание, и строк не переносит принципиально.

Разметка нарочно бедная: «# » — заголовок, пустая строка — отступ, два
пробела — отступ списка, http:// и https:// — ссылка. Больше ничего не
нужно: это окно «О программе», а не браузер.

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

Вкладки названы «Ресурсы» и «Возможности».

Тексты переписаны короче: строка — одна мысль, без рассуждений.
2026-08-22 11:08:45 +03:00
58d7748d51 Значки Icons8, окно «О программе» с указанием авторства
Значки заменены на присланные. Они вшиты в программу модулем uIconData,
который собирается из icons_online командой tools/make_icons.py — правятся
исходные картинки, а не он. Размер выбран под кнопку: двадцать четыре
точки в клетке двадцать восемь, уменьшение делает convert, пропорции
сохранены — все исходники квадратные.

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

Значок программы стал значком окна. Значок самого файла под Windows
задаётся не отсюда, а ресурсом при сборке — это осталось как было.

Окно «О программе» (Справка → О программе). Две вкладки: «Библиотеки и
авторство» и «Что умеет». Первая обязательна — бесплатная лицензия
Icons8 требует ссылку на каждый значок; заодно там перечислено всё, на
чём программа держится, и под какой лицензией. Списки разные по
системам: под Linux ffmpeg и libmpv ставит пакетный менеджер, под
Windows их скачивает сама программа, и это разные сборки.

Вкладки свои, а не TPageControl: системные вкладки под Windows остались
бы светлой полосой поверх тёмного окна.

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

Свои рисованные значки из icons/ убраны: они больше не используются, и
держать рядом два набора значило бы путать.
2026-08-22 10:54:30 +03:00
41ea1b80de Один щелчок — одно действие; кнопки внизу приведены в порядок
Главное. Каждый щелчок по нашей кнопке срабатывал дважды. Click вызывает
сама LCL при отпускании внутри кнопки — TControl.WMLButtonUp, с той же
проверкой «внутри», какую я написал руками в MouseUp. Мой вызов
оказывался вторым. На кнопке-переключателе это значит «ничего не
произошло»: первое срабатывание меняло состояние, второе возвращало
обратно. Свой вызов убран.

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

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

Третье. Правка файла формы не сработала молча — строка не совпала, а я
это не проверил. Кнопка прилипания осталась широкой, с надписью, и
наехала на соседние. Исправлено. Заодно выяснилось, что файл формы
пересобирается только вместе со своим модулем: правки одного лишь lfm в
двоичный файл не попадают.

Значок прилипания переделан в подкову магнита: линия со стрелками смысла
не передавала.

Переключение журнала добавлено в меню «Вид» и на Ctrl+L.
2026-08-22 01:40:43 +03:00
9d6e5580a1 Квадратные кнопки со значками, журнал перестал уезжать из-под курсора
Двойной щелчок по кнопке журнала. Причина оказалась не в кнопке: журнал
лежал ниже полосы состояния. Спрятали журнал — полоса уехала вниз на сто
тридцать пикселей, кнопка ушла из-под курсора, и следующий щелчок в то же
место попадал уже мимо. Порядок исправлен: полоса состояния теперь самая
нижняя, журнал над ней, и при переключении ничего не движется.

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

Значки рисуются кодом, а не берутся из файла: программа не должна
зависеть от того, лежит ли рядом с ней картинка. Рисунки придуманы в
icons/*.svg — оттуда их можно перегнать в png чем угодно, проверено
командой convert.

Свойства из файлов форм теперь проверяются отдельной программой по
сведениям о типах. Файл формы разбирается при запуске, а не при сборке:
опечатка в имени свойства или в значении перечисления даёт не ошибку
компиляции, а падение окна на старте. Проверка включена в build.sh.
2026-08-22 01:08:58 +03:00
fd34e71f49 Показатель работы внизу: ход, участок на монтаже, переключатель журнала
Полоска выполнения теперь показывает всё, что делается в стороне от
окна: сборку наложений и затуханий, разбор звука, миниатюры. Раньше она
знала только про звук и миниатюры, а самое долгое — пересборку участков
с прозрачностью — не показывала вовсе.

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

Расчёт вынесен в uAppTypes отдельной функцией именно затем, чтобы его
можно было прогнать числами, и прогоняется: проверки в
tests/progress/progtest.lpr включены в build.sh. Замер: до половины пути
десять шагов таймера, до трёх четвертей — ещё десять.

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

Кнопка «Журнал» прячет и показывает нижнюю полосу с сообщениями,
состояние переживает перезапуск.

Системный TStatusBar не взят намеренно: его рисует система, и под
Windows он остался бы светлой полосой — ровно та беда, из-за которой уже
переписаны кнопки, полосы прокрутки, журнал и меню. Всё сделано на своей
нижней панели, теми же средствами.
2026-08-22 00:52:09 +03:00
1e00cd9f44 Прозрачность и переходы видны в предпросмотре
Плеер играет список кусков, а список накладывать не умеет: там побеждает
верхний клип целиком. Поэтому участки с прозрачностью и переходами
заранее сводятся в отдельные файлы кэша, и предпросмотр играет уже их.
Файл считается один раз, имя выводится из описания слоёв — при следующем
открытии ждать нечего.

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

Для сборки подстановка не применяется: там наложение считается заново с
параметрами вывода, и промежуточный файл был бы лишней потерей качества.

Проверено на монтаже с клипом в 50% прозрачности, где оба слоя из одного
файла: кадр наложения ближе к полусумме двух слоёв (отклонение 13.8), чем
к любому из них по отдельности (26.0 и 18.4).

Попутно найдено и исправлено: наложения кодировались процессорным x265,
потому что ffmpeg выбирает кодировщик по имени кодека, а мы передавали
«hevc». Замерено: 9.6 с материала за 11.2 с работы. Теперь и здесь
берётся предпочтительный кодировщик, аппаратный.

В служебную программу добавлена команда preview: список кусков ровно
такой, каким его видит предпросмотр, вместе с готовыми наложениями.
2026-08-21 18:19:33 +03:00
80e6585134 Затухания видны и настраиваются, сборка с перекодированием, свой журнал в окне сборки
Затухания. Механика была написана давно и работала в сборке, но увидеть
или задать её было нечем. Теперь у клипа два верхних угла — ручки:
левая тянет появление, правая уход. Взятое затухание рисуется срезанным
углом, так это устроено в монтажных программах. Ручки перехватывают
нажатие раньше растягивания за край: они мелкие, промахнуться мимо них
легче, а взяться за край можно ниже.

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

Сборка с перекодированием. Отдельный пункт меню и отдельное окно:
размер кадра от 720p до 4K, поток в килобитах, кодек HEVC или H.264.
Перекодирование идёт одним проходом на весь ролик — пересобирать куски
по отдельности нельзя, на стыках была бы видна смена качества.

Кодирование на видеокарте, и распаковка тоже: замерено на паре кусков
2688x1512 — 9.18 с на процессоре против 1.77 с с аппаратной распаковкой,
впятеро. Полностью карточный путь через scale_cuda оказался медленнее
(5.74 с) и не взят. Если аппаратная распаковка не пойдёт, сборка
повторяется без неё: медленно лучше, чем никак. Окно честно
предупреждает, когда аппаратного кодировщика в ffmpeg нет вовсе.

Журнал в окне сборки заменён на свой: у системного поля оставались
светлые полосы прокрутки и не работала прокрутка вбок с Shift. Это было
последнее такое место.
2026-08-21 18:11:59 +03:00
36499edd50 Остаток полосы меню закрашивается своими руками, с отчётом в журнал
До этого места не дотянуться ни отрисовкой пунктов (это не пункт), ни
подложкой через SetMenuInfo: под Wine пункты выходят тёмными, а полоса
остаётся светлой. Полоса живёт в неклиентской части окна, поэтому
закрашиваем прямо туда: правый край последнего названия берётся из
GetMenuItemRect, границы полосы из GetMenuBarInfo, всё что правее —
заливается нашей кистью.

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

Заодно закраска сама отчитывается в журнал: какие прямоугольники выдала
система и куда мы рисовали. Полоса упорно остаётся светлой, а гадать по
снимкам экрана дальше некуда.
2026-08-21 13:58:19 +03:00
1e6da9b592 Выход не ждёт ядро mpv: под Wine это ожидание не кончалось
Журнал закрытия показал место точно: последняя запись «Закрытие: закрываю
плеер», дальше ничего. Значит, зависание внутри mpv_terminate_destroy —
она не просто отпускает клиента, а ждёт, пока ядро mpv свернёт работу и
добьёт свои потоки. Под Linux это мгновенно, под Wine не кончалось вовсе,
причём даже когда ничего не открывали.

Теперь плеер отсоединяется через mpv_destroy: она отпускает клиента и
возвращает управление сразу, а ядро без клиентов сворачивается само.
Команда «завершиться» перед этим намеренно не посылается — её тоже
выполняет ядро, и встать она могла бы точно так же. Если в библиотеке
mpv_destroy не окажется, остаётся прежний путь с ожиданием, и в журнале
будет видно, что пошли по нему.

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

Плеер получил свою строчку в журнале — теперь видно и отсоединение, и
завершение, а не только шаги окна.
2026-08-21 13:27:27 +03:00
ad88683e8b Вставки тишины для звука делаются без картинки
Звук пропадал кусками, и причина оказалась не в громкости и не в
раскладке дорожек: обе были верны. Дыры на звуковой дорожке заполнялись
тем же чёрным клипом, что и дыры на видеодорожке, а опорные кадры в нём
стоят через 4.171 с. Перемотка внутри такой вставки откатывается к
ближайшему опорному кадру, дорожка начинает отдавать звук на несколько
секунд раньше соседних, смешивание равняется по ней — и содержимое
соседней дорожки в это окно не попадает.

Замерено на файле, без запуска окна. Смешивание трёх дорожек проекта:

  старт  0 с: -23.3 dB     старт 24 с: -91.0 dB   (полная тишина)
  старт 12 с: -10.1 dB     старт 30 с: -18.0 dB

При этом та же дорожка A2 отдельно на 24-й секунде даёт -11.7 dB, то
есть содержимое на месте. Смешивание A2 с A4 работает, A1 с A2 — нет:
у A1 в этой точке идёт длинная чёрная вставка, и перемотка в ней
откатывается на 3.1 с назад.

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

  0 с -24.1   7 с -19.0   12 с -11.9   18 с -17.5   24 с -11.7
  25 с  -9.6  30 с  -9.5  40 с -15.6   60 с -19.5   72 с -15.9

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

Заодно в служебную программу добавлена команда audio: раскладка
звуковых дорожек проекта на куски вместе со строками edl://. Ею это и
было найдено.
2026-08-21 12:58:13 +03:00
11075d6b52 Подложка полосы задаётся на показе окна, в журнал пишутся громкости и шаги закрытия
Подложка. SetMenuInfo работает по дескриптору меню, а тот появляется
вместе с дескриптором окна — то есть после создания формы. Оба прежних
вызова попадали на несозданный дескриптор и молча ничего не делали.
Теперь вызов идёт из показа окна.

Громкости. В журнал пишется, какая дорожка какую громкость получила и
какая цепочка смешивания ушла в mpv. Снимками экрана это не разобрать:
в одном месте одного и того же клипа звук есть, в соседнем нет, и без
цепочки видно только следствие.

Закрытие. Каждый шаг отмечается в журнале: остановка предпросмотра,
ожидание потоков миниатюр и разбора звука, сохранение настроек,
закрытие плеера, выгрузка libmpv. Под Wine программа зависает при
выходе, а по молчаливому журналу шаг не найти.
2026-08-21 12:37:18 +03:00
bc7418ac35 Подложка полосы меню задаётся, когда у меню уже есть дескриптор
Цвет подложки задаётся кистью через SetMenuInfo по дескриптору меню, а
дескриптор появляется только когда меню отдано окну. Вызов стоял до
присваивания, условие HandleAllocated не выполнялось, и подложка не
задавалась ни разу: пункты рисовались нашими руками, а полоса правее
последнего названия оставалась светлой.

Теперь вызов идёт дважды и оба раза нужны: до присваивания — расставить
обработчики отрисовки к первому показу, после — задать подложку. Полоса
меню живёт в неклиентской части окна и сама себя не перерисовывает,
поэтому следом идёт DrawMenuBar.
2026-08-21 12:31:44 +03:00
71e9292885 Полоса меню снова настоящая, цвет задаётся по-своему на каждой системе
Самодельная полоса из кнопок стоила поведения: наведение на соседнее
название не перекидывало туда раскрытый список, не работал переход по
меню с Alt и стрелками. Подражать настоящему меню своими руками пришлось
бы до бесконечности, а причина отказа была одна — цвет.

Цвет решается иначе. Под gtk2 своими руками меню не рисуется, и это
записано в самом LCL: OwnerDraw, OnDrawItem и OnMeasureItem там помечены
как неподдерживаемые (interfaces/gtk2/issues.xml), вызывает DoDrawItem
только win32. Зато gtk принимает описание стиля текстом —
gtk_rc_parse_string, разбирается один раз до создания окон. Под win32
наоборот: строк стиля нет, зато отрисовка пунктов работает, в том числе
для названий в самой полосе.

Проверено отдельным пробником (probe/menu): настоящее TMainMenu под gtk2
берёт наши цвета и в полосе, и в раскрытом списке, включая цвет
выделенного пункта.

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

Остаток полосы правее последнего названия под Windows по-прежнему рисует
система — это уступка, а не решение.
2026-08-21 10:44:31 +03:00
53a90f0d4e Звук больше не глохнет насовсем, меню открывается каждый раз
Общая громкость плеера выставлялась по громкости единственной звуковой
дорожки и обратно уже не возвращалась. Клип с выключенным звуком —
обычное дело, и стоило монтажу на миг остаться с одной такой дорожкой,
как плеер немел до конца работы: на всём монтаже, сколько бы дорожек
потом ни появилось. Теперь в ветке смешивания громкость всегда полная —
громкость каждой дорожки задана внутри цепочки. Случай «дорожек нет
вовсе» тоже снимает и цепочку, и приглушение.

Замер: одна дорожка с громкостью 0 даёт точный ноль, после возврата
дорожек звук возвращается на -11.4 dB. Раньше он не возвращался.

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

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

Фоновая работа видна в нижней полосе: надпись с числом файлов и своя
полоска выполнения. Разбор звука читает файл целиком, на шести
гигабайтах это больше десяти секунд, и молчать об этом нельзя.
2026-08-20 22:32:50 +03:00
chaiman
e09e61103d Вид звукового клипа сохраняется, вставки тишины заказываются
Вид клипа «звук» не умел записываться: в файле проекта он превращался в
«видео». После открытия такой клип терял форму звука, получал ненужную
кнопку кадрирования, а ползунок начинал править прозрачность вместо
громкости. Найдено прямо в файле проекта пользователя.

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

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

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

Проверено на проекте пользователя: виды клипов после чтения верные,
список звуковой дорожки A2 играет сам по себе (-16 dB), и вся связка с
смешиванием тоже (-8.8 dB).
2026-08-20 19:30:30 +03:00
chaiman
f9e50632aa Кадрирование: поворот после обрезки, рамка не расходится с настройкой
Поворот и отражение применялись к исходному кадру, а обрезка шла после
них. Из-за этого рамка показывала одно место, а вырезалось другое.
Теперь наоборот: сначала выбирается кусок, потом он поворачивается и
отражается. Если после поворота кусок лёг поперёк кадра проекта, он
вписывается целиком с полями.

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

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

Отдельно: разбор звука больше не держит главный поток. Перезапуск ждал
окончания текущего файла, а это чтение гигабайтов — окно всё это время не
отвечало, и меню не открывалось вовсе. Теперь проход доигрывает сам и
запускается заново, а идёт он с наинизшим приоритетом.
2026-08-20 19:23:54 +03:00
chaiman
40a0e2041f Цепочка смешивания снимается до загрузки
Чёрное окно и полная тишина оказались следствием того, что цепочка
смешивания звуковых дорожек оставалась от прошлой загрузки. Она
ссылается на дорожки по номерам, а в новом файле таких номеров нет.

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

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

Проверена вся последовательность: две звуковые дорожки, затем одна, затем
без внешнего звука. Кадр на месте во всех трёх случаях.
2026-08-20 13:17:27 +03:00
chaiman
3583f74864 Звуковые дорожки смешиваются, а не побеждают друг друга
Правило «верхний побеждает» я перенёс на звук механически, и это была
ошибка: оно придумано для картинки, где и делает возможной сборку
копированием. Для звука оно бессмысленно — под голос нельзя было положить
фоновую музыку. И стоит смешивание ничего: звук всё равно перекодируется.

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

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

Проверено: два тона на разных дорожках, второй с громкостью 50%. В
готовом ролике -21.2 и -27.1 dB, в предпросмотре -21.2 и -27.2. Совпало.

Найдена по дороге ошибка в собственном условии: я считал слои без
победителя и потому обнулял список, когда звучали ровно две дорожки.
2026-08-20 12:10:57 +03:00
chaiman
79fe091fbb Звуковая дорожка подключается командой, а не свойством
Найдена настоящая причина обеих жалоб на звук. Свойство audio-files,
заданное перед загрузкой, не подключает внешний список кусков вовсе:
проверено на живом mpv через его канал управления — после установки
свойства дорожек по-прежнему две, обе не внешние. Играл звук, встроенный
в саму картинку.

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

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

Если подключить не удалось, в журнал пишется предупреждение. Глушить звук
на этот случай нельзя — прошлая попытка так сделать оставила без звука
весь монтаж.
2026-08-20 11:58:29 +03:00
chaiman
d0dc22630e Убрана заглушка звука, из-за которой пропал звук вообще
Вчерашняя правка глушила звук, когда перебор дорожек не находил внешнюю.
Задумывалось как защита: играть звук из картинки вместо отдельной дорожки
монтажа нельзя. Вышло наоборот — звука не стало нигде.

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

Проверено на точном списке кусков, который строит предпросмотр: картинка
2..8 с, звук сдвинут на 4..10 с. Воспроизведение дало тишину до четвёртой
секунды и тон после — ровно то, что на монтаже.
2026-08-20 11:27:55 +03:00
chaiman
cf20a0164e Две ошибки раздельного звука
Первая: при выключенных дорожках картинки не играло вообще ничего. Поток
картинки опустевал, уплощение возвращало неудачу, и предпросмотр
останавливался вместе со звуком, которого выключение не касалось.

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

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

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

Проверено: при выключенных дорожках картинки получается чёрный кадр и
сохранившийся звук (-21 dB); внешний список кусков выбирается верно и в
ту, и в другую сторону.
2026-08-20 11:00:41 +03:00
chaiman
3acf79d174 Этап 5: прозрачность, переходы и затухания
Ползунок на клипе картинки теперь задаёт непрозрачность, на звуковом —
громкость. Уплощение выдаёт для таких мест не одного победителя, а слои.

Слои появляются в трёх случаях: клип полупрозрачен и сквозь него виден
нижний; два клипа на одной дорожке наползли друг на друга и должны
перетечь один в другой; у клипа задано плавное появление или уход.

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

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

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

Проверено замерами:
- красный с непрозрачностью 50% поверх синего дал (127, 0, 126);
- переход на одной дорожке: синий плавно перешёл в красный за секунду;
- звуковой переход: -24 dB, -27.5, -35, -51, тишина;
- появление за 2 секунды: пересобрано 2 секунды из 6, остальное
  скопировано.

Найдено по дороге: участок с наложением склеивался с соседним куском и
терял слои — в условии склейки они не учитывались.
2026-08-20 10:11:20 +03:00
chaiman
b6e215a03f Связки клипов и порядок дорожек
Ctrl+G связывает выделенные клипы и развязывает их, если они уже в одной
связке. Связка двигается, режется и удаляется целиком: половина связки —
это разъехавшийся звук, который потом не найти.

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

Новая дорожка картинки встаёт перед первой звуковой, а не в конец списка:
порядок дорожек задаёт, кто побеждает при перекрытии.

Найдена и исправлена настоящая ошибка: при резе новый кусок терял
громкость и кадрирование — они просто не переносились на хвост.
2026-08-20 09:58:16 +03:00
chaiman
a06ecfd720 Этапы 3 и 4: звук отдельно от картинки
У дорожки появился вид — картинка или звук, у клипа принадлежность к
связке. Проект заводит две дорожки картинки и две звука; при добавлении
файла со звуком клип кладётся парой, связанной общим номером.

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

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

Готовые потоки сшиваются копированием.

Предпросмотр получает второй список кусков как внешнюю звуковую дорожку.

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

Проверено сборкой:
- картинка из молчащего синего файла 0..6 с, звук из другого файла с 2 по
  6 с. В результате тишина первые две секунды и тон дальше — звук встал
  туда, куда положен, а не туда, где картинка;
- обычный парный случай: клип с 2 по 6 с, тишина до него и тон в нём;
- проверки уплощения пройдены.
2026-08-20 09:50:03 +03:00
chaiman
b7b41f7cf0 Этап 2: кадрирование на клипе с перетаскиваемой рамкой
Настройка кадрирования переехала с файла на клип: два куска одного файла
бывает нужно кадрировать по-разному. У файла осталась такая же, но лишь
как заготовка — её берёт новый клип при добавлении. Проекты, записанные
раньше, читаются: клип без своей настройки берёт её у файла.

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

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

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

Проверено сборкой: первый кусок без кадрирования скопирован, второй с
увеличением вдвое пересобран через scale=1280:720,crop=640:360:384:180.
154 пакета вошло, 154 вышло, декодирование без ошибок.

Заодно: правый щелчок по списку файлов теперь выделяет файл под курсором.
Без этого меню без выделения отключалось целиком и выглядело так, будто
его нет. Цвет формы звука сменён на пастельно-жёлтый.
2026-08-19 23:10:08 +03:00
chaiman
f1ef6e624a Этап 1: форма звука на клипах
Для каждого файла один раз строится список пиков: сто значений в секунду,
каждое есть наибольший модуль отсчёта за свои десять миллисекунд. Рисовать
по настоящим отсчётам нельзя — в секунде их 48 тысяч, а в клипе на экране
сотни пикселей.

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

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

Проверено: файл «4 с тона, 4 с тишины, 4 с тона» даёт форму в первой и
последней трети и пустоту посередине. Полная амплитуда даёт 254 из 255.
Разбор записи на 892 с занимает 12.7 с — это чтение 6.3 ГБ с диска,
быстрее не выйдет. Первоначальный критерий «меньше двух секунд» в плане
был занижен и исправлен по замеру.

Заодно снят риск четвёртого этапа: mpv принимает edl:// как внешнюю
звуковую дорожку и берёт звук именно из неё. Проверено встречными
списками — картинке «тишина, потом тон», звуку «тон, потом тишина».
2026-08-19 21:23:24 +03:00
55 changed files with 13185 additions and 371 deletions

47
.gitignore vendored
View file

@ -1,7 +1,11 @@
# Результаты сборки
/lib/
# Обёртка для macOS: её создаёт lazbuild при сборке под darwin.
/nlve.app/
/nlve
/nlve.exe
/nlve_intel
/nlve_arm
/nlve.dbg
/nlve.res
/tests/lib/
@ -42,3 +46,46 @@ shot*.png
# Журнал последнего запуска
/nlve.log
# Проверка свойств из файлов форм.
/tests/lfmcheck/lfmcheck
/tests/lfmcheck/units/
# Проверка хода полоски: двоичный файл и промежуточные модули.
/tests/progress/progtest
/tests/progress/*.o
/tests/progress/*.ppu
# Сборка пробников. Сами исходники нужны: они и есть запись замера.
/probe/*/units/
/probe/menu/menuprobe
/probe/menu/menuprobe.exe
# --- Тому, что ниже, в репозитории не место ---
# Готовые ролики и журналы сборки, которые ложатся рядом с ними.
*.mkv
*.mp4
*.log
# Скачанные зависимости и подопытная сборка под Windows: сотни мегабайт,
# у каждого свои и добываются меню «Зависимости».
/test/
# Отладочные символы macOS: их делает компоновщик.
/nlve.dSYM/
/nlve.app/
# Пробники замеров — отдельные программки, к самому редактору отношения
# не имеют.
/probe/
# Рабочие бумаги: планы, логи сессий, изначальная постановка. Нужны в
# работе, но это не исходники.
/docs/plans/
/docs/session-logs/
/IMPLEMENTATION_PLAN.md
/изначальная постановка.txt
# Мусор компоновщика Lazarus.
link*.res

142
BACKLOG.md Normal file
View file

@ -0,0 +1,142 @@
# Что отложено
Список того, что известно и сознательно не сделано. Не «когда-нибудь
починим», а «знаем, чего это стоит, и решили пока не платить».
## macOS: собирается, но работает наполовину
Сборка проверена. В тулчейне fpcupdeluxe уже стоит всё нужное:
кросс-компиляторы, Apple SDK (10.13, 11.0, 12.3) и виджетсет cocoa для
обеих архитектур. Обе цели собираются с первого раза:
./build.sh macos → nlve_intel (Mach-O x86_64)
nlve_arm (Mach-O arm64)
Чего не будет, пока не сделано:
**Предпросмотра нет.** Встраивание mpv написано под два случая: X11 (под
gtk2 создаётся своё дочернее окно и его номер отдаётся mpv в `wid`) и
Windows (там у панели есть настоящий HWND). Под cocoa нужен третий путь —
отдать mpv указатель на NSView. Это не правка на десять строк: нужно
разобраться, как LCL под cocoa отдаёт NSView панели, и проверить, что mpv
рисует именно в неё.
**Окно зависимостей врёт.** Списки в `uDeps.pas` разделены на два случая:
Windows и всё остальное. Мак попадает во второй и получает совет
`sudo apt install ffmpeg`. Нужен свой список: `brew install ffmpeg mpv`
или готовые сборки.
**Проверить нечем.** У нас нет мака. Собранный, но ни разу не запущенный
файл — это предположение, а не сборка. Всё, что можно утверждать сейчас:
компилятор и компоновщик отработали без ошибок.
**Двух файлов может оказаться мало.** Intel и Apple Silicon сейчас
раздаются отдельно. Склеить их в один универсальный умеет `lipo`, но в
тулчейне он есть только для powerpc — для x86_64 и arm64 его надо
доставить.
**Обёртка .app не настроена.** lazbuild её создаёт, но внутри лежит
ссылка на файл с именем `nlve`, которого после переименования нет.
Сейчас она просто удаляется. Настоящему приложению нужны свой Info.plist,
значок и, для раздачи, подпись и нотаризация у Apple.
## Лицензия не выбрана
libmpv грузится в наш процесс, а её сборки — GPL (в Ubuntu GPL-3+ из-за
libsmbclient, у shinchiro тоже GPL). По толкованию FSF это делает
совместную работу производной, и при раздаче программа должна быть под
GPL-3+ или совместимой.
Пока ничего не раздаётся — вопрос не стоит. Решать его перед первой
раздачей сборок. Возможный обход: отказаться от libmpv в пользу своего
вывода кадров через ffmpeg, но это большая работа и заметно хуже по
плавности.
ffmpeg проблемой не является: он запускается отдельной программой.
## Настоящая прозрачность в исходном файле
Цветовой ключ выбивает цвет, но бывает и прозрачность, записанная в самом
файле: WebM с VP8/VP9, ProRes 4444, QuickTime Animation. ffmpeg их
распаковывает в кадр с альфа-каналом, и ключ таким файлам не нужен —
достаточно не выбрасывать альфу по дороге.
Что для этого потребуется, когда дойдут руки:
- при добавлении файла запоминать, есть ли у него альфа (`pix_fmt` с
буквой «a»: yuva420p, yuva444p10le, argb);
- такой клип считать несплошным так же, как клип с ключом, — то есть
собирать участок слоями;
- в цепочке фильтров следить, чтобы альфа не терялась: `scale` её
сохраняет, а `pad` — только с прозрачным цветом полей;
- аппаратное декодирование к таким файлам не применять: альфа в VP9
лежит отдельным потоком, и видеокарта его не отдаёт.
Чего не будет в любом случае: раздать готовый ролик с прозрачностью в
MP4/H.264 нельзя — контейнер и кодек альфу не хранят. Для этого нужен
отдельный вывод в VP9 или ProRes 4444.
## Предпросмотр не показывает кадрирование обычного клипа
Кадрирование и поворот видны в окне только у клипов с ключом или
прозрачностью: их плеер кладёт поверх основы сам и применяет к ним
цепочку фильтров. Обычный клип идёт списком кусков, а список фильтровать
нечем — он играется как есть, во весь кадр. В готовом ролике при этом всё
верно: такой кусок помечен на пересборку и обкладывается полями.
Проверено опытом: у клипа выключен только ключ, кадрирование оставлено —
окно показывает его во весь кадр.
Как это делается тем же механизмом, что и наложение: провести такой клип
через ту же дорожку наложения, а под ним заставить основу быть чёрной —
непрозрачный клип с полями в готовом ролике именно так и закрывает всё,
что ниже. Значит, уплощению нужно понятие «здесь дыра, даже если ниже
что-то есть».
Чего это стоит: ветка масштабирования в цепочке работает на каждом кадре,
а не только там, где клип виден. Для накладки в 512 точек это пустяк, для
куска 2688x1512 — уже нет. Поэтому сначала замер, потом решение.
## Окно настроек программы
Переключателей, которые стоят машинного времени, становится больше, а
живут они кто где: прилипание и схлопывание — кнопками внизу, ключ в
предпросмотре — в меню «Вид», выбор кодировщика вообще нигде (берётся
NVENC, если он есть). Это разрозненно и не даёт увидеть общую цену.
Нужно одно окно, где собрано всё, что можно выключить ради скорости:
- ключ и прозрачность в предпросмотре (сейчас в меню «Вид»);
- наложение в предпросмотре вообще — на слабой машине может не хватать и
его, тогда честнее показывать одну основу;
- кодировщик для пересборки: аппаратный или процессорный, и какой
именно;
- где держать кэш и когда его убирать;
- прилипание клипов и курсора (сейчас кнопками, но им место и здесь).
Почему это важно: разработка идёт на машине с быстрой видеокартой, и
всё, что здесь считается «недорогим», на другой может оказаться
неподъёмным. Пока цифры замеряны только на одной машине, у человека
должна быть возможность выключить любую из этих трат.
## Мелочи, до которых не дошли руки
**Вид дорожки не виден в её заголовке.** Дорожки картинки, звука и текста
подписаны V1, A1, Т1, и по букве вид угадывается, но не более того.
Клип, попавший на дорожку чужого вида, теперь не принимается и не
теряется, но само различие стоило бы показать явно — значком или цветом
полосы заголовка.
**Значок файла программы под Windows.** Значок окна задаётся из
`uIconData`, а значок самого `.exe` в проводнике берётся из ресурса при
сборке — там он до сих пор стандартный.
**Кэш наложений не убирается.** Каждая правка наплыва создаёт в кэше
проекта новый файл; старые остаются лежать. Клипы с ключом и
прозрачностью сюда больше не попадают — их показывает сам плеер, — так
что расти кэшу теперь почти не с чего.
**Окно кадрирования открывается около четырёх секунд.** Оно синхронно
достаёт два кадра ffmpeg-ом (по 2.2 с каждый на шестигигабайтном файле) и
только потом показывается. Лечится показом окна сразу и дорисовкой кадра
по готовности, либо одним кадром вместо двух.

239
README.md
View file

@ -46,6 +46,30 @@ Lazarus другой версии, и собранные им модули LCL
`./nlve remux <файл>...` — в коде этого нет и не было: слово `remux` не
является именем файла и молча пропускалось.
### Встраивание в рабочий стол
```
tools/install-desktop.sh # берёт ./nlve рядом с репозиторием
tools/install-desktop.sh /путь/к/nlve
```
Скрипт кладёт пункт меню, значки и описание типа файла — всё в домашний
каталог, без прав администратора. После этого `*.nlve` перестают быть
просто текстом: у них своё имя типа (`application/x-nlve-project`), свой
значок и открытие двойным щелчком. Проверяется так:
```
xdg-mime query filetype проект.nlve # application/x-nlve-project
xdg-mime query default application/x-nlve-project
```
Внутри проект — обычный JSON, и тип объявлен наследником
`application/json`. Наследником простого текста делать не стали: тогда
файл снова открывался бы редактором по умолчанию.
Файловый менеджер может показать новый значок не сразу — ему нужно
перечитать базу.
## Управление
| Действие | Клавиши |
@ -62,9 +86,11 @@ Lazarus другой версии, и собранные им модули LCL
| Прокрутка по дорожкам | колесо мыши |
| Прокрутка по времени | Shift + колесо, средняя кнопка |
| Прокрутка журнала вбок | Shift + колесо над журналом |
| Растянуть форму звука по высоте | Shift + стрелки вверх и вниз |
| Выключить дорожку / оставить только её | кнопки М и С в заголовке |
| Громкость клипа | тянуть ручку на клипе вверх-вниз |
| Автосхлопывание вкл/выкл | Ctrl+K |
| Связать или развязать клипы | Ctrl+G |
| Отменить прилипание на время | Shift при перетаскивании |
| Вписать проект целиком | Ctrl+0 |
| Новый, открыть, сохранить проект | Ctrl+N, Ctrl+O, Ctrl+S |
@ -99,11 +125,61 @@ Lazarus другой версии, и собранные им модули LCL
если кнопку нажали и потянули, курсор остаётся на месте, в том числе во
время воспроизведения.
**Прилипание.** Края клипов притягиваются друг к другу и к курсору.
Кнопка внизу включает и выключает его, а Shift во время перетаскивания
меняет решение на противоположное: при включённом прилипании временно
отключает, при выключенном — временно включает. Так одна клавиша полезна
в обоих режимах, и не нужно помнить, какой сейчас.
**Прилипание клипов** (кнопка с магнитом). Края клипов притягиваются
друг к другу и к курсору. Shift во время перетаскивания меняет решение на
противоположное: при включённом прилипании временно отключает, при
выключенном — временно включает. Так одна клавиша полезна в обоих
режимах, и не нужно помнить, какой сейчас.
**Прилипание курсора** (кнопка с клеем). Курсор, поставленный щелчком,
притягивается к границам клипов на любой дорожке. Без этого в стык не
попасть: на общем плане одна точка экрана — это секунды материала, а
ставят курсор чаще всего именно на стык. Shift при щелчке работает так
же — меняет решение на противоположное.
Переключателя два, а не один, потому что это разные привычки: клипы
липнут при перетаскивании, курсор — при щелчке, и одно бывает нужно без
другого.
**Ключ и прозрачность в предпросмотре** (меню «Вид»). Включено — окно
показывает то же, что даст сборка: выбитый цвет и приглушение считаются
на лету. Выключено — оверлей виден целиком, со своим фоном: так понятно,
куда он встал и где его края.
Считается это по площади самого оверлея, а не всего кадра. Замер на
2688x1512 (600 кадров, десять секунд материала): основа 2.07 с, с
наложением 2.43 с, с ключом и прозрачностью 2.66 с. То есть наложение
стоит 0.36 с, ключ добавляет ещё 0.23 с — вчетверо быстрее реального
времени. На слабой машине это может быть заметнее, потому выключатель и
есть.
**Отмеченный участок.** У линейки два этажа. Верхний, в двенадцать точек,
— про отметки: протяжка по нему отмечает участок, даже если отметок не
было, а щелчок без движения их снимает. По краям участка стоят жёлтые
уголки, за них тянут. Нижний этаж — про курсор, как и раньше.
Клавиши тоже остались: `I` ставит начало по курсору, `O` — конец; оба
пункта есть в меню «Правка», там же снятие отметок. Пока он отмечен, сборка
собирает только его — это единственный способ проверить правку на длинном
монтаже, не тратя на каждую проверку столько же времени, сколько идёт сам
ролик. Отметки живут в файле проекта: участок относится к монтажу, а не к
привычкам человека.
**Дорожки.** Слева в заголовке две стрелки: поднять и опустить. У верхней
дорожки некуда вверх, у нижней вниз — такая стрелка нарисована тускло и
не нажимается. Заголовок можно и просто потащить мышью. Двойной щелчок по
названию переименовывает дорожку: на монтаже из десятка дорожек «карта»,
«звук мотора» и «титры» читаются несравнимо лучше, чем V1, A2 и Т1.
Правый щелчок по заголовку открывает меню: поднять, опустить,
переименовать, удалить. Удаление дорожки с клипами спрашивает
подтверждение и называет число клипов.
**Копирование свойств клипа.** Ctrl+Shift+C берёт с выделенного клипа всё
оформление — кадрирование, поворот, увеличение, смещение, прозрачность,
цветовой ключ, затухания, громкость, — Ctrl+Shift+V применяет их ко всем
выделенным. Вид клипа и его текст при этом не переносятся: свойства
копируют между разными клипами, и превращать картинку в надпись незачем.
**Схлопывание.** Кнопка внизу показывает и режим, и состояние: утоплена —
автосхлопывание включено, и тогда при перетаскивании клипа всё, что
@ -137,6 +213,20 @@ Ctrl+Shift+R, который схлопывает дыру под курсоро
В списке файлов слева показывается кадр из каждого файла, его параметры
и длительность. Кадры делаются в фоне и запоминаются в кэше проекта.
На клипах видна форма звука. Считается она по заранее разобранным пикам —
сто значений в секунду, каждое есть наибольший модуль отсчёта за свои
десять миллисекунд. Рисовать по настоящим отсчётам нельзя: в секунде их
48 тысяч, а в клипе на экране сотни пикселей.
Разбор идёт в фоне и запоминается в кэше проекта. Для записи на 892 с это
около 13 секунд — столько занимает чтение шести гигабайт с диска, и
быстрее не выйдет; при следующем открытии проекта пики берутся готовыми.
**Shift со стрелками вверх и вниз растягивает форму по высоте.** Звук
бывает тихим, и угадывать за человека, во сколько раз растянуть картинку,
неправильно. Усиление меняет только рисунок, самого звука не касается, и
запоминается между запусками.
## Файл не совпадает с проектом
Проект имеет одно разрешение и один набор кодеков на всё — иначе куски не
@ -146,11 +236,23 @@ Ctrl+Shift+R, который схлопывает дыру под курсоро
Пересобираются **только они**. Остальной материал по-прежнему копируется
без потерь и почти мгновенно. Ролик целиком не перекодируется никогда.
Как именно кадр такого файла ложится в кадр проекта, решает окно
«Кадрирование и поворот» из контекстного меню библиотеки: вписать целиком
с полями, заполнить кадр с обрезкой или растянуть, плюс поворот на 90,
отражение, увеличение и смещение. Показывается настоящий кадр, пропущенный
через ту же цепочку фильтров, что пойдёт в дело.
Как именно кадр ложится в кадр проекта, решает окно «Кадрирование и
поворот»: вписать целиком с полями, заполнить кадр с обрезкой или
растянуть, плюс поворот на 90, отражение, увеличение и смещение.
Слева в окне — кадр источника с рамкой: её тянут мышью за середину и за
правый нижний угол, и она держит пропорции кадра проекта. Справа —
настоящий результат, пропущенный через ту же цепочку фильтров, что пойдёт
в дело.
**Настройка принадлежит клипу**, а открывается кнопкой в правом нижнем
видимом углу самого клипа. Именно видимом: у клипа длиной в час настоящий
угол почти всегда за краем экрана. Два куска одного файла можно
кадрировать по-разному.
У файла в библиотеке хранится такая же настройка, но она лишь заготовка:
её берёт новый клип при добавлении. Правится она из контекстного меню
библиотеки.
По умолчанию кадр вписывается целиком. Растяжение надо выбрать руками:
раньше оно применялось молча, и вертикальное видео размазывалось по ширине.
@ -258,6 +360,98 @@ Linux потом искала по нему `.dll`.
случая, когда программа падает до появления окна: иначе всё, что она о
себе рассказала, пропадает вместе с окном.
## Наложение, переходы и прозрачность
Ползунок на клипе картинки задаёт **непрозрачность**, на звуковом —
громкость. Клип с непрозрачностью меньше единицы смешивается с тем, что
под ним.
**Звуковые дорожки смешиваются.** Правило «верхний побеждает» придумано
для картинки — оно и делает возможной сборку копированием. Для звука оно
бессмысленно: без смешивания нельзя положить фоновую музыку под голос. И
стоит оно ничего, потому что звук всё равно перекодируется.
Плеер играет одну звуковую дорожку за раз, поэтому в предпросмотре смесь
собирается его же цепочкой фильтров: каждая дорожка монтажа подаётся
своим списком кусков, дальше `amix`. Громкость клипов подставляется в ту
же цепочку — иначе предпросмотр врал бы про баланс. Проверено: в
предпросмотре и в готовом ролике уровни совпадают до десятой доли
децибела.
Два клипа **на одной дорожке**, наползшие друг на друга, дают переход:
один плавно перетекает в другой. На разных дорожках перекрытие означает
не переход, а наложение — там по-прежнему побеждает верхний, если он
непрозрачен.
Различие не косметическое. Если бы кроссфейдом считалось любое
перекрытие, то в проекте, где клипы на разных дорожках перекрываются
постоянно, каждое такое место пришлось бы пересобирать.
У клипа есть плавное появление и уход. Для картинки это затемнение, для
звука — нарастание громкости.
**Всё это стоит перекодирования** — но только своего участка. Перед
сборкой программа сообщает, сколько секунд из скольких придётся
пересобрать, и даёт отказаться.
Звук при наложении сводится прямо в отсчётах, без фильтров ffmpeg: доля
каждого слоя считается на каждый отсчёт, и длина результата получается
ровно такой, какая нужна.
## Качество пересобираемых кусков
Куски, которые нельзя скопировать, целятся в наибольший битрейт среди
файлов проекта. Без этого кодировщик берёт своё значение по умолчанию, и
оно скромное: на съёмке 2688x1512 с 56 Мбит/с NVENC выдавал 5.9 Мбит/с —
пересобранный кусок бросался в глаза среди скопированных соседей.
Кроме среднего задаётся потолок в полтора раза выше и буфер вдвое: без
них кодировщик разгоняется на сложных кадрах ровно настолько, насколько
позволит средний, и мелкие детали всё равно теряются.
Берётся именно наибольший, а не средний: пересобранный кусок стоит рядом
со скопированными, и если он окажется хуже их, разница видна именно на
нём. Лишнее место дешевле.
## Место на диске
Куски весят примерно столько же, сколько готовый ролик, а на длинном
материале и больше: на часовой съёмке набралось 110 ГБ. Поэтому:
- **картинка не собирается в промежуточный файл.** Звук собирается
раньше, и склейка сразу кладёт всё вместе в конечный файл. Раньше
получалось два полных ролика на диске одновременно — 91 ГБ в кэше и 91
ГБ рядом — и лишний проход копирования ради одной звуковой дорожки;
- **после удачной сборки куски удаляются,** и в журнале сказано, сколько
места освободилось. После неудачной — остаются: с них продолжают.
Оставить их можно настройкой `keep_parts` в файле настроек: пригодится,
когда разбираешься, из чего собрался ролик.
## Прерванная сборка
Каждый доведённый до конца кусок помечается в списке рядом с самими
кусками (`готово.txt` в кэше). Если сборка не дошла до конца — упала,
была отменена или программу закрыли, — при следующей сборке в тот же файл
программа предложит продолжить с готовых кусков.
Проверяется не только наличие файла: сборку могли прервать посреди
записи, и файл оказался бы обрезанным. Поэтому куском считается только
тот, который успел попасть в список.
Первая строка списка — отпечаток плана: пути, места и длины всех кусков.
Не совпал — значит монтаж с тех пор менялся, и готовые куски не подойдут;
программа скажет об этом и соберёт заново.
## Сколько осталось
Окно сборки показывает, сколько прошло и сколько примерно осталось.
Оценка простая: если четверть пути заняла минуту, весь путь займёт
четыре. На деле скорость меняется — копируемые куски идут мгновенно,
пересобираемые медленно, — поэтому число называется «примерно» и
пересчитывается на каждом шаге. Даже такая оценка отвечает на главный
вопрос: успею ли я сходить за чаем.
## Как собирается ролик
1. Монтаж уплощается в последовательность непересекающихся кусков.
@ -367,6 +561,30 @@ mpv сам выбрал `nvdec-copy`, всё работало, и ошибки
«Invalid floating point operation», и предпросмотр не запускался. Лечится
`SetExceptionMask` после загрузки библиотеки.
**Цепочку смешивания надо снимать до загрузки.** Она ссылается на дорожки
по номерам, и оставшаяся от прошлого раза не даёт открыть новый файл
вовсе: проверено на живом mpv — после такой загрузки дорожек ноль, кадра
нет, не играет ничего. Выглядит это как чёрное окно и полная тишина, и
на плеер не похоже совсем.
Снимать её после загрузки поздно: ломается сама загрузка.
**Внешняя звуковая дорожка не подключается свойством.** Задать
`audio-files` перед загрузкой недостаточно: список дорожек после
загрузки остаётся прежним, внешний файл не подключается вовсе, и играет
звук, встроенный в саму картинку. Проверено на живом mpv через его канал
управления: после установки свойства дорожек по-прежнему две, обе не
внешние.
Из-за этого звук был слышен там, где на монтаже его нет. Попытка
защититься, отключая звук при ненайденной внешней дорожке, сделала
хуже — звука не стало нигде.
Правильный способ — команда `audio-add` с признаком `select` после
загрузки: она добавляет дорожку, выбирает её и снимает выбор с
внутренней. Проверено: дорожек становится четыре, внутренняя звуковая
помечается невыбранной.
**Аргументы loadfile.** В mpv 0.38 у команды появился четвёртый аргумент —
номер позиции в списке, и настройки файла сдвинулись на пятое место.
Прежний вызов стал ошибкой «The loadfile option must be an integer», и под
@ -433,6 +651,7 @@ Wine с новой библиотекой не грузилось ничего.
| `src/uPanCropForm.pas` | Окно кадрирования и поворота |
| `src/uProjectInfoForm.pas` | Сведения о проекте и ответ ffprobe |
| `src/uThumbs.pas` | Кадры-миниатюры для списка файлов |
| `src/uWaveform.pas` | Пики звука для формы волны на клипах |
| `src/uTheme.pas` | Цвета окна, своя кнопка, своё окно сообщения |
| `src/uLogView.pas` | Журнал: своя отрисовка, свои полосы прокрутки |
| `src/uThemedScroll.pas` | Полоса прокрутки и обвязка для таблиц |

View file

@ -6,6 +6,7 @@
# ./build.sh current то же самое
# ./build.sh windows редактор под Windows (кросс-сборка)
# ./build.sh tool консольный инструмент проверок
# ./build.sh macos две сборки под macOS: nlve_intel и nlve_arm
# ./build.sh test инструмент плюс прогон проверок
# ./build.sh all всё перечисленное
# ./build.sh run собрать под эту систему и запустить
@ -22,6 +23,8 @@ HERE=$(cd "$(dirname "$0")" && pwd)
FPCUP=${FPCUP:-/home/chaiman/fpcupdeluxe}
LAZBUILD="$FPCUP/lazarus/lazbuild"
LAZCONF="$FPCUP/config_lazarus"
# Компилятор нужен напрямую для консольных проверок без формы.
FPC="$FPCUP/fpc/bin/x86_64-linux/fpc.sh"
# Собранный виджетсет только один — gtk2. Ни qt5, ни gtk3 в этом
# тулчейне нет, подставлять их бессмысленно.
@ -99,6 +102,48 @@ build_windows() {
rm -rf "$HERE/lib/x86_64-win64"
}
build_macos() {
check_toolchain
# Сборка под macOS кладёт файл туда же, куда и сборка под Linux, и
# затирает её: имя цели в проекте одно на все системы. Поэтому каждый
# файл переименовывается сразу, а линуксовая сборка в конце делается
# заново.
if run_lazbuild "Редактор под macOS (Intel)" \
--cpu=x86_64 --os=darwin --ws=cocoa "$HERE/nlve.lpi"; then
mv -f "$HERE/nlve" "$HERE/nlve_intel"
echo "готово: $HERE/nlve_intel"
file "$HERE/nlve_intel" 2>/dev/null | cut -d: -f2- | sed 's/^/ /'
else
echo "не собралось" >&2
status=1
fi
if run_lazbuild "Редактор под macOS (Apple Silicon)" \
--cpu=aarch64 --os=darwin --ws=cocoa "$HERE/nlve.lpi"; then
mv -f "$HERE/nlve" "$HERE/nlve_arm"
echo "готово: $HERE/nlve_arm"
file "$HERE/nlve_arm" 2>/dev/null | cut -d: -f2- | sed 's/^/ /'
else
echo "не собралось" >&2
status=1
fi
# Обёртку .app делает lazbuild, и внутри неё ссылка на файл с именем
# nlve, которого после переименования уже нет. Обманчивая пустышка не
# нужна.
rm -rf "$HERE/nlve.app"
# Модули под darwin лежат отдельно, но сборку под Linux мы затёрли —
# возвращаем её на место.
note "Возвращаю сборку под эту систему"
build_current
echo
echo "ВНИМАНИЕ: под macOS предпросмотра не будет — встраивание mpv"
echo "написано под X11 и Windows. Подробности в BACKLOG.md."
}
build_tool() {
note "Консольный инструмент проверок"
if "$HERE/tests/build.sh"; then
@ -120,6 +165,42 @@ run_tests() {
echo "проверки не пройдены" >&2
status=1
fi
# Свойства, записанные в файлах форм, проверяются по сведениям о типах.
# Файл формы разбирается при запуске, а не при сборке: опечатка в имени
# свойства даёт не ошибку компиляции, а падение окна на старте.
note "Проверка свойств из файлов форм"
if "$LAZBUILD" --primary-config-path="$LAZCONF" \
"$HERE/tests/lfmcheck/lfmcheck.lpi" >/dev/null 2>&1; then
if "$HERE/tests/lfmcheck/lfmcheck"; then
:
else
status=1
fi
# Окно зависимостей при создании записывает рядом с собой список
# пакетов по умолчанию. Проверке это не мешает, а в дереве исходников
# такому файлу не место.
rm -f "$HERE/tests/lfmcheck/deps-linux.json"
else
echo "не собралось" >&2
status=1
fi
# Ход полоски выполнения проверяется отдельной программой: расчёт вынесен
# в uAppTypes именно затем, чтобы его можно было прогнать числами.
note "Проверка хода полоски выполнения"
if "$FPC" -Fu"$HERE/src" -FE"$HERE/tests/progress" \
-o"$HERE/tests/progress/progtest" \
"$HERE/tests/progress/progtest.lpr" >/dev/null 2>&1; then
if "$HERE/tests/progress/progtest"; then
:
else
status=1
fi
else
echo "не собралось" >&2
status=1
fi
}
do_clean() {
@ -137,6 +218,9 @@ case "${1:-current}" in
windows|win|win64)
build_windows
;;
macos|mac|darwin)
build_macos
;;
tool)
build_tool
;;
@ -163,7 +247,7 @@ case "${1:-current}" in
;;
*)
fail "неизвестная цель: $1
Доступны: current, windows, tool, test, all, run, clean"
Доступны: current, windows, macos, tool, test, all, run, clean"
;;
esac

View file

@ -0,0 +1,5 @@
Log panel: <a target="_blank" href="https://icons8.com/icon/79872/property">Property</a> icon by <a target="_blank" href="https://icons8.com">Icons8</a>
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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 463 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 793 B

View file

@ -18,6 +18,9 @@ begin
Application.Title := 'nlve';
Application.Scaled := True;
Application.Initialize;
{ Цвета меню задаются до создания окон: под gtk2 это строка стиля, а её
разбирают один раз — уже созданные виджеты о ней не узнают. }
ApplyMenuStyle;
{ Подписка на появление окон: своим окнам цвета задаются в их же
обработчике создания, а это подстраховка на случай окна, созданного
самой библиотекой. }

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Тип файла проекта nlve.
Зачем. Внутри проект — обычный JSON, и без этого описания система
считает его просто текстом: открывает редактором и рисует значок
текстового файла. Здесь у него появляется своё имя, свой значок и своя
программа по двойному щелчку.
Тип наследуется от JSON, но не от простого текста: показывать его как
текст незачем, а вот средствам, умеющим JSON, знать о родстве полезно.
-->
<mime-info xmlns="http://www.freedesktop.org/standards/shared-mime-info">
<mime-type type="application/x-nlve-project">
<comment>nlve project</comment>
<comment xml:lang="ru">Проект nlve</comment>
<sub-class-of type="application/json"/>
<glob pattern="*.nlve" weight="80"/>
<icon name="application-x-nlve-project"/>
<generic-icon name="video-x-generic"/>
</mime-type>
</mime-info>

17
packaging/nlve.desktop.in Normal file
View file

@ -0,0 +1,17 @@
[Desktop Entry]
Type=Application
Version=1.0
Name=nlve
GenericName=Video editor
GenericName[ru]=Видеоредактор
Comment=Non-linear video editing without re-encoding
Comment[ru]=Монтаж видео без перекодирования
Exec=@BIN@ %f
Path=@DIR@
Icon=nlve
Terminal=false
StartupNotify=true
Categories=AudioVideo;Video;AudioVideoEditing;
MimeType=application/x-nlve-project;video/mp4;video/x-matroska;
Keywords=video;editor;montage;
Keywords[ru]=видео;монтаж;редактор;

BIN
packaging/nlve.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

235
src/uAboutForm.pas Normal file
View file

@ -0,0 +1,235 @@
{
uAboutForm — окно «О программе».
Две вкладки. «Ресурсы» — на чём программа держится и чьи значки
использует: бесплатная лицензия Icons8 требует ссылку на каждый значок.
«Возможности» — что программа умеет.
Вкладки свои, а не TPageControl: системные под Windows остались бы
светлой полосой поверх тёмного окна.
Текст показывает uArticleView: он сам переносит строки по ширине окна и
делает ссылки нажимаемыми.
}
unit uAboutForm;
{$mode objfpc}{$H+}
interface
uses
Classes, SysUtils, Controls, Forms, Graphics, StdCtrls, Dialogs,
uTheme, uArticleView;
procedure ShowAboutForm;
implementation
type
{ Обработчики живут в объекте: событиям нужны методы. }
TAboutDialog = class(TComponent)
public
Form: TForm;
BtnResources: TThemedButton;
BtnFeatures: TThemedButton;
ViewResources: TArticleView;
ViewFeatures: TArticleView;
procedure ShowResources(Sender: TObject);
procedure ShowFeatures(Sender: TObject);
procedure Select(AResources: Boolean);
end;
procedure TAboutDialog.Select(AResources: Boolean);
begin
BtnResources.Down := AResources;
BtnFeatures.Down := not AResources;
ViewResources.Visible := AResources;
ViewFeatures.Visible := not AResources;
end;
procedure TAboutDialog.ShowResources(Sender: TObject);
begin
Select(True);
end;
procedure TAboutDialog.ShowFeatures(Sender: TObject);
begin
Select(False);
end;
{ Списки под Windows и под Linux разные: там сборки скачивает сама
программа, здесь их ставит пакетный менеджер. }
function ResourcesText: string;
begin
Result :=
'# nlve' + LineEnding +
'Монтаж без перекодирования: куски вырезаются и склеиваются ' +
'копированием потока.' + LineEnding +
'Автор: 4aiman' + LineEnding +
LineEnding +
'# Значки' + LineEnding +
'Icons8. Нужна ссылка на каждый значок.' + LineEnding +
'Прилипание — Magnet' + LineEnding +
' https://icons8.com/icon/79942/magnet' + LineEnding +
'Схлопывание — Merge Vertical' + LineEnding +
' https://icons8.com/icon/2EQegp8tbR6Q/merge-vertical' + LineEnding +
'Журнал — Property' + LineEnding +
' https://icons8.com/icon/79872/property' + LineEnding +
'Прилипание курсора — Клей' + LineEnding +
' https://icons8.com/icon/111296/glue' + LineEnding +
'Значок программы — Showing Video Frames' + LineEnding +
' https://icons8.com/icon/69093/showing-video-frames' + LineEnding +
'Сам Icons8' + LineEnding +
' https://icons8.com' + LineEnding +
LineEnding +
'# Видео и звук' + LineEnding +
{$IFDEF WINDOWS}
'ffmpeg — нарезка, склейка, перекодирование. Сборка BtbN, GPL.' +
LineEnding +
' https://github.com/BtbN/FFmpeg-Builds' + LineEnding +
' https://ffmpeg.org' + LineEnding +
'libmpv — предпросмотр. Сборка shinchiro, GPL.' + LineEnding +
' https://github.com/shinchiro/mpv-winbuild-cmake' + LineEnding +
' https://mpv.io' + LineEnding +
'7-Zip — распаковка сборок libmpv. LGPL-2.1.' + LineEnding +
' https://www.7-zip.org' + LineEnding +
{$ELSE}
'ffmpeg — нарезка, склейка, перекодирование. GPL-2 и новее.' +
LineEnding +
' sudo apt install ffmpeg' + LineEnding +
' https://ffmpeg.org' + LineEnding +
'libmpv — предпросмотр. GPL-3 и новее.' + LineEnding +
' sudo apt install mpv' + LineEnding +
' https://mpv.io' + LineEnding +
{$ENDIF}
LineEnding +
'# Инструменты' + LineEnding +
'Free Pascal' + LineEnding +
' https://www.freepascal.org' + LineEnding +
'Lazarus LCL' + LineEnding +
' https://www.lazarus-ide.org' + LineEnding +
LineEnding +
'ffmpeg и libmpv в состав программы не входят: ffmpeg запускается ' +
'отдельной программой, libmpv ставится отдельно.';
end;
function FeaturesText: string;
begin
Result :=
'# Монтаж' + LineEnding +
'Дорожки для картинки и для звука.' + LineEnding +
'При перекрытии картинку даёт верхняя дорожка, звук всех дорожек ' +
'смешивается.' + LineEnding +
'Перенос, растягивание за края, рез по курсору (S), удаление.' +
LineEnding +
'Прилипание к соседям и к курсору. Shift отменяет его.' + LineEnding +
'Связки: видео и его звук ходят вместе. Ctrl+G связывает и ' +
'развязывает.' + LineEnding +
'Схлопывание дыр: Ctrl+Shift+R или само при перетаскивании.' +
LineEnding +
'Отмена и повтор: Ctrl+Z, Ctrl+Y.' + LineEnding +
LineEnding +
'# На дорожках' + LineEnding +
'Форма звука. Shift со стрелками усиливает её на тихих записях.' +
LineEnding +
'Громкость и прозрачность — ручкой на клипе.' + LineEnding +
'Появление и уход — за верхние углы клипа.' + LineEnding +
'Перекрытие на одной дорожке помечено крестом: там переход.' +
LineEnding +
'Цвет краёв показывает, потребует ли рез перекодирования.' + LineEnding +
LineEnding +
'# Предпросмотр' + LineEnding +
'Играет монтаж целиком, не собирая файлов.' + LineEnding +
'Показывает кадр проекта, а не кадр исходника.' + LineEnding +
'Прозрачность и переходы видны.' + LineEnding +
LineEnding +
'# Кадрирование' + LineEnding +
'Вписать, заполнить или растянуть. Увеличение, смещение, поворот, ' +
'отражение.' + LineEnding +
'Рамку можно тянуть мышью.' + LineEnding +
'У каждого клипа своё.' + LineEnding +
LineEnding +
'# Сборка' + LineEnding +
'Обычная — копированием потоков, за время копирования файла.' +
LineEnding +
'Заранее сказано, сколько секунд придётся пересобрать и почему.' +
LineEnding +
'Точный рез перекодирует только огрызок до опорного кадра.' +
LineEnding +
'С перекодированием: от 720p до 4K, свой поток, HEVC или H.264, ' +
'на видеокарте.' + LineEnding +
'Перепаковка в другой контейнер без пересчёта.' + LineEnding +
LineEnding +
'# Прочее' + LineEnding +
'Индекс опорных кадров читается из заголовка: 20 мс вместо 12 секунд.' +
LineEnding +
'Фоновая работа видна полоской внизу и штриховкой на монтаже.' +
LineEnding +
'Свои цвета во всех окнах.' + LineEnding +
'Настройки и недавние проекты переживают перезапуск.';
end;
procedure ShowAboutForm;
var
Dlg: TAboutDialog;
BtnClose: TThemedButton;
begin
Dlg := TAboutDialog.Create(nil);
try
Dlg.Form := TForm.CreateNew(nil);
Dlg.Form.Caption := 'О программе';
Dlg.Form.Position := poScreenCenter;
Dlg.Form.ClientWidth := 720;
Dlg.Form.ClientHeight := 560;
Dlg.Form.Constraints.MinWidth := 420;
Dlg.Form.Constraints.MinHeight := 300;
Dlg.BtnResources := TThemedButton.Create(Dlg.Form);
Dlg.BtnResources.Parent := Dlg.Form;
Dlg.BtnResources.SetBounds(12, 12, 140, 28);
Dlg.BtnResources.Caption := 'Ресурсы';
Dlg.BtnResources.TabStop := False;
Dlg.BtnResources.OnClick := @Dlg.ShowResources;
Dlg.BtnFeatures := TThemedButton.Create(Dlg.Form);
Dlg.BtnFeatures.Parent := Dlg.Form;
Dlg.BtnFeatures.SetBounds(158, 12, 140, 28);
Dlg.BtnFeatures.Caption := 'Возможности';
Dlg.BtnFeatures.TabStop := False;
Dlg.BtnFeatures.OnClick := @Dlg.ShowFeatures;
Dlg.ViewResources := TArticleView.Create(Dlg.Form);
Dlg.ViewResources.Parent := Dlg.Form;
Dlg.ViewResources.SetBounds(12, 48, 696, 460);
Dlg.ViewResources.Anchors := [akTop, akLeft, akRight, akBottom];
Dlg.ViewFeatures := TArticleView.Create(Dlg.Form);
Dlg.ViewFeatures.Parent := Dlg.Form;
Dlg.ViewFeatures.SetBounds(12, 48, 696, 460);
Dlg.ViewFeatures.Anchors := [akTop, akLeft, akRight, akBottom];
BtnClose := TThemedButton.Create(Dlg.Form);
BtnClose.Parent := Dlg.Form;
BtnClose.SetBounds(608, 520, 100, 28);
BtnClose.Anchors := [akRight, akBottom];
BtnClose.Caption := 'Закрыть';
BtnClose.ModalResult := mrOk;
BtnClose.Default := True;
BtnClose.Cancel := True;
ApplyDarkTheme(Dlg.Form);
{ Текст задаётся после раскраски: раскладка считается по тому шрифту,
которым он будет нарисован. }
Dlg.ViewResources.SetText(ResourcesText);
Dlg.ViewFeatures.SetText(FeaturesText);
Dlg.Select(True);
Dlg.Form.ShowModal;
finally
Dlg.Form.Free;
Dlg.Free;
end;
end;
end.

View file

@ -11,9 +11,18 @@ unit uAppTypes;
interface
uses
SysUtils;
SysUtils
{$IFDEF UNIX}, BaseUnix{$ENDIF};
type
{ Цвет в записи, принятой в LCL: $00BBGGRR.
Свой тип, а не TColor из Graphics: модель не должна тянуть за собой
оконную библиотеку. Стоило один раз потянуть — и консольная проверка
перестала собираться. Значения совпадают, окно приводит одно к
другому без пересчёта. }
TRgb = LongInt;
{ Чем заполнен сегмент готового ряда.
skVideo — кусок настоящего исходного файла, копируется без перекодирования.
@ -26,6 +35,67 @@ type
показа. Позиция на таймлайне здесь не хранится намеренно — она равна
сумме длительностей предыдущих сегментов, дублировать её значит
завести источник рассогласования. }
{ Один слой наложения.
Появляется там, где кусок нельзя описать одним источником: клип
полупрозрачен и сквозь него видно нижний, либо два клипа на одной
дорожке наползли друг на друга и должны перетечь один в другой.
Слои перечислены снизу вверх. }
TSegmentLayer = record
FilePath: string;
SrcStart: Double;
{ Цепочка кадрирования этого слоя: с полями до кадра проекта. Годится
там, где слой сам по себе занимает весь кадр. }
Filter: string;
{ Та же цепочка без полей и место, куда слой встаёт в кадре.
Верхний слой полями обкладывать нельзя: поле чёрное и непрозрачное,
и уменьшенный клип закрывал бы им всё нижнее видео вместо того,
чтобы лечь в свой угол. }
FitFilter: string;
X: Integer;
Y: Integer;
{ Постоянная непрозрачность слоя. }
Opacity: Double;
{ Слой — неподвижная картинка, а не видео.
Её надо повторять: одиночный файл даёт ffmpeg ровно один кадр, и
кусок с наложением схлопывался в одну шестидесятую секунды —
фотография из ролика просто пропадала. }
IsImage: Boolean;
{ Цветовой ключ слоя: этот цвет становится прозрачным. Ключ выключен,
если KeyEnabled равно False. }
KeyEnabled: Boolean;
KeyColor: TRgb;
KeySimilarity: Integer;
KeyBlend: Integer;
{ Переход: непрозрачность меняется от начала куска к концу. Если оба
значения равны Opacity, перехода нет. }
FadeFrom: Double;
FadeTo: Double;
end;
TSegmentLayers = array of TSegmentLayer;
{ Окно наложения для плеера: когда, куда и каким фильтром класть
оверлей поверх основы.
Живёт в базовых типах, а не в уплощении, потому что плеер про
уплощение знать не должен: ему отдают готовое описание. }
TOverlayWindow = record
TlStart: Double;
TlEnd: Double;
{ Цепочка фильтров для самого оверлея: размер, поворот. Без полей —
поля закрыли бы нижнее видео. }
Filter: string;
X: Integer;
Y: Integer;
Width: Integer;
Height: Integer;
end;
TOverlayWindows = array of TOverlayWindow;
TFlatSegment = record
FilePath: string;
SrcStart: Double;
@ -53,6 +123,16 @@ type
{ Готовая цепочка фильтров ffmpeg: как кадр этого исходника ложится в
кадр проекта. Считается один раз при уплощении. }
Filter: string;
{ Разметка субтитров для этого куска: времена уже сдвинуты к его
началу. Пусто — текста здесь нет. Заполняется только при сборке с
вжиганием. }
SubFile: string;
{ Слои наложения. Пусто — обычный кусок с одним источником, который
можно скопировать. Непусто — участок придётся собрать заново.
Ради этого и заведено: наложение и переходы стоят перекодирования,
но только своего участка, а не всего ролика. }
Layers: TSegmentLayers;
end;
TFlatSegmentArray = array of TFlatSegment;
@ -77,6 +157,30 @@ function TotalDuration(const Segments: TFlatSegmentArray): Double;
«куска», для 5 — «кусков». Без этого в интерфейсе появляется «1 кусков». }
function Plural(ACount: Integer; const AOne, AFew, AMany: string): string;
{ Как двигается показанная доля выполнения.
Настоящая доля известна только вехами: файл разобран, вставка готова.
Между вехами не происходит ничего видимого, и полоска, стоящая на месте,
выглядит как зависшая программа.
Поэтому между вехами она ползёт сама — к следующей вехе, но никогда её
не достигая: каждый шаг покрывает малую долю оставшегося расстояния.
Пока работа идёт как задумано, веха приходит быстро и полоска прыгает к
ней. Затянулась — шаги мельчают, и заполнение само собой замедляется.
Назад полоска не идёт никогда. }
function AdvanceProgress(AShown, AReal, ANext, AStepShare: Double): Double;
{ Каталог, в котором лежит сама программа.
Не ExtractFilePath(ParamStr(0)): если программу запустили через
символьную ссылку — а так она и попадает в PATH и в меню, — то путь
укажет на ссылку, и рядом с ней окажутся и журнал, и файл зависимостей,
и поиск ffmpeg. Ссылки раскрываются, пока не кончатся.
Под Windows ссылок такого рода нет, и путь берётся как есть. }
function ProgramDir: string;
implementation
var
@ -127,4 +231,44 @@ begin
Result := AMany;
end;
function AdvanceProgress(AShown, AReal, ANext, AStepShare: Double): Double;
begin
Result := AShown;
if AReal > Result then
Result := AReal;
if ANext > Result then
Result := Result + (ANext - Result) * AStepShare;
{ До самого конца полоска не доходит: конец наступает тогда, когда
работа кончилась, а не когда мы досчитали. }
if Result > 0.999 then
Result := 0.999;
end;
function ProgramDir: string;
var
Path, Target: string;
{$IFDEF UNIX}
Guard: Integer;
{$ENDIF}
begin
Path := ExpandFileName(ParamStr(0));
{$IFDEF UNIX}
{ Ограничение на число переходов — от закольцованных ссылок. }
Guard := 0;
while Guard < 16 do
begin
Target := fpReadLink(Path);
if Target = '' then
Break;
{ Ссылка может указывать и относительным путём — считаем его от
каталога самой ссылки. }
if (Length(Target) > 0) and (Target[1] <> '/') then
Target := ExpandFileName(ExtractFilePath(Path) + Target);
Path := Target;
Inc(Guard);
end;
{$ENDIF}
Result := ExtractFilePath(Path);
end;
end.

414
src/uArticleView.pas Normal file
View file

@ -0,0 +1,414 @@
{
uArticleView — страница текста со ссылками.
Журнал (uLogView) для этого не годится: он показывает сообщения
программы, где перенос сбил бы выравнивание, и строки там не
переносятся принципиально. Здесь наоборот — сплошной текст, который
должен сам ложиться по ширине окна и переливаться заново при его
изменении. Считать переносы заранее нельзя ещё и потому, что текст
когда-нибудь поедет в переводы: длина строки в другом языке будет иной.
Разметка нарочно бедная, вся в одну строку:
«# заголовок» — заголовок раздела
пустая строка — отступ между абзацами
ведущие пробелы — отступ, сохраняется при переносе
http:// и https:// — ссылка, её можно нажать
Больше ничего не нужно: это окно «О программе», а не браузер.
}
unit uArticleView;
{$mode objfpc}{$H+}
interface
uses
Classes, SysUtils, Math, Controls, Graphics, LCLType, LCLIntf, Types,
uTheme, uThemedScroll;
type
{ Кусок строки: либо обычный текст, либо ссылка. }
TRun = record
Text: string;
Url: string;
Left: Integer;
Width: Integer;
end;
TVisualLine = record
Runs: array of TRun;
Top: Integer;
Height: Integer;
Heading: Boolean;
end;
TArticleView = class(TCustomControl)
private
FLines: TStringList;
FVisual: array of TVisualLine;
FContentHeight: Integer;
FScroll: Integer;
FBar: TThemedScrollBar;
FHotLine: Integer;
FHotRun: Integer;
procedure BarChange(Sender: TObject);
procedure Relayout;
procedure SyncBar;
{ Ссылка под указателем. Возвращает пустую строку, если её там нет. }
function UrlAt(X, Y: Integer; out ALine, ARun: Integer): string;
function LineHeight(AHeading: Boolean): Integer;
protected
procedure Paint; override;
procedure Resize; override;
procedure MouseMove(Shift: TShiftState; X, Y: Integer); override;
procedure MouseUp(Button: TMouseButton; Shift: TShiftState;
X, Y: Integer); override;
procedure MouseLeave; override;
function DoMouseWheel(Shift: TShiftState; WheelDelta: Integer;
MousePos: TPoint): Boolean; override;
public
constructor Create(AOwner: TComponent); override;
destructor Destroy; override;
procedure SetText(const AText: string);
published
property Align;
property Anchors;
property Font;
property ParentFont;
property Visible;
end;
implementation
const
PadLeft = 10;
PadTop = 8;
BarWidth = 12;
{ Отступ между абзацами и после заголовка — доли высоты строки. }
ParaGap = 6;
constructor TArticleView.Create(AOwner: TComponent);
begin
inherited Create(AOwner);
DoubleBuffered := True;
Color := ThemeSurface;
ParentColor := False;
FLines := TStringList.Create;
FHotLine := -1;
FHotRun := -1;
FBar := TThemedScrollBar.Create(Self);
FBar.Parent := Self;
FBar.Setup(True);
FBar.OnChange := @BarChange;
FBar.Visible := False;
end;
destructor TArticleView.Destroy;
begin
FLines.Free;
inherited Destroy;
end;
function TArticleView.LineHeight(AHeading: Boolean): Integer;
begin
Canvas.Font.Assign(Font);
if AHeading then
Canvas.Font.Style := [fsBold];
Result := Canvas.TextHeight('Wg') + 3;
Canvas.Font.Style := [];
end;
procedure TArticleView.SetText(const AText: string);
begin
FLines.Text := AText;
FScroll := 0;
Relayout;
Invalidate;
end;
{ Раскладка: каждая строка исходника режется на слова и укладывается по
ширине. Ссылка не режется никогда — из двух половин её не собрать. }
procedure TArticleView.Relayout;
var
I, N, Indent, X, MaxX, W, Start: Integer;
Src, Word, Rest: string;
Heading: Boolean;
Line: TVisualLine;
Y: Integer;
procedure PushLine;
begin
if Length(Line.Runs) = 0 then
Exit;
Line.Top := Y;
Line.Height := LineHeight(Line.Heading);
Inc(Y, Line.Height);
SetLength(FVisual, Length(FVisual) + 1);
FVisual[High(FVisual)] := Line;
Line.Runs := nil;
X := Indent;
end;
procedure PushWord(const AWord: string; const AUrl: string);
var
Wid: Integer;
begin
Canvas.Font.Assign(Font);
if Line.Heading then
Canvas.Font.Style := [fsBold];
Wid := Canvas.TextWidth(AWord);
Canvas.Font.Style := [];
{ Не влезло — переносим, но только если на строке уже что-то есть:
иначе слово шире окна ушло бы в бесконечный перенос. }
if (X + Wid > MaxX) and (Length(Line.Runs) > 0) then
PushLine;
N := Length(Line.Runs);
SetLength(Line.Runs, N + 1);
Line.Runs[N].Text := AWord;
Line.Runs[N].Url := AUrl;
Line.Runs[N].Left := X;
Line.Runs[N].Width := Wid;
Inc(X, Wid);
end;
begin
FVisual := nil;
FContentHeight := 0;
if FLines = nil then
Exit;
MaxX := ClientWidth - PadLeft - BarWidth - 6;
if MaxX < 80 then
MaxX := 80;
Y := PadTop;
Line.Runs := nil;
Line.Heading := False;
for I := 0 to FLines.Count - 1 do
begin
Src := FLines[I];
if Trim(Src) = '' then
begin
Inc(Y, ParaGap + LineHeight(False) div 2);
Continue;
end;
Heading := Copy(Src, 1, 2) = '# ';
if Heading then
begin
Src := Copy(Src, 3, Length(Src));
Inc(Y, ParaGap);
end;
Indent := PadLeft;
while (Length(Src) > 0) and (Src[1] = ' ') do
begin
Inc(Indent, 12);
Delete(Src, 1, 1);
end;
Line.Heading := Heading;
X := Indent;
Rest := Src;
while Rest <> '' do
begin
Start := Pos(' ', Rest);
if Start = 0 then
begin
Word := Rest;
Rest := '';
end
else
begin
Word := Copy(Rest, 1, Start);
Rest := Copy(Rest, Start + 1, Length(Rest));
end;
if (Pos('http://', Word) = 1) or (Pos('https://', Word) = 1) then
PushWord(Word, TrimRight(Word))
else
PushWord(Word, '');
end;
PushLine;
if Heading then
Inc(Y, 2);
end;
FContentHeight := Y + PadTop;
SyncBar;
end;
procedure TArticleView.SyncBar;
var
Page: Integer;
begin
if FBar = nil then
Exit;
Page := ClientHeight;
FBar.SetBounds(ClientWidth - BarWidth, 0, BarWidth, ClientHeight);
FBar.SetRange(FContentHeight, Page);
FBar.Visible := FBar.Needed;
if FScroll > FContentHeight - Page then
FScroll := Max(0, FContentHeight - Page);
FBar.Position := FScroll;
end;
procedure TArticleView.BarChange(Sender: TObject);
begin
if FScroll = FBar.Position then
Exit;
FScroll := FBar.Position;
Invalidate;
end;
procedure TArticleView.Resize;
begin
inherited Resize;
Relayout;
Invalidate;
end;
function TArticleView.DoMouseWheel(Shift: TShiftState; WheelDelta: Integer;
MousePos: TPoint): Boolean;
var
Step: Integer;
begin
Step := LineHeight(False) * 3;
if WheelDelta > 0 then
FScroll := Max(0, FScroll - Step)
else
FScroll := Min(Max(0, FContentHeight - ClientHeight), FScroll + Step);
if FBar <> nil then
FBar.Position := FScroll;
Invalidate;
Result := True;
end;
function TArticleView.UrlAt(X, Y: Integer; out ALine, ARun: Integer): string;
var
{ Не Top: так называется свойство самого элемента. }
I, J, LineTop: Integer;
begin
Result := '';
ALine := -1;
ARun := -1;
for I := 0 to High(FVisual) do
begin
LineTop := FVisual[I].Top - FScroll;
if (Y < LineTop) or (Y >= LineTop + FVisual[I].Height) then
Continue;
for J := 0 to High(FVisual[I].Runs) do
if (FVisual[I].Runs[J].Url <> '') and
(X >= FVisual[I].Runs[J].Left) and
(X < FVisual[I].Runs[J].Left + FVisual[I].Runs[J].Width) then
begin
ALine := I;
ARun := J;
Exit(FVisual[I].Runs[J].Url);
end;
Exit;
end;
end;
procedure TArticleView.MouseMove(Shift: TShiftState; X, Y: Integer);
var
L, R: Integer;
begin
inherited MouseMove(Shift, X, Y);
UrlAt(X, Y, L, R);
if (L <> FHotLine) or (R <> FHotRun) then
begin
FHotLine := L;
FHotRun := R;
if L >= 0 then
Cursor := crHandPoint
else
Cursor := crDefault;
Invalidate;
end;
end;
procedure TArticleView.MouseLeave;
begin
inherited MouseLeave;
if FHotLine >= 0 then
begin
FHotLine := -1;
FHotRun := -1;
Cursor := crDefault;
Invalidate;
end;
end;
procedure TArticleView.MouseUp(Button: TMouseButton; Shift: TShiftState;
X, Y: Integer);
var
Url: string;
L, R: Integer;
begin
inherited MouseUp(Button, Shift, X, Y);
if Button <> mbLeft then
Exit;
Url := UrlAt(X, Y, L, R);
if Url <> '' then
OpenURL(Url);
end;
procedure TArticleView.Paint;
var
I, J, LineTop: Integer;
Run: TRun;
Hot: Boolean;
begin
Canvas.Brush.Color := ThemeSurface;
Canvas.Brush.Style := bsSolid;
Canvas.FillRect(ClientRect);
Canvas.Brush.Style := bsClear;
for I := 0 to High(FVisual) do
begin
LineTop := FVisual[I].Top - FScroll;
if LineTop + FVisual[I].Height < 0 then
Continue;
if LineTop > ClientHeight then
Break;
Canvas.Font.Assign(Font);
if FVisual[I].Heading then
Canvas.Font.Style := [fsBold];
for J := 0 to High(FVisual[I].Runs) do
begin
Run := FVisual[I].Runs[J];
Hot := (I = FHotLine) and (J = FHotRun);
if Run.Url <> '' then
begin
if Hot then
Canvas.Font.Color := ThemeAccent
else
Canvas.Font.Color := ThemeLink;
Canvas.Font.Style := Canvas.Font.Style + [fsUnderline];
end
else if FVisual[I].Heading then
Canvas.Font.Color := ThemeText
else
Canvas.Font.Color := ThemeText;
Canvas.TextOut(Run.Left, LineTop, Run.Text);
Canvas.Font.Style := Canvas.Font.Style - [fsUnderline];
end;
Canvas.Font.Style := [];
end;
end;
end.

397
src/uColorKeyForm.pas Normal file
View file

@ -0,0 +1,397 @@
{
uColorKeyForm — окно цветового ключа.
Цветовой ключ выбивает из клипа один цвет: всё, что достаточно на него
похоже, становится прозрачным, и сквозь клип видно то, что лежит под
ним. Так работает «зелёный экран» в кино, только цвет выбираем мы.
Главное в этом окне — не поля, а картинка. Цвет берут щелчком прямо по
кадру: назвать пурпурный числом человек не сможет, а ткнуть в него
мышью — сможет. Выбитое показывается клеткой, как в редакторах
изображений: клетка видна там, где будет видно нижний клип.
Порог считается тем же способом, что и в ffmpeg: расстояние между
цветом точки и заданным цветом в пространстве RGB, поделённое на
наибольшее возможное. Иначе картинка в окне обещала бы одно, а сборка
давала другое.
}
unit uColorKeyForm;
{$mode objfpc}{$H+}
interface
uses
Classes, SysUtils, Controls, Forms, Graphics, StdCtrls, Dialogs, ExtCtrls,
ComCtrls, uAppTypes, uProject, uTheme;
{ Показывает окно. True — человек нажал «Готово», и в AKey лежит
изменённое.
APath — файл клипа, ATime — время внутри файла, откуда брать кадр для
показа. }
function EditColorKey(var AKey: TColorKey; const APath: string;
const ATime: Double; const ATempFile: string): Boolean;
implementation
uses
Math, FPimage, IntfGraphics, uFfmpegCmd, uProcRunner;
const
{ Клетка под выбитым: две краски и размер квадратика. }
ColorCheckA = TColor($4A4A4A);
ColorCheckB = TColor($5E5E5E);
CheckSize = 8;
type
TColorKeyDialog = class(TComponent)
public
Form: TForm;
pbFrame: TPaintBox;
cbOn: TCheckBox;
BtnColor: TThemedButton;
tbSimilar: TTrackBar;
tbBlend: TTrackBar;
lblSimilar: TLabel;
lblBlend: TLabel;
lblHint: TLabel;
Key: TColorKey;
{ Кадр как он есть и кадр с уже выбитым цветом. Первый нужен, чтобы
брать из него цвет по щелчку: во втором выбитое уже заменено
клеткой, и попасть по исходному цвету было бы нельзя. }
Source: TBitmap;
Shown: TBitmap;
procedure AnyChange(Sender: TObject);
procedure ColorClick(Sender: TObject);
procedure FramePaint(Sender: TObject);
procedure FrameMouseDown(Sender: TObject; AButton: TMouseButton;
AShift: TShiftState; AX, AY: Integer);
{ Прямоугольник, в который вписан кадр. }
function FrameRect: TRect;
procedure Rebuild;
procedure UpdateLabels;
end;
{ Насколько точка отличается от заданного цвета: 0 — тот же цвет, 1 —
наибольшее возможное расхождение. Повторяет расчёт фильтра colorkey. }
function ColorDistance(AR, AG, AB: Integer; AKey: TRgb): Double;
var
DR, DG, DB: Integer;
begin
DR := AR - (AKey and $FF);
DG := AG - ((AKey shr 8) and $FF);
DB := AB - ((AKey shr 16) and $FF);
Result := Sqrt((DR * DR + DG * DG + DB * DB) / (255.0 * 255.0 * 3.0));
end;
{ Прозрачность точки после ключа: 0 — выбита совсем, 1 — осталась. }
function KeyAlpha(const ADist, ASimilar, ABlend: Double): Double;
begin
if ABlend > 0.0001 then
Result := Max(0, Min(1, (ADist - ASimilar) / ABlend))
else if ADist > ASimilar then
Result := 1
else
Result := 0;
end;
{ Кадр с выбитым цветом: где выбито — клетка, где нет — исходная точка,
а на смягчённом крае они смешаны.
Точки берутся через промежуточное изображение, а не через Canvas.Pixels:
каждое обращение к Pixels под gtk2 идёт через оконную систему, и на
кадре в четверть миллиона точек окно бы просто встало. }
procedure ApplyKey(ASrc, ADst: TBitmap; const AKey: TColorKey);
var
Img: TLazIntfImage;
X, Y, R, G, B: Integer;
A, Similar, Blend: Double;
C: TFPColor;
Check: TColor;
begin
Similar := AKey.Similarity / 100;
Blend := AKey.Blend / 100;
Img := ASrc.CreateIntfImage;
try
if AKey.Enabled then
for Y := 0 to Img.Height - 1 do
for X := 0 to Img.Width - 1 do
begin
C := Img.Colors[X, Y];
R := C.Red shr 8;
G := C.Green shr 8;
B := C.Blue shr 8;
A := KeyAlpha(ColorDistance(R, G, B, AKey.Color), Similar, Blend);
if A >= 0.999 then
Continue;
if ((X div CheckSize) + (Y div CheckSize)) and 1 = 0 then
Check := ColorCheckA
else
Check := ColorCheckB;
C.Red := Round(R * A + (Check and $FF) * (1 - A)) * 257;
C.Green := Round(G * A + ((Check shr 8) and $FF) * (1 - A)) * 257;
C.Blue := Round(B * A + ((Check shr 16) and $FF) * (1 - A)) * 257;
Img.Colors[X, Y] := C;
end;
ADst.LoadFromIntfImage(Img);
finally
Img.Free;
end;
end;
function TColorKeyDialog.FrameRect: TRect;
var
W, H: Integer;
Scale: Double;
begin
Result := Rect(0, 0, 0, 0);
if (Source = nil) or (Source.Width = 0) or (Source.Height = 0) then
Exit;
Scale := Min(pbFrame.Width / Source.Width, pbFrame.Height / Source.Height);
W := Max(1, Round(Source.Width * Scale));
H := Max(1, Round(Source.Height * Scale));
Result := Bounds((pbFrame.Width - W) div 2, (pbFrame.Height - H) div 2, W, H);
end;
procedure TColorKeyDialog.FramePaint(Sender: TObject);
var
R: TRect;
begin
pbFrame.Canvas.Brush.Color := ThemeSurface;
pbFrame.Canvas.FillRect(0, 0, pbFrame.Width, pbFrame.Height);
if Shown = nil then
begin
pbFrame.Canvas.Font.Color := ThemeText;
pbFrame.Canvas.TextOut(8, 8, 'Кадр получить не удалось');
Exit;
end;
R := FrameRect;
pbFrame.Canvas.StretchDraw(R, Shown);
end;
procedure TColorKeyDialog.FrameMouseDown(Sender: TObject;
AButton: TMouseButton; AShift: TShiftState; AX, AY: Integer);
var
R: TRect;
SX, SY: Integer;
begin
if Source = nil then
Exit;
R := FrameRect;
if (AX < R.Left) or (AX >= R.Right) or (AY < R.Top) or (AY >= R.Bottom) then
Exit;
SX := Round((AX - R.Left) / (R.Right - R.Left) * Source.Width);
SY := Round((AY - R.Top) / (R.Bottom - R.Top) * Source.Height);
SX := Max(0, Min(Source.Width - 1, SX));
SY := Max(0, Min(Source.Height - 1, SY));
Key.Color := TRgb(Source.Canvas.Pixels[SX, SY]);
BtnColor.Swatch := TColor(Key.Color);
{ Ткнули в цвет — значит, его и хотят выбить. Требовать после этого
отдельно поставить галку было бы издевательством. }
if not cbOn.Checked then
cbOn.Checked := True;
Rebuild;
end;
procedure TColorKeyDialog.ColorClick(Sender: TObject);
var
Dlg: TColorDialog;
begin
Dlg := TColorDialog.Create(nil);
try
Dlg.Color := TColor(Key.Color);
if not Dlg.Execute then
Exit;
Key.Color := TRgb(Dlg.Color);
BtnColor.Swatch := Dlg.Color;
Rebuild;
finally
Dlg.Free;
end;
end;
procedure TColorKeyDialog.UpdateLabels;
begin
lblSimilar.Caption := Format('Допуск: %d%%', [tbSimilar.Position]);
lblBlend.Caption := Format('Смягчение края: %d%%', [tbBlend.Position]);
end;
procedure TColorKeyDialog.AnyChange(Sender: TObject);
begin
Key.Enabled := cbOn.Checked;
Key.Similarity := tbSimilar.Position;
Key.Blend := tbBlend.Position;
UpdateLabels;
Rebuild;
end;
procedure TColorKeyDialog.Rebuild;
begin
if Source = nil then
Exit;
if Shown = nil then
Shown := TBitmap.Create;
ApplyKey(Source, Shown, Key);
pbFrame.Invalidate;
end;
{ Кадр из середины куска. Первый кадр часто чёрный, и выбирать по нему
цвет не по чему. }
function GrabFrame(const APath: string; const ATime: Double;
const ATempFile: string): TBitmap;
var
Res: TProcResult;
Png: TPortableNetworkGraphic;
begin
Result := nil;
if APath = '' then
Exit;
Res := RunProcess(FfmpegExe,
ArgsPreviewFrame(APath, ATempFile, ATime, ''), nil, nil, nil);
if (not Res.Started) or (Res.ExitCode <> 0) or (not FileExists(ATempFile)) then
Exit;
Png := TPortableNetworkGraphic.Create;
try
try
Png.LoadFromFile(ATempFile);
Result := TBitmap.Create;
{ Формат точек не трогаем. Присвоение ему пересоздаёт хранилище
картинки, и только что положенный кадр пропадает — окно
показывало ровный чёрный прямоугольник вместо кадра. }
Result.Assign(Png);
except
FreeAndNil(Result);
end;
finally
Png.Free;
end;
SysUtils.DeleteFile(ATempFile);
end;
function EditColorKey(var AKey: TColorKey; const APath: string;
const ATime: Double; const ATempFile: string): Boolean;
var
Dlg: TColorKeyDialog;
BtnOk, BtnCancel: TThemedButton;
Lbl: TLabel;
begin
Result := False;
Dlg := TColorKeyDialog.Create(nil);
try
Dlg.Key := AKey;
Dlg.Source := GrabFrame(APath, ATime, ATempFile);
Dlg.Form := TForm.CreateNew(nil);
Dlg.Form.Caption := 'Цветовой ключ';
Dlg.Form.Position := poScreenCenter;
Dlg.Form.ClientWidth := 760;
Dlg.Form.ClientHeight := 560;
Dlg.pbFrame := TPaintBox.Create(Dlg.Form);
Dlg.pbFrame.Parent := Dlg.Form;
Dlg.pbFrame.SetBounds(12, 12, 736, 360);
Dlg.pbFrame.Anchors := [akLeft, akTop, akRight, akBottom];
Dlg.pbFrame.OnPaint := @Dlg.FramePaint;
Dlg.pbFrame.OnMouseDown := @Dlg.FrameMouseDown;
Lbl := TLabel.Create(Dlg.Form);
Lbl.Parent := Dlg.Form;
Lbl.SetBounds(12, 380, 736, 20);
Lbl.Anchors := [akLeft, akRight, akBottom];
Lbl.Caption := 'Щёлкните по кадру, чтобы взять цвет. Клетка — то, ' +
'что станет прозрачным.';
Dlg.cbOn := TCheckBox.Create(Dlg.Form);
Dlg.cbOn.Parent := Dlg.Form;
Dlg.cbOn.SetBounds(12, 408, 220, 22);
Dlg.cbOn.Anchors := [akLeft, akBottom];
Dlg.cbOn.Caption := 'Выбивать цвет';
Dlg.cbOn.Checked := AKey.Enabled;
Dlg.cbOn.OnClick := @Dlg.AnyChange;
Dlg.BtnColor := TThemedButton.Create(Dlg.Form);
Dlg.BtnColor.Parent := Dlg.Form;
Dlg.BtnColor.SetBounds(240, 404, 120, 28);
Dlg.BtnColor.Anchors := [akLeft, akBottom];
Dlg.BtnColor.Caption := 'Цвет';
Dlg.BtnColor.Swatch := TColor(AKey.Color);
Dlg.BtnColor.OnClick := @Dlg.ColorClick;
Dlg.lblSimilar := TLabel.Create(Dlg.Form);
Dlg.lblSimilar.Parent := Dlg.Form;
Dlg.lblSimilar.SetBounds(12, 442, 200, 20);
Dlg.lblSimilar.Anchors := [akLeft, akBottom];
Dlg.tbSimilar := TTrackBar.Create(Dlg.Form);
Dlg.tbSimilar.Parent := Dlg.Form;
Dlg.tbSimilar.SetBounds(210, 438, 538, 30);
Dlg.tbSimilar.Anchors := [akLeft, akRight, akBottom];
Dlg.tbSimilar.Min := 1;
Dlg.tbSimilar.Max := 60;
Dlg.tbSimilar.TickStyle := tsNone;
Dlg.tbSimilar.Position := Max(1, Min(60, AKey.Similarity));
Dlg.tbSimilar.OnChange := @Dlg.AnyChange;
Dlg.lblBlend := TLabel.Create(Dlg.Form);
Dlg.lblBlend.Parent := Dlg.Form;
Dlg.lblBlend.SetBounds(12, 478, 200, 20);
Dlg.lblBlend.Anchors := [akLeft, akBottom];
Dlg.tbBlend := TTrackBar.Create(Dlg.Form);
Dlg.tbBlend.Parent := Dlg.Form;
Dlg.tbBlend.SetBounds(210, 474, 538, 30);
Dlg.tbBlend.Anchors := [akLeft, akRight, akBottom];
Dlg.tbBlend.Min := 0;
Dlg.tbBlend.Max := 40;
Dlg.tbBlend.TickStyle := tsNone;
Dlg.tbBlend.Position := Max(0, Min(40, AKey.Blend));
Dlg.tbBlend.OnChange := @Dlg.AnyChange;
Dlg.lblHint := TLabel.Create(Dlg.Form);
Dlg.lblHint.Parent := Dlg.Form;
Dlg.lblHint.SetBounds(12, 512, 480, 36);
Dlg.lblHint.Anchors := [akLeft, akRight, akBottom];
Dlg.lblHint.AutoSize := False;
Dlg.lblHint.WordWrap := True;
Dlg.lblHint.Caption := 'Участки с ключом придётся пересобрать: ' +
'копированием прозрачность не делается.';
BtnOk := TThemedButton.Create(Dlg.Form);
BtnOk.Parent := Dlg.Form;
BtnOk.SetBounds(540, 518, 100, 30);
BtnOk.Anchors := [akRight, akBottom];
BtnOk.Caption := 'Готово';
BtnOk.ModalResult := mrOk;
BtnOk.Default := True;
BtnCancel := TThemedButton.Create(Dlg.Form);
BtnCancel.Parent := Dlg.Form;
BtnCancel.SetBounds(648, 518, 100, 30);
BtnCancel.Anchors := [akRight, akBottom];
BtnCancel.Caption := 'Отмена';
BtnCancel.ModalResult := mrCancel;
BtnCancel.Cancel := True;
ApplyDarkTheme(Dlg.Form);
Dlg.UpdateLabels;
Dlg.Rebuild;
if Dlg.Form.ShowModal <> mrOk then
Exit;
AKey := Dlg.Key;
Result := True;
finally
Dlg.Source.Free;
Dlg.Shown.Free;
Dlg.Form.Free;
Dlg.Free;
end;
end;
end.

View file

@ -24,7 +24,7 @@ unit uDeps;
interface
uses
Classes, SysUtils;
Classes, SysUtils, uAppTypes;
type
TStringArray = array of string;
@ -89,7 +89,7 @@ uses
function DepTargetDir: string;
begin
Result := ExtractFilePath(ExpandFileName(ParamStr(0)));
Result := ProgramDir;
end;
function DepsConfigPath: string;

224
src/uEncodeForm.pas Normal file
View file

@ -0,0 +1,224 @@
{
uEncodeForm — окно настроек сборки с перекодированием.
Зачем оно нужно. Обычная сборка копирует потоки и потому мгновенна, но
отдаёт ролик ровно в том виде, в каком снят исходник. Когда ролик надо
отдать под площадку — в 1080p с определённым потоком, — приходится
перекодировать, и это уже осознанный выбор, а не поведение по
умолчанию. Поэтому отдельный пункт меню и отдельное окно.
Окно собирается кодом, без файла формы: элементов в нём меньше десятка,
и описание заняло бы больше места, чем сам код. Так же сделано окно
сообщения в uTheme.
Перекодирование идёт на видеокарте. Процессорный кодировщик на
2688x1512 работает медленнее реального времени, и часовой ролик считал
бы часами; выбор кодировщика делает uFfmpegCmd, предпочитая NVENC.
Если аппаратного кодировщика нет, окно об этом честно предупреждает.
}
unit uEncodeForm;
{$mode objfpc}{$H+}
interface
uses
Classes, SysUtils, Controls, Forms, Graphics, StdCtrls, Dialogs,
uTheme, uExport;
{ Спрашивает настройки. Возвращает False, если человек передумал.
ADefaultCodec — кодек проекта: с него разумно начинать, иначе первый же
выбор менял бы формат без спроса. }
function AskEncodeProfile(out AProfile: TEncodeProfile;
const ADefaultCodec: string): Boolean;
implementation
uses
uFfmpegCmd;
type
TPreset = record
Caption: string;
Width: Integer;
Height: Integer;
{ Поток в килобитах в секунду. Значения взяты как обычная рабочая
середина для съёмки с движением: меньше — заметны квадраты на
траве и воде, больше — файл растёт без видимой пользы. }
Bitrate: Integer;
end;
const
Presets: array[0..3] of TPreset = (
(Caption: '1280x720 (720p)'; Width: 1280; Height: 720; Bitrate: 8000),
(Caption: '1920x1080 (1080p)'; Width: 1920; Height: 1080; Bitrate: 16000),
(Caption: '2560x1440 (1440p)'; Width: 2560; Height: 1440; Bitrate: 28000),
(Caption: '3840x2160 (4K)'; Width: 3840; Height: 2160; Bitrate: 50000)
);
type
{ Обработчики живут в объекте: событиям нужны методы. }
TEncodeDialog = class(TComponent)
public
Form: TForm;
cbPreset: TComboBox;
cbCodec: TComboBox;
edBitrate: TEdit;
lblEncoder: TLabel;
procedure PresetChange(Sender: TObject);
procedure CodecChange(Sender: TObject);
procedure UpdateEncoderNote;
end;
procedure TEncodeDialog.PresetChange(Sender: TObject);
begin
if (cbPreset.ItemIndex >= 0) and (cbPreset.ItemIndex <= High(Presets)) then
edBitrate.Text := IntToStr(Presets[cbPreset.ItemIndex].Bitrate);
end;
procedure TEncodeDialog.CodecChange(Sender: TObject);
begin
UpdateEncoderNote;
end;
procedure TEncodeDialog.UpdateEncoderNote;
var
Codec, Enc: string;
begin
if cbCodec.ItemIndex = 0 then
Codec := 'hevc'
else
Codec := 'h264';
Enc := PreferredEncoder(Codec);
if Pos('nvenc', Enc) > 0 then
begin
lblEncoder.Font.Color := ThemeTextDim;
lblEncoder.Caption := 'Кодировщик: ' + Enc + ' — на видеокарте.';
end
else
begin
{ Предупреждение, а не запрет: пусть человек сам решит, готов ли
ждать. Молча уйти на процессор нельзя — разница в разы. }
lblEncoder.Font.Color := ThemeAccent;
lblEncoder.Caption := 'Кодировщик: ' + Enc +
' — аппаратного не нашлось, сборка будет очень долгой.';
end;
end;
function AskEncodeProfile(out AProfile: TEncodeProfile;
const ADefaultCodec: string): Boolean;
var
Dlg: TEncodeDialog;
Lbl: TLabel;
BtnOk, BtnCancel: TThemedButton;
I, Bitrate: Integer;
begin
Result := False;
AProfile := Default(TEncodeProfile);
Dlg := TEncodeDialog.Create(nil);
try
Dlg.Form := TForm.CreateNew(nil);
Dlg.Form.Caption := 'Сборка с перекодированием';
Dlg.Form.BorderStyle := bsDialog;
Dlg.Form.Position := poScreenCenter;
Dlg.Form.ClientWidth := 460;
Dlg.Form.ClientHeight := 220;
Lbl := TLabel.Create(Dlg.Form);
Lbl.Parent := Dlg.Form;
Lbl.SetBounds(16, 16, 120, 20);
Lbl.Caption := 'Размер кадра';
Dlg.cbPreset := TComboBox.Create(Dlg.Form);
Dlg.cbPreset.Parent := Dlg.Form;
Dlg.cbPreset.SetBounds(160, 12, 280, 26);
Dlg.cbPreset.Style := csDropDownList;
for I := 0 to High(Presets) do
Dlg.cbPreset.Items.Add(Presets[I].Caption);
Dlg.cbPreset.ItemIndex := 1;
Dlg.cbPreset.OnChange := @Dlg.PresetChange;
Lbl := TLabel.Create(Dlg.Form);
Lbl.Parent := Dlg.Form;
Lbl.SetBounds(16, 56, 140, 20);
Lbl.Caption := 'Поток, кбит/с';
Dlg.edBitrate := TEdit.Create(Dlg.Form);
Dlg.edBitrate.Parent := Dlg.Form;
Dlg.edBitrate.SetBounds(160, 52, 120, 26);
Dlg.edBitrate.Text := IntToStr(Presets[1].Bitrate);
Lbl := TLabel.Create(Dlg.Form);
Lbl.Parent := Dlg.Form;
Lbl.SetBounds(16, 96, 140, 20);
Lbl.Caption := 'Кодек';
Dlg.cbCodec := TComboBox.Create(Dlg.Form);
Dlg.cbCodec.Parent := Dlg.Form;
Dlg.cbCodec.SetBounds(160, 92, 280, 26);
Dlg.cbCodec.Style := csDropDownList;
Dlg.cbCodec.Items.Add('HEVC (H.265) — меньше файл');
Dlg.cbCodec.Items.Add('H.264 — совместимее');
if LowerCase(ADefaultCodec) = 'h264' then
Dlg.cbCodec.ItemIndex := 1
else
Dlg.cbCodec.ItemIndex := 0;
Dlg.cbCodec.OnChange := @Dlg.CodecChange;
Dlg.lblEncoder := TLabel.Create(Dlg.Form);
Dlg.lblEncoder.Parent := Dlg.Form;
Dlg.lblEncoder.SetBounds(16, 132, 424, 36);
Dlg.lblEncoder.WordWrap := True;
Dlg.lblEncoder.AutoSize := False;
Dlg.lblEncoder.ParentColor := False;
BtnOk := TThemedButton.Create(Dlg.Form);
BtnOk.Parent := Dlg.Form;
BtnOk.SetBounds(240, 180, 100, 28);
BtnOk.Caption := 'Собрать';
BtnOk.ModalResult := mrOk;
BtnOk.Default := True;
BtnCancel := TThemedButton.Create(Dlg.Form);
BtnCancel.Parent := Dlg.Form;
BtnCancel.SetBounds(348, 180, 100, 28);
BtnCancel.Caption := 'Отмена';
BtnCancel.ModalResult := mrCancel;
BtnCancel.Cancel := True;
{ Цвета задаются до первого показа, как и во всех остальных окнах. }
ApplyDarkTheme(Dlg.Form);
Dlg.UpdateEncoderNote;
if Dlg.Form.ShowModal <> mrOk then
Exit;
I := Dlg.cbPreset.ItemIndex;
if (I < 0) or (I > High(Presets)) then
I := 1;
Bitrate := StrToIntDef(Trim(Dlg.edBitrate.Text), Presets[I].Bitrate);
{ Ноль и отрицательное — не поток, а описка. Верхняя граница выбрана
с запасом: выше него кодировщики уже отказываются работать. }
if (Bitrate < 100) or (Bitrate > 400000) then
Bitrate := Presets[I].Bitrate;
AProfile.Enabled := True;
AProfile.Width := Presets[I].Width;
AProfile.Height := Presets[I].Height;
AProfile.BitrateKbps := Bitrate;
if Dlg.cbCodec.ItemIndex = 1 then
AProfile.Codec := 'h264'
else
AProfile.Codec := 'hevc';
Result := True;
finally
Dlg.Form.Free;
Dlg.Free;
end;
end;
end.

View file

@ -51,6 +51,20 @@ uses
Classes, SysUtils, uAppTypes, uKeyframeIndex, uMediaProbe;
type
{ Настройки сборки с перекодированием.
Enabled = False означает обычную сборку копированием — то, ради чего
редактор и делался. Всё остальное имеет смысл только при True. }
TEncodeProfile = record
Enabled: Boolean;
Width: Integer;
Height: Integer;
BitrateKbps: Integer;
{ Кодек в привычных именах: h264 или hevc. Кодировщик под него
подбирается сам, с предпочтением аппаратного. }
Codec: string;
end;
{ Что получилось с одной частью.
Кусок монтажа обычно даёт одну часть, но при точном резе — две:
@ -87,6 +101,12 @@ type
TExportThread = class(TThread)
private
FSegments: TFlatSegmentArray;
{ Куски звука. Отдельный список: звук можно двигать независимо от
картинки, и точки реза у них разные. }
FAudio: TFlatSegmentArray;
{ Собранная звуковая дорожка и картинка без звука. }
FAudioPath: string;
FVideoPath: string;
FOutputPath: string;
FCacheDir: string;
FKeepIntermediate: Boolean;
@ -95,6 +115,13 @@ type
FStageText: string;
FFraction: Double;
FPendingLog: string;
FLogPath: string;
FResume: Boolean;
FReusedParts: Integer;
{ Имена кусков, доведённых до конца в этой или прошлой сборке. }
FDone: TStringList;
{ Признак того, что готовые куски относятся к этому же монтажу. }
FPlanKey: string;
FReport: TExportReport;
FOnProgress: TNotifyEvent;
@ -110,6 +137,8 @@ type
FPacketCount: Int64;
FMaster: TMediaInfo;
FProfile: TEncodeProfile;
FSubtitlePath: string;
{ Формат промежуточных кусков: 'matroska' или 'mpegts'. }
FIntermediateFormat: string;
FIntermediateExt: string;
@ -120,6 +149,16 @@ type
procedure SyncProgress;
procedure SyncLog;
procedure Log(const AText: string);
procedure WriteLogLine(const AText: string);
{ Отпечаток плана сборки: пути, места и длины кусков. По нему видно,
что готовые куски сделаны из того же монтажа, а не от прошлой
правки. }
function PlanKey: string;
procedure LoadDoneList;
function IsDone(const AName: string): Boolean;
procedure MarkDone(const AName: string);
{ Готов ли кусок с прошлого раза. Заполняет отчёт по нему. }
function TakeReadyPart(AIndex: Integer; out ARep: TSegmentReport): Boolean;
procedure SetStage(const AText: string);
procedure PublishProgress;
@ -134,15 +173,23 @@ type
function WriteConcatList(out AListPath: string): Boolean;
function ConcatAll(const AListPath: string): Boolean;
procedure Verify;
{ Сборка звука: куски сводятся в сырой поток и сжимаются одним
проходом. Сырой поток нужен для точности — стык получается с
точностью до отсчёта, а не до звукового кадра. }
function BuildAudio: Boolean;
function MixLayers(const ASeg: TFlatSegment; ADest: TStream;
ANeed: Int64; ARate, AChannels: Integer): Boolean;
function MuxFinal: Boolean;
protected
procedure Execute; override;
public
{ AMaster нужен, чтобы понять, влезут ли кодеки проекта в MPEG-TS.
Можно передать пустую запись — тогда способ склейки выберется
по одному лишь числу источников. }
constructor Create(const ASegments: TFlatSegmentArray;
destructor Destroy; override;
constructor Create(const ASegments, AAudio: TFlatSegmentArray;
const AOutputPath: string; AKeepIntermediate: Boolean;
const AMaster: TMediaInfo);
const AMaster: TMediaInfo; const AProfile: TEncodeProfile);
procedure RequestCancel;
@ -154,6 +201,27 @@ type
property OnProgress: TNotifyEvent read FOnProgress write FOnProgress;
property OnLog: TNotifyEvent read FOnLog write FOnLog;
{ Разметка субтитров, которую надо вложить в готовый файл отдельной
дорожкой. Пусто — либо текста нет, либо он вжигается в картинку.
Годится только для Matroska: MP4 хранит субтитры без оформления. }
property SubtitlePath: string read FSubtitlePath write FSubtitlePath;
{ Куда писать журнал сборки. Пусто — никуда.
Журнал кладётся рядом с готовым файлом и под тем же именем: когда
через неделю выяснится, что в ролике не то, разбираться придётся
именно по нему, а общий журнал программы к тому времени перезапишется
следующим запуском. Строка пишется сразу, а не копится в памяти:
если сборка упадёт, важна как раз последняя строка. }
property LogPath: string read FLogPath write FLogPath;
{ Продолжать прерванную сборку: готовые куски берутся с диска, а не
считаются заново.
Что считается готовым, помечено в списке рядом с самими кусками.
Проверять по одному только наличию файла нельзя: сборку могли
прервать посреди записи, и файл окажется обрезанным. }
property Resume: Boolean read FResume write FResume;
{ Сколько кусков взято готовыми. Для отчёта. }
property ReusedParts: Integer read FReusedParts;
end;
{ Каталог для временных кусков рядом с готовым файлом. }
@ -162,7 +230,33 @@ function CacheDirFor(const AOutputPath: string): string;
implementation
uses
Math, uFfmpegCmd, uProcRunner;
Math, md5, FileUtil, uFfmpegCmd, uProcRunner;
{ Размер файла или ноль, если его нет. }
function FileSizeOf(const APath: string): Int64;
var
F: TSearchRec;
begin
Result := 0;
if APath = '' then
Exit;
if FindFirst(APath, faAnyFile, F) = 0 then
begin
Result := F.Size;
FindClose(F);
end;
end;
{ Размер по-человечески: гигабайты, если их много. }
function SizeText(ABytes: Int64): string;
begin
if ABytes >= Int64(1024) * 1024 * 1024 then
Result := Format('%.1f ГБ', [ABytes / (1024 * 1024 * 1024)])
else if ABytes >= 1024 * 1024 then
Result := Format('%.0f МБ', [ABytes / (1024 * 1024)])
else
Result := Format('%d КБ', [ABytes div 1024]);
end;
function CacheDirFor(const AOutputPath: string): string;
begin
@ -171,13 +265,20 @@ begin
PathDelim;
end;
constructor TExportThread.Create(const ASegments: TFlatSegmentArray;
destructor TExportThread.Destroy;
begin
FDone.Free;
inherited Destroy;
end;
constructor TExportThread.Create(const ASegments, AAudio: TFlatSegmentArray;
const AOutputPath: string; AKeepIntermediate: Boolean;
const AMaster: TMediaInfo);
const AMaster: TMediaInfo; const AProfile: TEncodeProfile);
var
I: Integer;
begin
FMaster := AMaster;
FProfile := AProfile;
inherited Create(True);
FreeOnTerminate := False;
@ -195,6 +296,14 @@ begin
FSegments[I].FilePath := ExpandFileName(FSegments[I].FilePath);
end;
SetLength(FAudio, Length(AAudio));
for I := 0 to High(AAudio) do
begin
FAudio[I] := AAudio[I];
if FAudio[I].FilePath <> '' then
FAudio[I].FilePath := ExpandFileName(FAudio[I].FilePath);
end;
FOutputPath := ExpandFileName(AOutputPath);
FCacheDir := CacheDirFor(FOutputPath);
FKeepIntermediate := AKeepIntermediate;
@ -230,8 +339,148 @@ begin
FOnLog(Self);
end;
{ Строка в журнал сборки рядом с готовым файлом.
Файл открывается и закрывается на каждой строке. Это медленно, но строк
за сборку десятки, а взамен журнал остаётся целым даже если программа
упадёт на следующем шаге — ради этого он и заводится. }
procedure TExportThread.WriteLogLine(const AText: string);
var
F: TextFile;
begin
if FLogPath = '' then
Exit;
try
AssignFile(F, FLogPath);
if FileExists(FLogPath) then
Append(F)
else
Rewrite(F);
try
WriteLn(F, AText);
finally
CloseFile(F);
end;
except
{ Журнал не должен ронять сборку: не записалось — и ладно. }
on E: Exception do
FLogPath := '';
end;
end;
function TExportThread.PlanKey: string;
var
I: Integer;
begin
Result := Format('%dx%d %d/%d %s %s|', [FMaster.Width, FMaster.Height,
FMaster.FpsNum, FMaster.FpsDen, FMaster.VideoCodec, FMaster.PixFmt]);
for I := 0 to High(FSegments) do
Result := Result + Format('%s@%s+%s#%d%s;',
[ExtractFileName(FSegments[I].FilePath),
SecondsToStr(FSegments[I].SrcStart),
SecondsToStr(FSegments[I].Duration),
Length(FSegments[I].Layers),
FSegments[I].SubFile]);
Result := MD5Print(MD5String(Result));
end;
{ Список готовых кусков рядом с ними самими. Первая строка — отпечаток
плана: не совпал, значит куски от другого монтажа и брать их нельзя. }
procedure TExportThread.LoadDoneList;
var
Path: string;
begin
FDone := TStringList.Create;
FPlanKey := PlanKey;
Path := FCacheDir + 'готово.txt';
if not FResume then
begin
if FileExists(Path) then
SysUtils.DeleteFile(Path);
Exit;
end;
if not FileExists(Path) then
Exit;
try
FDone.LoadFromFile(Path);
except
FDone.Clear;
end;
if (FDone.Count = 0) or (FDone[0] <> FPlanKey) then
begin
Log('Готовые куски от другой расстановки — собираю заново.');
FDone.Clear;
end;
end;
function TExportThread.IsDone(const AName: string): Boolean;
begin
Result := (FDone <> nil) and (FDone.IndexOf(AName) > 0) and
(FileExists(FCacheDir + AName)) and (FileSize(FCacheDir + AName) > 0);
end;
procedure TExportThread.MarkDone(const AName: string);
var
Path: string;
begin
if FDone = nil then
Exit;
if FDone.Count = 0 then
FDone.Add(FPlanKey);
if FDone.IndexOf(AName) < 0 then
FDone.Add(AName);
Path := FCacheDir + 'готово.txt';
try
{ Записываем сразу: список нужен именно на случай, когда сборка не
доживёт до конца. }
FDone.SaveToFile(Path);
except
{ Не записалось — просто соберём заново в следующий раз. }
end;
end;
function TExportThread.TakeReadyPart(AIndex: Integer;
out ARep: TSegmentReport): Boolean;
var
Names: array[0..2] of string;
I: Integer;
begin
Result := False;
ARep := Default(TSegmentReport);
if (FDone = nil) or (FDone.Count = 0) then
Exit;
{ Имя куска зависит от того, каким путём он делался: обычный, с
наложением, с точным резом. Проверяем все три. }
Names[0] := Format('part_%.3d_a%s', [AIndex, FIntermediateExt]);
Names[1] := Format('part_%.3d_x%s', [AIndex, FIntermediateExt]);
Names[2] := Format('part_%.3d_b%s', [AIndex, FIntermediateExt]);
for I := 0 to 1 do
if IsDone(Names[I]) then
begin
{ Кусок с точным резом состоит из двух частей; если вторая тоже
готова, её возьмёт следующий заход — здесь достаточно первой. }
ARep.Index := AIndex;
ARep.SourcePath := FSegments[AIndex].FilePath;
ARep.RequestedStart := FSegments[AIndex].SrcStart;
ARep.ActualStart := FSegments[AIndex].SrcStart;
ARep.Duration := FSegments[AIndex].Duration;
ARep.Packets := -1;
ARep.Encoded := I = 1;
ARep.TempPath := FCacheDir + Names[I];
Inc(FReusedParts);
Log(Format('Кусок %d уже собран — беру готовый.', [AIndex + 1]));
Exit(True);
end;
end;
procedure TExportThread.Log(const AText: string);
begin
WriteLogLine(AText);
FPendingLog := AText;
Synchronize(@SyncLog);
end;
@ -445,13 +694,23 @@ begin
Exit;
end;
{ Кусок, доведённый до конца в прошлый раз, берём готовым. На длинном
ролике это разница между «собрать заново всё» и «доделать хвост». }
if TakeReadyPart(I, Rep) then
begin
AddPart(Rep);
FDoneWork := FDoneWork + FSegments[I].Duration;
PublishProgress;
Continue;
end;
SetStage(Format('Вырезаю кусок %d из %d', [I + 1, Length(FSegments)]));
{ Сгенерированные вставки берутся из файла, который заведомо длиннее
нужного: так одна и та же чёрная вставка годится для дыр любой
длины. Значит вырезать надо и из них, но притягивать к опорному
кадру нечего — они начинаются с нуля, а ноль всегда опорный. }
if FSegments[I].Kind <> skVideo then
if (FSegments[I].Kind <> skVideo) and (FSegments[I].SubFile = '') then
begin
Dst := FCacheDir + Format('part_%.3d_a%s', [I, FIntermediateExt]);
Rep.Index := I;
@ -470,14 +729,56 @@ begin
Exit;
Rep.TempPath := Dst;
MarkDone(ExtractFileName(Dst));
AddPart(Rep);
Continue;
end;
{ Кусок от файла, не совпадающего с параметрами проекта. Скопировать
его нельзя — пересобираем. Но только его: соседние куски, снятые тем
же оборудованием, что и весь проект, по-прежнему копируются. }
if FSegments[I].Transcode then
{ Участок с наложением: полупрозрачный клип поверх нижнего либо
переход между двумя клипами одной дорожки. Собирается из двух
входов сразу, копированием такое не получить. }
if Length(FSegments[I].Layers) > 0 then
begin
SetStage(Format('Собираю наложение, кусок %d из %d',
[I + 1, Length(FSegments)]));
Dst := FCacheDir + Format('part_%.3d_x%s', [I, FIntermediateExt]);
Rep.Index := I;
Rep.SourcePath := FSegments[I].Layers[0].FilePath;
Rep.RequestedStart := FSegments[I].Layers[0].SrcStart;
Rep.ActualStart := Rep.RequestedStart;
Rep.Duration := FSegments[I].Duration;
Rep.Packets := -1;
Rep.Encoded := True;
FStepWork := FSegments[I].Duration;
if not RunFfmpeg(ArgsRenderLayers(FSegments[I].Layers, Dst,
FSegments[I].Duration, FMaster.Width, FMaster.Height,
FMaster.FpsNum, FMaster.FpsDen, FMaster.PixFmt,
PreferredEncoder(FMaster.VideoCodec),
FIntermediateFormat = 'mpegts', FMaster.VideoBitrate,
FSegments[I].SubFile),
'Наложение в куске ' + IntToStr(I + 1)) then
Exit;
Rep.TempPath := Dst;
MarkDone(ExtractFileName(Dst));
AddPart(Rep);
Continue;
end;
{ Кусок от файла, не совпадающего с параметрами проекта, или с
вжигаемым текстом. Скопировать его нельзя — пересобираем. Но только
его: соседние куски, снятые тем же оборудованием, что и весь
проект, по-прежнему копируются.
Сгенерированные вставки — картинки и чёрное — сюда попадают только
с текстом: без него их незачем перекодировать, они и так в
параметрах проекта. Раньше они уходили в ветку выше и копировались
вместе с текстом мимо: галка вжигания стояла, а надписи на
фотографии не появлялось. }
if FSegments[I].Transcode or (FSegments[I].SubFile <> '') then
begin
SetStage(Format('Пересобираю кусок %d из %d (файл не совпадает с проектом)',
[I + 1, Length(FSegments)]));
@ -491,16 +792,27 @@ begin
Rep.Encoded := True;
FStepWork := FSegments[I].Duration;
{ У сгенерированной вставки время своё: она начинается с нуля, и
просить у неё кусок с середины монтажа бессмысленно. }
if FSegments[I].Kind <> skVideo then
Rep.RequestedStart := 0
else
Rep.RequestedStart := FSegments[I].SrcStart;
Rep.ActualStart := Rep.RequestedStart;
if not RunFfmpeg(ArgsTranscodeSegment(FSegments[I].FilePath, Dst,
FSegments[I].SrcStart, FSegments[I].SrcStart + FSegments[I].Duration,
Rep.RequestedStart, Rep.RequestedStart + FSegments[I].Duration,
FMaster.Width, FMaster.Height, FMaster.FpsNum, FMaster.FpsDen,
FMaster.VideoCodec, FMaster.PixFmt, FMaster.AudioCodec,
FMaster.SampleRate, FMaster.Channels, FSegments[I].Volume,
FIntermediateFormat, FSegments[I].Filter),
FIntermediateFormat, FSegments[I].Filter, FSegments[I].SubFile,
FMaster.VideoBitrate),
'Пересборка куска ' + IntToStr(I + 1)) then
Exit;
Rep.TempPath := Dst;
MarkDone(ExtractFileName(Dst));
AddPart(Rep);
Continue;
end;
@ -548,6 +860,7 @@ begin
Rep.Duration := EncodeUntil - FSegments[I].SrcStart;
Rep.TempPath := Dst;
Rep.Encoded := True;
MarkDone(ExtractFileName(Dst));
AddPart(Rep);
if EncodeUntil < SegEnd - 0.0005 then
@ -564,6 +877,7 @@ begin
Rep.Duration := SegEnd - EncodeUntil;
Rep.TempPath := Dst;
Rep.Encoded := False;
MarkDone(ExtractFileName(Dst));
AddPart(Rep);
end;
@ -595,6 +909,7 @@ begin
Rep.Duration := FSegments[I].Duration;
Rep.TempPath := Dst;
Rep.Encoded := False;
MarkDone(ExtractFileName(Dst));
AddPart(Rep);
end;
@ -632,9 +947,39 @@ end;
function TExportThread.ConcatAll(const AListPath: string): Boolean;
begin
SetStage('Склеиваю');
FStepWork := FReport.TotalDuration;
Result := RunFfmpeg(ArgsConcat(AListPath, FOutputPath), 'Склейка');
if FProfile.Enabled then
begin
{ Перекодирование всего ролика одним проходом. Так весь результат
выходит из одного кодировщика с одними настройками: пересобирать
куски по отдельности здесь нельзя, на стыках была бы видна смена
качества. }
SetStage('Склеиваю и перекодирую');
if RunFfmpeg(ArgsConcatEncode(AListPath, FOutputPath,
FProfile.Width, FProfile.Height, FMaster.FpsNum, FMaster.FpsDen,
FProfile.BitrateKbps, FProfile.Codec, FMaster.PixFmt, True, FAudioPath),
'Склейка с перекодированием') then
Exit(True);
if Terminated then
Exit(False);
{ Аппаратная распаковка не пошла — повторяем без неё.
Она даёт впятеро, но зависит от драйвера и от того, знаком ли
видеокарте кодек исходника. Отказываться из-за неё от сборки
нельзя: медленно лучше, чем никак. }
Log('Аппаратная распаковка не пошла, повторяю на процессоре.');
Exit(RunFfmpeg(ArgsConcatEncode(AListPath, FOutputPath,
FProfile.Width, FProfile.Height, FMaster.FpsNum, FMaster.FpsDen,
FProfile.BitrateKbps, FProfile.Codec, FMaster.PixFmt, False, FAudioPath),
'Склейка с перекодированием без аппаратной распаковки'));
end;
SetStage('Склеиваю');
Result := RunFfmpeg(ArgsConcatMux(AListPath, FAudioPath, FSubtitlePath,
FOutputPath), 'Склейка');
end;
procedure TExportThread.Verify;
@ -658,10 +1003,235 @@ begin
FReport.ActualPackets := Actual;
end;
{ --- Звук --- }
function TExportThread.BuildAudio: Boolean;
var
RawPath, PartPath: string;
All, Part: TFileStream;
I: Integer;
Need: Int64;
Zero: array[0..65535] of Byte;
Res: TProcResult;
Chunk: Int64;
Rate, Channels: Integer;
begin
Result := False;
FAudioPath := '';
if Length(FAudio) = 0 then
Exit;
Rate := FMaster.SampleRate;
if Rate <= 0 then
Rate := 48000;
Channels := FMaster.Channels;
if Channels <= 0 then
Channels := 2;
RawPath := FCacheDir + 'audio.raw';
PartPath := FCacheDir + 'audio_part.raw';
FillChar(Zero, SizeOf(Zero), 0);
SetStage('Собираю звук');
try
All := TFileStream.Create(RawPath, fmCreate);
try
for I := 0 to High(FAudio) do
begin
if Terminated then
Exit;
{ Сколько байт должен занять кусок. Считается из длительности, а
не берётся у ffmpeg: тогда длина всей дорожки совпадает с
монтажом до отсчёта, и звук не уезжает от картинки. }
Need := Round(FAudio[I].Duration * Rate) * Channels * 2;
if Length(FAudio[I].Layers) > 0 then
begin
{ Переход или наложение: слои сводятся прямо в отсчётах. Здесь
это проще и точнее, чем через фильтры ffmpeg — доля каждого
слоя считается на каждый отсчёт, без округлений по кадрам. }
if MixLayers(FAudio[I], All, Need, Rate, Channels) then
Need := 0;
end
else if FAudio[I].FilePath <> '' then
begin
Res := RunProcess(FfmpegExe,
ArgsExtractAudioRaw(FAudio[I].FilePath, PartPath,
FAudio[I].SrcStart, FAudio[I].Duration, Rate, Channels,
FAudio[I].Volume),
nil, @ErrorLine, @CancelQuery);
if Res.Cancelled then
Exit;
if Res.Started and (Res.ExitCode = 0) and FileExists(PartPath) then
begin
Part := TFileStream.Create(PartPath, fmOpenRead or fmShareDenyNone);
try
Chunk := Part.Size;
if Chunk > Need then
Chunk := Need;
if Chunk > 0 then
All.CopyFrom(Part, Chunk);
Dec(Need, Chunk);
finally
Part.Free;
end;
SysUtils.DeleteFile(PartPath);
end;
end;
{ Чего не хватило — дополняем тишиной. Так же заполняются дыры и
куски файлов без звука. }
while Need > 0 do
begin
Chunk := SizeOf(Zero);
if Chunk > Need then
Chunk := Need;
All.WriteBuffer(Zero, Chunk);
Dec(Need, Chunk);
end;
{ Учёт работы: секунда звука весит столько же, сколько секунда
картинки на вырезании. }
FDoneWork := FDoneWork + FAudio[I].Duration;
PublishProgress;
end;
finally
All.Free;
end;
SetStage('Сжимаю звук');
FAudioPath := FCacheDir + 'audio.m4a';
Res := RunProcess(FfmpegExe,
ArgsEncodeAudio(RawPath, FAudioPath, Rate, Channels, FMaster.AudioCodec),
@ProgressLine, @ErrorLine, @CancelQuery);
if Res.Cancelled then
Exit;
if (not Res.Started) or (Res.ExitCode <> 0) or (not FileExists(FAudioPath)) then
begin
FReport.Message := 'Не удалось сжать звук.';
Exit;
end;
Result := True;
finally
SysUtils.DeleteFile(RawPath);
SysUtils.DeleteFile(PartPath);
end;
end;
{ Сведение слоёв звука в один поток.
Доля каждого слоя меняется от начала куска к концу: так получается
переход. Смешивание идёт по отсчётам, поэтому длина результата ровно
такая, какая нужна. }
function TExportThread.MixLayers(const ASeg: TFlatSegment; ADest: TStream;
ANeed: Int64; ARate, AChannels: Integer): Boolean;
var
L, I, N: Integer;
Parts: array of TMemoryStream;
PartPath: string;
Res: TProcResult;
Src: TFileStream;
Samples, S, Ch: Integer;
Acc: Double;
K: Double;
V: SmallInt;
Out16: SmallInt;
Buf: array of SmallInt;
begin
Result := False;
SetLength(Parts, Length(ASeg.Layers));
for I := 0 to High(Parts) do
Parts[I] := nil;
try
for L := 0 to High(ASeg.Layers) do
begin
PartPath := FCacheDir + Format('mix_%d.raw', [L]);
Res := RunProcess(FfmpegExe,
ArgsExtractAudioRaw(ASeg.Layers[L].FilePath, PartPath,
ASeg.Layers[L].SrcStart, ASeg.Duration, ARate, AChannels, 1),
nil, @ErrorLine, @CancelQuery);
Parts[L] := TMemoryStream.Create;
if Res.Started and (Res.ExitCode = 0) and FileExists(PartPath) then
begin
Src := TFileStream.Create(PartPath, fmOpenRead or fmShareDenyNone);
try
Parts[L].CopyFrom(Src, Src.Size);
finally
Src.Free;
end;
SysUtils.DeleteFile(PartPath);
end;
Parts[L].Position := 0;
end;
Samples := ANeed div (AChannels * 2);
if Samples <= 0 then
Exit;
SetLength(Buf, AChannels);
for S := 0 to Samples - 1 do
begin
for Ch := 0 to AChannels - 1 do
begin
Acc := 0;
for L := 0 to High(ASeg.Layers) do
begin
{ Доля слоя меняется линейно по ходу куска. }
if Samples > 1 then
K := ASeg.Layers[L].FadeFrom +
(ASeg.Layers[L].FadeTo - ASeg.Layers[L].FadeFrom) * S / (Samples - 1)
else
K := ASeg.Layers[L].FadeFrom;
N := (S * AChannels + Ch) * 2;
if N + 1 < Parts[L].Size then
begin
Move(PByte(Parts[L].Memory)[N], V, 2);
Acc := Acc + V * K;
end;
end;
if Acc > 32767 then
Acc := 32767;
if Acc < -32768 then
Acc := -32768;
Out16 := Round(Acc);
Buf[Ch] := Out16;
end;
ADest.WriteBuffer(Buf[0], AChannels * 2);
end;
Result := True;
finally
for I := 0 to High(Parts) do
Parts[I].Free;
end;
end;
function TExportThread.MuxFinal: Boolean;
var
Res: TProcResult;
begin
SetStage('Сшиваю');
Res := RunProcess(FfmpegExe,
ArgsMux(FVideoPath, FAudioPath, FOutputPath, FSubtitlePath),
@ProgressLine, @ErrorLine, @CancelQuery);
Result := Res.Started and (Res.ExitCode = 0) and FileExists(FOutputPath);
if Res.Cancelled then
Result := False
else if not Result then
FReport.Message := 'Не удалось сшить картинку со звуком.';
end;
procedure TExportThread.Execute;
var
ListPath: string;
I: Integer;
Freed: Int64;
ToolError: string;
begin
try
@ -688,16 +1258,40 @@ begin
Log('Временные куски: ' + FCacheDir);
ChooseIntermediateFormat;
{ Список готовых кусков — после выбора промежуточного формата: от него
зависят их имена. }
LoadDoneList;
if FResume and (FDone.Count > 1) then
Log(Format('Продолжаю прерванную сборку: готовых кусков %d.',
[FDone.Count - 1]));
{ Единица учёта работы — секунда материала. Каждая секунда проходит
через вырезание и через склейку, отсюда двойной вес. }
FTotalWork := FReport.TotalDuration * 2;
через вырезание, склейку и сборку звука, отсюда тройной вес. }
FTotalWork := FReport.TotalDuration * 3;
FDoneWork := 0;
if not ExtractSegments then
Exit;
if not WriteConcatList(ListPath) then
Exit;
{ Звук собирается раньше картинки, и склейка сразу кладёт всё вместе в
конечный файл.
Так было не всегда: сперва картинка собиралась в отдельный файл, а
потом сшивалась со звуком. На часовом ролике это 91 ГБ
промежуточного файла и лишний полный проход копирования — места
требовалось вдвое больше готового ролика. }
FVideoPath := '';
if not BuildAudio then
begin
if FReport.Cancelled then
Exit;
{ Без звука ролик всё равно лучше, чем ничего. }
Log('Звук собрать не удалось, собираю картинку без него.');
FAudioPath := '';
end;
if not ConcatAll(ListPath) then
Exit;
@ -712,18 +1306,38 @@ begin
FReport.Succeeded := True;
FReport.Message := 'Готово.';
{ Уборка после удачной сборки.
Куски весят столько же, сколько готовый ролик, а на длинном
материале и больше: на часовой съёмке набралось 110 ГБ. Держать их
после того, как ролик собран, незачем — продолжать больше нечего.
После неудачи, наоборот, не трогаем: с них и продолжим.
Считаем освобождённое: цифра говорит человеку, сколько места было
занято, лучше любого объяснения. }
if not FKeepIntermediate then
begin
Freed := 0;
for I := 0 to High(FReport.Segments) do
{ Удаляем только то, что сами создали в своём каталоге. Части,
указывающие на общий кэш сгенерированных вставок, трогать нельзя:
они нужны и предпросмотру, и следующей сборке. }
if Copy(FReport.Segments[I].TempPath, 1, Length(FCacheDir)) = FCacheDir then
begin
Inc(Freed, FileSizeOf(FReport.Segments[I].TempPath));
DeleteFile(FReport.Segments[I].TempPath);
end;
Inc(Freed, FileSizeOf(FAudioPath));
DeleteFile(FAudioPath);
DeleteFile(ListPath);
DeleteFile(FCacheDir + 'готово.txt');
RemoveDir(FCacheDir);
Log('Временные куски удалены.');
end;
Log(Format('Временные куски удалены, освободилось %s.',
[SizeText(Freed)]));
end
else
Log('Временные куски оставлены: ' + FCacheDir);
except
on E: Exception do
begin

322
src/uExportForm.pas Normal file
View file

@ -0,0 +1,322 @@
{
uExportForm — окно сборки ролика.
Здесь выбирается не только имя файла, но и то, что от него зависит:
контейнер и способ попадания текста в ролик. Разделять их нельзя —
выбор одного меняет допустимое в другом.
Почему при MP4 вжигание становится обязательным. Отдельной дорожкой
субтитров со всем оформлением умеет только Matroska. В MP4 их кладут
как mov_text, а он не хранит ни цвета, ни обводки, ни положения — от
надписи остаётся голый текст в углу, и это хуже, чем ничего. Поэтому
при MP4 галка ставится и запирается: выбора всё равно нет.
Запертой она остаётся ровно пока выбран MP4. Переключение на MKV
возвращает и галку, и прежний ответ человека — тот, что он давал до
того, как MP4 всё за него решил.
}
unit uExportForm;
{$mode objfpc}{$H+}
interface
uses
Classes, SysUtils, Controls, Forms, Graphics, StdCtrls, Dialogs,
uTheme;
type
{ Что человек выбрал в окне. }
TExportChoice = record
Path: string;
{ Вжигать ли текст в картинку. При MP4 всегда True. }
BurnText: Boolean;
{ Собирать только отмеченный участок. Имеет смысл, только когда
участок отмечен. }
RegionOnly: Boolean;
end;
{ Показывает окно.
AHasText — есть ли в проекте надписи: без них про текст и спрашивать
незачем. APreferMkv — в монтаже есть точные резы, и MKV надёжнее.
ABurnDefault — ответ, данный в прошлый раз.
ARegion — описание отмеченного участка. Не пусто — в окне появляется
галка «только отмеченный участок». Галка, а не надпись: надпись легко
проглядеть, а тут вопрос из тех, где ошибка стоит часа работы — соберёшь
кусок вместо ролика или наоборот. }
function AskExport(var AChoice: TExportChoice; AHasText, APreferMkv: Boolean;
const ADefaultDir: string; const ARegion: string = ''): Boolean;
implementation
type
TExportDialog = class(TComponent)
public
Form: TForm;
edPath: TEdit;
BtnMp4: TThemedButton;
BtnMkv: TThemedButton;
cbBurn: TCheckBox;
cbRegion: TCheckBox;
lblNote: TLabel;
HasText: Boolean;
{ Ответ человека про вжигание, сохранённый до того, как MP4 навязал
своё. К нему возвращаемся при переходе на MKV. }
BurnWanted: Boolean;
{ Идёт ли согласование: пока идёт, ответы человека не запоминаются.
Установка галки кодом вызывает тот же обработчик, что и щелчок, и
без этого признака навязанное при MP4 значение записывалось бы как
осознанный выбор — при возврате на MKV галка так и оставалась
стоять. }
Applying: Boolean;
procedure FormatClick(Sender: TObject);
procedure BrowseClick(Sender: TObject);
procedure BurnClick(Sender: TObject);
procedure RegionClick(Sender: TObject);
function IsMp4: Boolean;
procedure ApplyFormat;
end;
function TExportDialog.IsMp4: Boolean;
begin
Result := BtnMp4.Down;
end;
{ Приводит имя файла и галку в согласие с выбранным контейнером. }
procedure TExportDialog.ApplyFormat;
var
Ext: string;
begin
Applying := True;
try
if IsMp4 then
Ext := '.mp4'
else
Ext := '.mkv';
if Trim(edPath.Text) <> '' then
edPath.Text := ChangeFileExt(edPath.Text, Ext);
if not HasText then
begin
lblNote.Caption := '';
Exit;
end;
if IsMp4 then
begin
{ Сначала запираем, потом ставим: наоборот обработчик успел бы счесть
навязанное значение осознанным. }
cbBurn.Enabled := False;
cbBurn.Checked := True;
lblNote.Caption := 'В MP4 отдельная дорожка субтитров теряет цвет, ' +
'обводку и положение, поэтому текст вжигается. Участки с текстом ' +
'придётся пересобрать.';
end
else
begin
cbBurn.Enabled := True;
cbBurn.Checked := BurnWanted;
if cbBurn.Checked then
lblNote.Caption := 'Участки с текстом придётся пересобрать. ' +
'Текст будет виден в любом проигрывателе.'
else
lblNote.Caption := 'Текст ляжет отдельной дорожкой субтитров: ' +
'сборка останется мгновенной, но показывать его будет не всякий ' +
'проигрыватель.';
end;
finally
Applying := False;
end;
end;
procedure TExportDialog.FormatClick(Sender: TObject);
begin
BtnMp4.Down := Sender = BtnMp4;
BtnMkv.Down := Sender = BtnMkv;
ApplyFormat;
end;
procedure TExportDialog.BurnClick(Sender: TObject);
begin
if Applying then
Exit;
{ Запоминаем только осознанный ответ: при MP4 галка стоит не по воле
человека, и запоминать её значило бы соврать ему в следующий раз. }
if cbBurn.Enabled then
BurnWanted := cbBurn.Checked;
ApplyFormat;
end;
{ Галка участка ничего не пересчитывает: она только запоминает ответ.
Всё, что от неё зависит, решается уже после закрытия окна. }
procedure TExportDialog.RegionClick(Sender: TObject);
begin
end;
procedure TExportDialog.BrowseClick(Sender: TObject);
var
Dlg: TSaveDialog;
begin
Dlg := TSaveDialog.Create(nil);
try
Dlg.Title := 'Куда собрать ролик';
Dlg.Options := Dlg.Options + [ofOverwritePrompt, ofPathMustExist];
if IsMp4 then
begin
Dlg.DefaultExt := '.mp4';
Dlg.Filter := 'MP4|*.mp4';
end
else
begin
Dlg.DefaultExt := '.mkv';
Dlg.Filter := 'Matroska|*.mkv';
end;
Dlg.FileName := edPath.Text;
if Dlg.Execute then
edPath.Text := Dlg.FileName;
finally
Dlg.Free;
end;
end;
function AskExport(var AChoice: TExportChoice; AHasText, APreferMkv: Boolean;
const ADefaultDir: string; const ARegion: string): Boolean;
var
Dlg: TExportDialog;
Lbl: TLabel;
BtnBrowse, BtnOk, BtnCancel: TThemedButton;
Dir: string;
begin
Result := False;
Dlg := TExportDialog.Create(nil);
try
Dlg.HasText := AHasText;
Dlg.BurnWanted := AChoice.BurnText;
Dlg.Form := TForm.CreateNew(nil);
Dlg.Form.Caption := 'Сборка ролика';
Dlg.Form.Position := poScreenCenter;
Dlg.Form.ClientWidth := 620;
Dlg.Form.ClientHeight := 272;
Lbl := TLabel.Create(Dlg.Form);
Lbl.Parent := Dlg.Form;
Lbl.SetBounds(12, 12, 200, 20);
Lbl.Caption := 'Куда собрать';
Dlg.edPath := TEdit.Create(Dlg.Form);
Dlg.edPath.Parent := Dlg.Form;
Dlg.edPath.SetBounds(12, 34, 490, 26);
Dlg.edPath.Anchors := [akTop, akLeft, akRight];
BtnBrowse := TThemedButton.Create(Dlg.Form);
BtnBrowse.Parent := Dlg.Form;
BtnBrowse.SetBounds(510, 33, 96, 28);
BtnBrowse.Anchors := [akTop, akRight];
BtnBrowse.Caption := 'Обзор...';
BtnBrowse.OnClick := @Dlg.BrowseClick;
Lbl := TLabel.Create(Dlg.Form);
Lbl.Parent := Dlg.Form;
Lbl.SetBounds(12, 76, 100, 20);
Lbl.Caption := 'Контейнер';
Dlg.BtnMp4 := TThemedButton.Create(Dlg.Form);
Dlg.BtnMp4.Parent := Dlg.Form;
Dlg.BtnMp4.SetBounds(110, 72, 90, 28);
Dlg.BtnMp4.Caption := 'MP4';
Dlg.BtnMp4.TabStop := False;
Dlg.BtnMp4.OnClick := @Dlg.FormatClick;
Dlg.BtnMkv := TThemedButton.Create(Dlg.Form);
Dlg.BtnMkv.Parent := Dlg.Form;
Dlg.BtnMkv.SetBounds(206, 72, 90, 28);
Dlg.BtnMkv.Caption := 'MKV';
Dlg.BtnMkv.TabStop := False;
Dlg.BtnMkv.OnClick := @Dlg.FormatClick;
if ARegion <> '' then
begin
Lbl := TLabel.Create(Dlg.Form);
Lbl.Parent := Dlg.Form;
Lbl.SetBounds(310, 76, 300, 20);
Lbl.Anchors := [akTop, akLeft, akRight];
Lbl.Caption := 'Только участок: ' + ARegion;
Lbl.Font.Color := TColor($30A0D0);
end;
Dlg.cbRegion := TCheckBox.Create(Dlg.Form);
Dlg.cbRegion.Parent := Dlg.Form;
Dlg.cbRegion.SetBounds(12, 112, 596, 22);
Dlg.cbRegion.Anchors := [akTop, akLeft, akRight];
Dlg.cbRegion.Caption := 'Только отмеченный участок: ' + ARegion;
Dlg.cbRegion.Visible := ARegion <> '';
Dlg.cbRegion.Checked := ARegion <> '';
Dlg.cbRegion.OnClick := @Dlg.RegionClick;
Dlg.cbBurn := TCheckBox.Create(Dlg.Form);
Dlg.cbBurn.Parent := Dlg.Form;
Dlg.cbBurn.SetBounds(12, 138, 300, 22);
Dlg.cbBurn.Caption := 'Вжечь текст в картинку';
Dlg.cbBurn.Visible := AHasText;
Dlg.cbBurn.OnClick := @Dlg.BurnClick;
Dlg.lblNote := TLabel.Create(Dlg.Form);
Dlg.lblNote.Parent := Dlg.Form;
Dlg.lblNote.SetBounds(12, 164, 596, 52);
Dlg.lblNote.Anchors := [akTop, akLeft, akRight];
Dlg.lblNote.AutoSize := False;
Dlg.lblNote.WordWrap := True;
BtnOk := TThemedButton.Create(Dlg.Form);
BtnOk.Parent := Dlg.Form;
BtnOk.SetBounds(400, 230, 100, 30);
BtnOk.Anchors := [akRight, akBottom];
BtnOk.Caption := 'Собрать';
BtnOk.ModalResult := mrOk;
BtnOk.Default := True;
BtnCancel := TThemedButton.Create(Dlg.Form);
BtnCancel.Parent := Dlg.Form;
BtnCancel.SetBounds(508, 230, 100, 30);
BtnCancel.Anchors := [akRight, akBottom];
BtnCancel.Caption := 'Отмена';
BtnCancel.ModalResult := mrCancel;
BtnCancel.Cancel := True;
ApplyDarkTheme(Dlg.Form);
{ Начальное имя: где были в прошлый раз, с расширением по контейнеру. }
Dir := ADefaultDir;
if Dir = '' then
Dir := GetCurrentDir;
if AChoice.Path <> '' then
Dlg.edPath.Text := AChoice.Path
else
Dlg.edPath.Text := IncludeTrailingPathDelimiter(Dir) + 'ролик.mkv';
{ Точные резы просят Matroska: в MP4 перекодированный стык может
воспроизводиться неправильно. }
Dlg.BtnMkv.Down := APreferMkv or
(LowerCase(ExtractFileExt(Dlg.edPath.Text)) = '.mkv');
Dlg.BtnMp4.Down := not Dlg.BtnMkv.Down;
Dlg.ApplyFormat;
if Dlg.Form.ShowModal <> mrOk then
Exit;
AChoice.Path := Trim(Dlg.edPath.Text);
if AChoice.Path = '' then
Exit;
AChoice.BurnText := AHasText and Dlg.cbBurn.Checked;
AChoice.RegionOnly := (ARegion <> '') and Dlg.cbRegion.Checked;
Result := True;
finally
Dlg.Form.Free;
Dlg.Free;
end;
end;
end.

View file

@ -86,7 +86,13 @@ function ArgsTranscodeSegment(const ASrc, ADst: string; AStart, AEnd: Double;
AWidth, AHeight, AFpsNum, AFpsDen: Integer;
const AVideoCodec, APixFmt, AAudioCodec: string;
ASampleRate, AChannels: Integer; AVolume: Double;
const AFormat: string; const AFilter: string = ''): TArgs;
const AFormat: string; const AFilter: string = '';
const ASubFile: string = ''; ABitrate: Int64 = 0): TArgs;
{ Путь внутри строки фильтра. Двоеточие там разделяет настройки, обратная
косая и кавычка имеют своё значение — всё это надо обезвредить, иначе
путь с пробелом или двоеточием развалит разбор. }
function EscapeFilterPath(const APath: string): string;
{ Кадр одиночной картинки для окна кадрирования. }
function ArgsPreviewFrame(const ASrc, ADst: string; const ATime: Double;
@ -109,7 +115,68 @@ function TsSupportsCodecs(const AVideoCodec, AAudioCodec: string): Boolean;
{ Склеить куски по списку. Формат вывода ffmpeg определит по расширению. }
function ArgsConcat(const AListFile, ADst: string): TArgs;
{ Склейка сразу с готовым звуком и разметкой — прямо в конечный файл.
Зачем. Раньше картинка сначала собиралась в отдельный файл, а потом
сшивалась со звуком в конечный. На часовом ролике это означало 91 ГБ
промежуточного файла и лишний полный проход копирования: места нужно
вдвое больше готового ролика, и всё это ради того, чтобы приписать
звуковую дорожку.
Звук собирается раньше картинки, и склейка сразу кладёт всё вместе куда
надо. Пусто в AAudio — значит звука нет, тогда это обычная склейка. }
function ArgsConcatMux(const AListFile, AAudio, ASubFile, ADst: string): TArgs;
{ Склеить куски и сразу перекодировать картинку в заданный размер и поток.
Нужно для сборки «под площадку»: когда ролик надо отдать в 1080p с
определённым битрейтом, а исходники сняты в 2688x1512. Отдельный путь, а
не пересборка каждого куска по отдельности: здесь важно, чтобы весь
ролик получился одним кодировщиком с одними настройками — иначе на
стыках видно смену качества.
Кодировщик выбирается аппаратный: перекодирование целого ролика на
процессоре идёт медленнее реального времени, и смысл теряется. }
function ArgsConcatEncode(const AListFile, ADst: string;
AWidth, AHeight, AFpsNum, AFpsDen, ABitrateKbps: Integer;
const AVideoCodec, APixFmt: string; AHwDecode: Boolean = True;
const AAudio: string = ''): TArgs;
{ Перепаковать файл в другой контейнер без перекодирования. }
{ Звук куска в сыром виде: 16 бит со знаком, частота и число каналов
проекта. Сырой поток нужен потому, что куски потом просто дописываются
друг за другом байтами — так стык получается с точностью до отсчёта, а
не до звукового кадра. }
function ArgsExtractAudioRaw(const ASource, ATarget: string;
const AStart, ADuration: Double; ARate, AChannels: Integer;
const AVolume: Double): TArgs;
{ Сжатие собранного сырого звука. Один проход на весь ролик. }
function ArgsEncodeAudio(const ASource, ATarget: string;
ARate, AChannels: Integer; const ACodec: string): TArgs;
{ Пересборка участка с наложением: два слоя сводятся в один кадр.
Постоянная непрозрачность делается наложением с приглушённым альфа-
каналом, переход — фильтром xfade. Их нельзя перепутать: xfade умеет
плавно менять долю во времени, а простое наложение — нет.
ASubFile — разметка текста, которую надо вжечь в этот кусок. Пусто — не
вжигать. Без этого текст пропадал везде, где есть наложение: кусок шёл
этой веткой, а она про субтитры не знала. }
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;
{ Сшивание готовой картинки с готовым звуком. Оба потока копируются.
ASubFile — разметка субтитров, которую надо вложить отдельной дорожкой.
Пусто — не вкладывать. }
function ArgsMux(const AVideo, AAudio, ATarget: string;
const ASubFile: string = ''): TArgs;
function ArgsRemux(const ASrc, ADst: string): TArgs;
{ Сведения о файле в JSON. }
@ -148,7 +215,7 @@ function ArgsNormalize(const ASrc, ADst: string;
function ArgsImageClip(const ASrc, ADst: string; ADuration: Double;
AWidth, AHeight, AFpsNum, AFpsDen: Integer;
const AVideoCodec, APixFmt, AAudioCodec: string;
ASampleRate, AChannels: Integer): TArgs;
ASampleRate, AChannels: Integer; ABitrate: Int64 = 0): TArgs;
{ Сделать чёрный клип с тишиной заданной длины.
@ -159,6 +226,13 @@ function ArgsBlackClip(const ADst: string; ADuration: Double;
const AVideoCodec, APixFmt, AAudioCodec: string;
ASampleRate, AChannels: Integer): TArgs;
{ Вставка тишины для звуковой дорожки: только звук, без картинки.
Отдельно от чёрного клипа, потому что перемотка внутри видео
откатывается к опорному кадру, а в звуке опорным является каждый. }
function ArgsSilenceClip(const ADst: string; ADuration: Double;
const AAudioCodec: string; ASampleRate, AChannels: Integer): TArgs;
{ Кадр-миниатюра для списка файлов проекта.
Кадр берётся не с нуля: у многих съёмок первые секунды это крышка
@ -179,13 +253,204 @@ function ParseProgressLine(const ALine: string; var ASeconds: Double;
implementation
uses
StrUtils;
StrUtils, uTransform;
{ Ключи качества. Объявлено в самом начале: ими пользуется отрисовка
слоёв, а она по тексту раньше самой функции. }
function BitrateArgs(ABitrate: Int64): TArgs; forward;
{ Общие параметры вывода в формате проекта. Объявлено заранее, потому что
используется выше по тексту, чем определено. }
function ArgsExtractAudioRaw(const ASource, ATarget: string;
const AStart, ADuration: Double; ARate, AChannels: Integer;
const AVolume: Double): TArgs;
var
Filter: string;
begin
Result := ['-hide_banner', '-nostdin', '-loglevel', 'error', '-y',
'-ss', SecondsToStr(AStart), '-t', SecondsToStr(ADuration),
'-i', ASource, '-vn', '-map', '0:a:0?'];
{ Громкость применяется здесь же: звук всё равно перекодируется, второго
прохода ради неё не нужно. }
if Abs(AVolume - 1) > 0.001 then
begin
Filter := 'volume=' + SecondsToStr(AVolume);
Result := Concat(Result, ['-af', Filter]);
end;
Result := Concat(Result,
['-ac', IntToStr(AChannels), '-ar', IntToStr(ARate),
'-f', 's16le', ATarget]);
end;
function ArgsEncodeAudio(const ASource, ATarget: string;
ARate, AChannels: Integer; const ACodec: string): TArgs;
var
Codec: string;
begin
Codec := ACodec;
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];
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;
var
I: Integer;
Graph, Fit, Src, OutLabel: string;
Crossfade: Boolean;
begin
Result := ['-hide_banner', '-nostdin', '-loglevel', 'warning', '-y'];
{ Каждый слой — отдельный вход, вырезанный ровно по длине участка.
Неподвижная картинка — случай особый: её надо повторять ключом -loop,
иначе ffmpeg отдаёт один-единственный кадр, и весь кусок с наложением
схлопывается в одну шестидесятую секунды. Перематывать её тоже
незачем: у картинки нет времени. }
for I := 0 to High(ALayers) do
if ALayers[I].IsImage then
Result := Concat(Result,
['-loop', '1',
'-framerate', Format('%d/%d', [AFpsNum, AFpsDen]),
'-t', SecondsToStr(ADuration),
'-i', ALayers[I].FilePath])
else
Result := Concat(Result,
['-ss', SecondsToStr(ALayers[I].SrcStart),
'-t', SecondsToStr(ADuration),
'-i', ALayers[I].FilePath]);
{ Переход отличается от наложения тем, что доля меняется во времени. }
Crossfade := (Length(ALayers) = 2) and
((Abs(ALayers[1].FadeFrom - ALayers[1].FadeTo) > 0.001) or
(Abs(ALayers[0].FadeFrom - ALayers[0].FadeTo) > 0.001));
Graph := '';
for I := 0 to High(ALayers) do
begin
{ Нижний слой — холст, он занимает весь кадр, и поля ему нужны.
Верхнему поля вредны: чёрное поле непрозрачно и закрыло бы холст,
поэтому верхний кладётся на своё место как есть. Переход — случай
особый: xfade смешивает два кадра целиком, и оба должны быть в
размер проекта. }
if (I > 0) and (not Crossfade) and (ALayers[I].FitFilter <> '') then
Fit := ALayers[I].FitFilter
else
Fit := ALayers[I].Filter;
if Fit = '' then
Fit := Format('scale=%d:%d,setsar=1', [AWidth, AHeight]);
{ Цветовой ключ ставится после вписывания и до наложения: он должен
работать по кадру проекта, иначе смягчение края разъедется вместе с
масштабом. Цвет записывается как 0xRRGGBB — порядок обратный тому,
в каком он хранится у нас. }
if ALayers[I].KeyEnabled then
Fit := Fit + ',' + ColorKeyFilter(ALayers[I].KeyColor,
ALayers[I].KeySimilarity, ALayers[I].KeyBlend);
Graph := Graph + Format('[%d:v]%s,format=yuva420p[v%d];', [I, Fit, I]);
end;
if Length(ALayers) = 1 then
begin
Src := 'v0';
{ Ключ без нижнего слоя: выбитое надо чем-то заполнить, иначе при
переводе в кадр без прозрачности вернётся исходный цвет — тот
самый, который выбивали. Кладём на чёрное поле. }
if ALayers[0].KeyEnabled then
begin
Graph := Graph + Format('color=c=black:s=%dx%d:r=%d/%d:d=%s[bg];' +
'[bg][v0]overlay=0:0[k0];',
[AWidth, AHeight, AFpsNum, AFpsDen, SecondsToStr(ADuration)]);
Src := 'k0';
end;
if Abs(ALayers[0].FadeTo - ALayers[0].FadeFrom) < 0.001 then
Graph := Graph + Format('[%s]null[out]', [Src])
{ Один слой с меняющейся долей — это затухание в чёрное или из него. }
else if ALayers[0].FadeTo >= ALayers[0].FadeFrom then
Graph := Graph + Format('[%s]fade=t=in:st=0:d=%s:alpha=0[out]',
[Src, SecondsToStr(ADuration)])
else
Graph := Graph + Format('[%s]fade=t=out:st=0:d=%s:alpha=0[out]',
[Src, SecondsToStr(ADuration)]);
end
else if Crossfade then
Graph := Graph + Format('[v0][v1]xfade=transition=fade:duration=%s:offset=0[out]',
[SecondsToStr(ADuration)])
else
begin
{ Верхний слой приглушается по альфа-каналу и кладётся на нижний —
на своё место в кадре, теми же координатами, по которым его
показывает предпросмотр. }
Graph := Graph + Format('[v1]colorchannelmixer=aa=%s[t];' +
'[v0][t]overlay=%d:%d[out]',
[SecondsToStr(ALayers[High(ALayers)].Opacity),
ALayers[High(ALayers)].X, ALayers[High(ALayers)].Y]);
end;
{ Текст вжигается последним: он ложится поверх уже сведённой картинки,
как и в куске без наложения. Разметка приходит со сдвигом под этот
кусок, поэтому время в ней считается от его начала. }
if ASubFile <> '' then
Graph := Graph + Format(';[out]subtitles=filename=''%s''[outs]',
[EscapeFilterPath(ASubFile)]);
{ Кодировщик выбирается предпочтительный, а не тот, что ffmpeg возьмёт
по имени кодека. Имя «hevc» уводит его на процессорный x265, и участок
наложения считался медленнее реального времени: замерено 9.6 с
материала за 11.2 с работы. }
if ASubFile <> '' then
OutLabel := '[outs]'
else
OutLabel := '[out]';
Result := Concat(Result,
['-filter_complex', Graph, '-map', OutLabel,
'-t', SecondsToStr(ADuration),
'-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']);
if AIsTs then
Result := Concat(Result, ['-f', 'mpegts']);
Result := Concat(Result, [ATarget]);
end;
function ArgsMux(const AVideo, AAudio, ATarget: string;
const ASubFile: string): TArgs;
begin
Result := ['-hide_banner', '-nostdin', '-loglevel', 'warning', '-y',
'-i', AVideo, '-i', AAudio];
{ Субтитры отдельной дорожкой: ничего не перекодируется, проигрыватель
может их включить или выключить. Кодек ass сохраняет всё оформление —
в MP4 такого нет, там субтитры кладутся голым текстом, поэтому туда
их и не предлагают. }
if ASubFile <> '' then
Result := Concat(Result, ['-i', ASubFile]);
Result := Concat(Result, ['-map', '0:v:0', '-map', '1:a:0']);
if ASubFile <> '' then
Result := Concat(Result, ['-map', '2: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',
'-progress', 'pipe:1', '-nostats', ATarget]);
end;
function MasterOutputArgs(AWidth, AHeight, AFpsNum, AFpsDen: Integer;
const AVideoCodec, APixFmt, AAudioCodec: string;
ASampleRate, AChannels: Integer): TArgs; forward;
ASampleRate, AChannels: Integer; ABitrate: Int64 = 0): TArgs; forward;
{ Запрет двунаправленных кадров. Объявлено заранее по той же причине. }
function NoBFramesArgs: TArgs; forward;
@ -201,7 +466,7 @@ function LocateTool(const AName: string): string;
var
Own: string;
begin
Own := ExtractFilePath(ExpandFileName(ParamStr(0))) + AName
Own := ProgramDir + AName
{$IFDEF WINDOWS} + '.exe'{$ENDIF};
if FileExists(Own) then
Exit(Own);
@ -387,11 +652,21 @@ begin
Result := Concat(Result, ['-f', AFormat, ADst]);
end;
function EscapeFilterPath(const APath: string): string;
begin
Result := StringReplace(APath, '\', '/', [rfReplaceAll]);
Result := StringReplace(Result, '''', '\''', [rfReplaceAll]);
Result := StringReplace(Result, ':', '\:', [rfReplaceAll]);
end;
function ArgsTranscodeSegment(const ASrc, ADst: string; AStart, AEnd: Double;
AWidth, AHeight, AFpsNum, AFpsDen: Integer;
const AVideoCodec, APixFmt, AAudioCodec: string;
ASampleRate, AChannels: Integer; AVolume: Double;
const AFormat: string; const AFilter: string): TArgs;
const AFormat: string; const AFilter: string;
const ASubFile: string; ABitrate: Int64): TArgs;
var
Chain: string;
begin
Result := Concat(CommonFfmpegPrefix, [
'-ss', SecondsToStr(AStart),
@ -404,17 +679,26 @@ begin
{ Как именно кадр ложится в кадр проекта, решено заранее и передано
готовой цепочкой фильтров. }
if AFilter <> '' then
Result := Concat(Result, ['-vf', AFilter])
Chain := AFilter
else
Result := Concat(Result, ['-vf', Format(
'scale=%d:%d:force_original_aspect_ratio=decrease,pad=%d:%d:(ow-iw)/2:(oh-ih)/2,setsar=1',
[AWidth, AHeight, AWidth, AHeight])]);
Chain := Format(
'scale=%d:%d:force_original_aspect_ratio=decrease,' +
'pad=%d:%d:(ow-iw)/2:(oh-ih)/2,setsar=1',
[AWidth, AHeight, AWidth, AHeight]);
{ Текст рисуется последним — уже по кадру проекта. Раньше он попал бы
под масштабирование и уехал бы вместе с картинкой. }
if ASubFile <> '' then
Chain := Chain + ',subtitles=filename=''' +
EscapeFilterPath(ASubFile) + '''';
Result := Concat(Result, ['-vf', Chain]);
if Abs(AVolume - 1) >= 0.001 then
Result := Concat(Result, ['-af', 'volume=' + SecondsToStr(AVolume)]);
Result := Concat(Result, MasterOutputArgs(0, 0, AFpsNum, AFpsDen,
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);
@ -436,6 +720,72 @@ begin
(AAudioCodec = 'ac3') or (AAudioCodec = 'mp2');
end;
function ArgsConcatEncode(const AListFile, ADst: string;
AWidth, AHeight, AFpsNum, AFpsDen, ABitrateKbps: Integer;
const AVideoCodec, APixFmt: string; AHwDecode: Boolean;
const AAudio: string): TArgs;
var
Fps, Enc: string;
begin
Enc := PreferredEncoder(AVideoCodec);
Result := Concat(CommonFfmpegPrefix, []);
{ Распаковка тоже на видеокарте. Замерено на паре кусков 2688x1512:
9.18 с без неё против 1.77 с с ней, впятеро.
Кадры при этом всё равно возвращаются в обычную память, а масштаб
считает процессор. Полностью карточный путь через scale_cuda в том же
замере оказался медленнее (5.74 с) — видимо, из-за преобразований
формата, и городить его незачем.
Признак необязательный: если распаковка не пойдёт, вызывающий
повторит без неё. }
if AHwDecode and (Pos('nvenc', Enc) > 0) then
Result := Concat(Result, ['-hwaccel', 'cuda']);
Result := Concat(Result, ['-f', 'concat', '-safe', '0', '-i', AListFile]);
{ Готовый звук подмешивается прямо здесь, чтобы не собирать картинку в
промежуточный файл ради одной звуковой дорожки. }
if AAudio <> '' then
Result := Concat(Result, ['-i', AAudio, '-map', '0:v:0', '-map', '1:a:0'])
else
Result := Concat(Result, ['-map', '0:v:0', '-map', '0:a:0?']);
{ Вписываем целиком и добиваем полями: соотношение сторон менять нельзя,
иначе кадр растянется. }
if (AWidth > 0) and (AHeight > 0) then
Result := Concat(Result, ['-vf', Format(
'scale=%d:%d:force_original_aspect_ratio=decrease,' +
'pad=%d:%d:(ow-iw)/2:(oh-ih)/2,setsar=1',
[AWidth, AHeight, AWidth, AHeight])]);
Result := Concat(Result, ['-c:v', Enc]);
if ABitrateKbps > 0 then
{ Верхняя граница и запас буфера задаются вместе со средним потоком:
без них кодировщик волен выдать всплеск вдвое выше заказанного. }
Result := Concat(Result, [
'-b:v', IntToStr(ABitrateKbps) + 'k',
'-maxrate', IntToStr(Round(ABitrateKbps * 1.5)) + 'k',
'-bufsize', IntToStr(ABitrateKbps * 2) + 'k']);
Fps := '';
if (AFpsNum > 0) and (AFpsDen > 0) then
Fps := IntToStr(AFpsNum) + '/' + IntToStr(AFpsDen);
if Fps <> '' then
Result := Concat(Result, ['-r', Fps]);
if APixFmt <> '' then
Result := Concat(Result, ['-pix_fmt', APixFmt]);
{ Звук уже собран и сжат отдельным проходом — трогать его незачем. }
Result := Concat(Result, ['-c:a', 'copy']);
Result := Concat(Result, ProgressArgs);
if WantsFaststart(ADst) then
Result := Concat(Result, ['-movflags', '+faststart']);
Result := Concat(Result, [ADst]);
end;
function ArgsConcat(const AListFile, ADst: string): TArgs;
begin
Result := Concat(CommonFfmpegPrefix, [
@ -453,6 +803,46 @@ begin
Result := Concat(Result, [ADst]);
end;
function ArgsConcatMux(const AListFile, AAudio, ASubFile, ADst: string): TArgs;
var
Next: Integer;
begin
Result := Concat(CommonFfmpegPrefix, [
'-f', 'concat',
{ Без -safe 0 демультиплексор откажется брать абсолютные пути. }
'-safe', '0',
'-i', AListFile
]);
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']);
Result := Concat(Result, ProgressArgs);
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, [
@ -623,9 +1013,31 @@ begin
end;
{ Реализация объявленной выше функции. }
{ Ключи качества для пересобираемых кусков.
Без них кодировщик берёт своё значение по умолчанию, а оно скромное: на
съёмке 2688x1512 с 56 Мбит/с NVENC выдавал 5.9 Мбит/с, и пересобранный
кусок бросался в глаза среди скопированных. Поэтому целимся в тот же
битрейт, что у исходного материала.
Кроме среднего задаётся потолок и размер буфера: без них кодировщик
разгоняется на сложных кадрах ровно настолько, насколько ему позволит
средний, и мелкие детали всё равно пропадают. }
function BitrateArgs(ABitrate: Int64): TArgs;
begin
Result := [];
{ Ниже ста килобит в секунду — это не битрейт, а неизвестность: ffprobe
не всегда его сообщает. Тогда оставляем решение кодировщику. }
if ABitrate < 100000 then
Exit;
Result := ['-b:v', IntToStr(ABitrate div 1000) + 'k',
'-maxrate', IntToStr(ABitrate * 3 div 2000) + 'k',
'-bufsize', IntToStr(ABitrate div 500) + 'k'];
end;
function MasterOutputArgs(AWidth, AHeight, AFpsNum, AFpsDen: Integer;
const AVideoCodec, APixFmt, AAudioCodec: string;
ASampleRate, AChannels: Integer): TArgs;
ASampleRate, AChannels: Integer; ABitrate: Int64): TArgs;
var
Fps: string;
begin
@ -641,6 +1053,7 @@ begin
Result := Concat(Result, ['-r', Fps]);
if APixFmt <> '' then
Result := Concat(Result, ['-pix_fmt', APixFmt]);
Result := Concat(Result, BitrateArgs(ABitrate));
if AAudioCodec <> '' then
begin
@ -673,7 +1086,7 @@ end;
function ArgsImageClip(const ASrc, ADst: string; ADuration: Double;
AWidth, AHeight, AFpsNum, AFpsDen: Integer;
const AVideoCodec, APixFmt, AAudioCodec: string;
ASampleRate, AChannels: Integer): TArgs;
ASampleRate, AChannels: Integer; ABitrate: Int64): TArgs;
begin
{ -loop 1 повторяет единственный кадр, -t задаёт длину.
Картинка вписывается в кадр целиком с чёрными полями по краям:
@ -696,7 +1109,7 @@ begin
[AWidth, AHeight, AWidth, AHeight])]);
Result := Concat(Result, MasterOutputArgs(0, 0, AFpsNum, AFpsDen,
AVideoCodec, APixFmt, AAudioCodec, ASampleRate, AChannels));
AVideoCodec, APixFmt, AAudioCodec, ASampleRate, AChannels, ABitrate));
Result := Concat(Result, NoBFramesArgs);
Result := Concat(Result, ['-shortest']);
Result := Concat(Result, ProgressArgs);
@ -733,6 +1146,32 @@ begin
Result := Concat(Result, [ADst]);
end;
function ArgsSilenceClip(const ADst: string; ADuration: Double;
const AAudioCodec: string; ASampleRate, AChannels: Integer): TArgs;
var
Codec: string;
Ch: Integer;
begin
Codec := AAudioCodec;
if Codec = '' then
Codec := 'aac';
Ch := AChannels;
if Ch < 1 then
Ch := 2;
Result := Concat(CommonFfmpegPrefix, [
'-f', 'lavfi',
'-i', Format('anullsrc=channel_layout=%s:sample_rate=%d',
[IfThen(Ch = 1, 'mono', 'stereo'), ASampleRate]),
'-c:a', Codec,
'-ac', IntToStr(Ch),
'-ar', IntToStr(ASampleRate),
'-t', SecondsToStr(ADuration)
]);
Result := Concat(Result, ProgressArgs);
Result := Concat(Result, [ADst]);
end;
function ArgsThumbnail(const ASrc, ADst: string; const ATime: Double;
AWidth, AHeight: Integer): TArgs;
begin

File diff suppressed because it is too large Load diff

View file

@ -53,6 +53,14 @@ type
property StageText: string read FStageText;
property Fraction: Double read FFraction;
{ Сколько всего заказано и сколько уже сделано. Нужно показателю
работы: по ним он знает, докуда ползти до следующей вехи. }
function Total: Integer;
property Done: Integer read FCurrent;
{ Место на монтаже, которое считается прямо сейчас. Ноль в обоих —
значит, к монтажу это отношения не имеет. }
function BusyFrom: Double;
function BusyTo: Double;
property PendingLog: string read FPendingLog;
property Succeeded: Boolean read FSucceeded;
property Cancelled: Boolean read FCancelled;
@ -113,6 +121,27 @@ begin
Log('ffmpeg: ' + ALine);
end;
function TGenerateThread.Total: Integer;
begin
Result := Length(FRequests);
end;
function TGenerateThread.BusyFrom: Double;
begin
if (FCurrent >= 0) and (FCurrent <= High(FRequests)) then
Result := FRequests[FCurrent].TlStart
else
Result := 0;
end;
function TGenerateThread.BusyTo: Double;
begin
if (FCurrent >= 0) and (FCurrent <= High(FRequests)) then
Result := FRequests[FCurrent].TlEnd
else
Result := 0;
end;
procedure TGenerateThread.Publish;
begin
if Length(FRequests) > 0 then
@ -175,6 +204,12 @@ begin
gkImage:
FStageText := Format('Делаю клип из картинки %d из %d',
[I + 1, Length(FRequests)]);
gkSilence:
FStageText := Format('Делаю вставку тишины %d из %d',
[I + 1, Length(FRequests)]);
gkLayers:
FStageText := Format('Собираю наложение %d из %d',
[I + 1, Length(FRequests)]);
end;
Publish;
@ -191,11 +226,26 @@ begin
FMaster.VideoCodec, FMaster.PixFmt, AudioCodec,
FMaster.SampleRate, FMaster.Channels);
gkImage:
{ Фотография попадает в ролик наравне со съёмкой, и жать её
сильнее, чем её соседей, незачем. }
Args := ArgsImageClip(FRequests[I].SourcePath, FRequests[I].TargetPath,
FRequests[I].Duration,
FMaster.Width, FMaster.Height, FMaster.FpsNum, FMaster.FpsDen,
FMaster.VideoCodec, FMaster.PixFmt, AudioCodec,
FMaster.SampleRate, FMaster.Channels, FMaster.VideoBitrate);
gkSilence:
Args := ArgsSilenceClip(FRequests[I].TargetPath,
FRequests[I].Duration, AudioCodec,
FMaster.SampleRate, FMaster.Channels);
gkLayers:
{ Тот же расчёт, что и при сборке ролика: прозрачность —
наложением с приглушённым альфа-каналом, переход — фильтром
xfade. Разными путями считать одно и то же нельзя, иначе
предпросмотр разойдётся с готовым роликом. }
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);
else
Continue;
end;

488
src/uIconData.pas Normal file
View file

@ -0,0 +1,488 @@
{
uIconData — значки, вшитые в программу.
Собран из файлов в icons_online командой:
python3 tools/make_icons.py
Правится не он, а исходные картинки: этот файл переписывается целиком.
Значки взяты у Icons8 и требуют указания авторства — оно есть в окне
«О программе», вкладка «Библиотеки и авторство». Ссылки на каждый значок
лежат в icons_online/attribution.txt.
Картинки чёрные на прозрачном фоне. Цвет им задаётся при отрисовке:
берётся прозрачность точки, а цвет — тот, каким сейчас рисуется кнопка.
Поэтому один и тот же значок годится и для тёмной кнопки, и для
оранжевой.
}
unit uIconData;
{$mode objfpc}{$H+}
interface
const
{ icons8-магнит-30.png, 24 на 24 точек }
IconSnapData: array[0..472] 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, $08, $1F, $07, $2E, $1B, $C7,
$2C, $48, $09, $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,
$AD, $14, $AA, $A0, $B6, $E6, $39, $24, $17, $AE, $69, $32, $88, $D4, $77, $4B,
$EE, $DD, $EF, $7F, $EF, $DD, $FB, $07, $66, $1D, $6A, $6A, $EF, $B2, $4D, $85,
$22, $F3, $74, $B9, $E3, $9A, $1E, $92, $2D, $5F, $E0, $80, $0B, $9E, $78, $27,
$40, $F8, $E4, $85, $4B, $0E, $71, $B3, $F0, $35, $4E, $78, $45, $12, $6B, $40,
$83, $62, $1A, $BE, $44, $83, $AF, $29, $5C, $10, $46, $9C, $B2, $9C, $C4, $1D,
$EA, $0C, $53, $71, $41, $78, $A3, $8E, $33, $29, $28, $E1, $67, $E2, $82, $E0,
$53, $0A, $41, $2B, $9A, $CD, $1E, $65, $43, $3E, $C0, $C7, $67, $60, $64, $CA,
$EC, $86, $13, $D5, $82, $2A, $B9, $F8, $B0, $C7, $11, $35, $6A, $1C, $D3, $8F,
$73, $39, $AA, $A6, $05, $16, $9E, $51, $BE, $49, $01, $80, $02, $4D, $23, $EB,
$85, $97, $EB, $0A, $E6, $AC, $3B, $D1, $CD, $7D, $3A, $46, $D6, $35, $5B, $9A,
$74, $5C, $52, $BE, $62, $C2, $E2, $97, $31, $7B, $81, $F6, $4F, $F1, $41, $1B,
$61, $8E, $AD, $04, $71, $C3, $08, $C5, $86, $7E, $83, $16, $D8, $DC, $B3, $C3,
$37, $AB, $3C, $18, $03, $50, $0C, $D9, $A7, $8B, $4D, $0B, $FB, $8F, $2D, $8D,
$D9, $A4, $85, $E0, $90, $27, $88, $4F, $03, $F2, $78, $8C, $51, $54, $B8, $D5,
$AD, $84, $A5, $D7, $59, $C4, $02, $84, $47, $CE, $68, $47, $82, $02, $2B, $11,
$F1, $CC, $39, $57, $88, $B6, $43, $A1, $8C, $CE, $83, $D8, $30, $65, $B4, $1C,
$FE, $20, $FF, $10, $3F, $90, $74, $7D, $D8, $DF, $AB, $6B, $DD, $00, $00, $00,
$00, $49, $45, $4E, $44, $AE, $42, $60, $82
);
{ icons8-merge-vertical-64.png, 24 на 24 точек }
IconRippleData: array[0..457] 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, $08, $1F, $07, $2E, $1B, $C7,
$2C, $48, $09, $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,
$24, $35, $F4, $B4, $8F, $A1, $8A, $7A, $5A, $D2, $45, $FC, $C7, $73, $DF, $D7,
$7D, $AE, $EB, $3E, $87, $2E, $89, $52, $27, $3D, $7A, $50, $43, $2F, $EA, $EA,
$BF, $8D, $28, $3A, $B6, $A9, $5F, $BF, $4D, $C7, $8A, $DF, $CB, $71, $4A, $90,
$33, $27, $2B, $C6, $AC, $49, $BB, $69, $03, $5D, $F2, $77, $82, $A8, $CD, $BE,
$BE, $F2, $51, $6F, $0A, $46, $2D, $E8, $EB, $D8, $1E, $2B, $1A, $69, $15, $0C,
$DB, $B6, $66, $A8, $6D, $7B, $D6, $AA, $1D, $F9, $A6, $B6, $60, $40, $50, $90,
$55, $36, $A3, $FC, $CD, $5A, $64, $C2, $BA, $79, $91, $82, $07, $B1, $67, $F6,
$54, $BC, $78, $95, $48, $24, $AE, $1D, $49, $9C, $C9, $C8, $38, $93, $38, $74,
$F5, $5E, $79, $F5, $A2, $62, $2F, $16, $54, $D5, $BE, $CC, $4C, $3A, $E4, $08,
$AA, $EA, $02, $79, $E3, $C6, $94, $D4, $04, $07, $26, $2D, $B7, $DC, $B0, $6C,
$C2, $BE, $A0, $A6, $64, $CC, $B8, $7C, $EC, $E6, $3D, $F4, $93, $2D, $1B, $1E,
$4D, $B7, $CC, $4D, $9C, $5B, $B2, $A2, $E4, $C6, $45, $23, $74, $83, $5B, $8B,
$4E, $54, $DB, $9A, $79, $B2, $EE, $D4, $5D, $73, $4B, $0D, $2E, $5D, $76, $74,
$4F, $70, $F4, $F9, $24, $3F, $47, $4D, $AD, $E2, $1F, $FF, $D6, $4F, $AA, $2A,
$EE, $05, $C1, $BD, $4A, $87, $BD, $B5, $30, $68, $4A, $4E, $24, $92, $33, $65,
$B0, $5B, $07, $29, $DE, $00, $F6, $77, $56, $B3, $E2, $94, $4B, $80, $00, $00,
$00, $00, $49, $45, $4E, $44, $AE, $42, $60, $82
);
{ icons8-property-30.png, 24 на 24 точек }
IconLogData: array[0..425] 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, $08, $1F, $07, $2E, $1B, $C7,
$2C, $48, $09, $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,
$3A, $28, $AD, $F7, $18, $AA, $F5, $36, $DA, $B7, $25, $3C, $67, $38, $27, $4F,
$9E, $EF, $79, $FE, $9C, $F3, $E3, $BF, $2D, $42, $24, $12, $8D, $10, $1B, $04,
$21, $8B, $39, $5B, $96, $65, $84, $D4, $8B, $63, $E7, $F6, $3C, $42, $51, $A3,
$4D, $0F, $59, $2F, $36, $C8, $60, $C6, $C4, $48, $E5, $4F, $CA, $B5, $81, $1F,
$59, $F6, $73, $7F, $D6, $1C, $12, $39, $2E, $97, $04, $8A, $2E, $BA, $E7, $7E,
$D6, $B2, $E4, $38, $09, $D4, $3C, $0D, $C9, $50, $EB, $2D, $09, $F2, $0A, $0E,
$B0, $69, $36, $E1, $6D, $3A, $72, $F2, $BD, $07, $98, $B7, $EA, $50, $B0, $66,
$B1, $FB, $26, $91, $86, $DB, $41, $C0, $A9, $92, $86, $60, $A5, $67, $76, $C1,
$5B, $BF, $29, $B5, $33, $E4, $95, $50, $90, $4B, $64, $68, $B9, $74, $DD, $1F,
$C8, $DB, $76, $23, $D8, $B5, $90, $F0, $BE, $DA, $19, $04, $9C, $B9, $73, $8F,
$75, $53, $09, $6F, $EC, $61, $50, $49, $55, $55, $70, $95, $36, $DD, $5F, $7F,
$8D, $08, $63, $29, $71, $EF, $1D, $C5, $74, $80, $7D, $F5, $54, $11, $05, $D3,
$5F, $40, $45, $59, $3C, $44, $73, $91, $BA, $B2, $4A, $47, $A2, $A3, $76, $12,
$A7, $AA, $F2, $8F, $EC, $03, $0F, $C8, $51, $5A, $EC, $EA, $63, $7C, $00, $00,
$00, $00, $49, $45, $4E, $44, $AE, $42, $60, $82
);
{ icons8-клей-30.png, 24 на 24 точек }
IconGlueData: array[0..513] 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, $08, $1F, $07, $2E, $1B, $C7,
$2C, $48, $09, $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,
$0E, $62, $59, $1C, $24, $18, $35, $61, $50, $04, $C2, $F3, $DC, $0E, $DE, $9A,
$96, $C2, $23, $0B, $67, $3D, $D7, $FF, $3A, $D7, $FD, $72, $38, $EF, $BA, $70,
$6A, $67, $D4, $23, $15, $87, $3E, $0B, $67, $31, $1A, $B7, $E8, $97, $D4, $86,
$AA, $8B, $FF, $97, $97, $2D, $49, $05, $41, $B0, $A5, $66, $20, $5B, $3E, $69,
$51, $12, $E5, $41, $F0, $31, $1B, $29, $47, $79, $DA, $85, $6C, $AA, $9D, $76,
$DA, $09, $6D, $89, $23, $0B, $66, $BC, $ED, $42, $3A, $AA, $27, $21, $A3, $9A,
$52, $41, $CB, $35, $DC, $F0, $A6, $6B, $CE, $86, $5B, $FD, $C0, $53, $FB, $82,
$A0, $A1, $00, $6E, $F6, $20, $CF, $21, $DF, $03, $14, $0D, $81, $FB, $1E, $18,
$C4, $7B, $73, $DE, $C5, $DE, $81, $DD, $FE, $09, $B7, $AD, $47, $B7, $1D, $4F,
$0C, $21, $A7, $2E, $15, $A4, $9A, $AE, $F6, $8A, $07, $8C, $B9, $A2, $A2, $13,
$91, $2F, $66, $15, $8C, $59, $10, $24, $DA, $26, $7A, $E5, $79, $53, $56, $CD,
$2B, $A9, $FA, $10, $91, $6D, $F3, $96, $1C, $49, $B4, $4C, $1E, $0F, $33, $6C,
$59, $F0, $43, $C3, $88, $7B, $36, $BB, $2E, $34, $D1, $72, $BD, $3F, $7D, $D1,
$9A, $20, $D8, $F3, $D2, $88, $EA, $BF, $60, $A9, $76, $BF, $FB, $9F, $48, $75,
$3F, $23, $D2, $50, $52, $B1, $2E, $B5, $A7, $79, $3C, $FB, $DF, $9F, $38, $A8,
$1C, $5F, $B2, $E0, $B1, $9C, $17, $1E, $BA, $6B, $D7, $2B, $9F, $4E, $F2, $CF,
$99, $F6, $4D, $B0, $1F, $A7, $AC, $1A, $97, $59, $25, $2B, $82, $AF, $9E, $A9,
$5B, $B3, $6C, $2A, $63, $B1, $C0, $8C, $8E, $15, $35, $97, $E4, $15, $0D, $67,
$C9, $73, $E0, $8E, $CB, $5E, $FB, $7E, $B6, $65, $3C, $EF, $FA, $0D, $91, $67,
$97, $14, $98, $55, $D3, $54, $00, $00, $00, $00, $49, $45, $4E, $44, $AE, $42,
$60, $82
);
{ icons8-цель-30.png, 24 на 24 точек }
IconTargetData: array[0..779] 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, $08, $1F, $07, $2E, $1B, $C7,
$2C, $48, $09, $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,
$44, $86, $D9, $4D, $9B, $0E, $3C, $4D, $58, $87, $14, $75, $22, $C8, $DC, $9C,
$2B, $78, $4A, $D9, $84, $04, $6B, $93, $B5, $34, $15, $BB, $FE, $6B, $65, $5D,
$B5, $F1, $F0, $67, $AC, $DD, $40, $D8, $73, $FB, $E0, $7B, $78, $DF, $E7, $7D,
$DE, $E7, $E5, $1E, $11, $B9, $EB, $9D, $B0, $43, $46, $1C, $81, $69, $57, $2D,
$68, $AE, $FD, $B0, $01, $3C, $67, $97, $1F, $3C, $E1, $8C, $59, $AF, $3A, $20,
$23, $26, $22, $30, $E3, $8A, $AF, $9D, $31, $77, $9B, $70, $1F, $D8, $E7, $4D,
$3D, $0E, $DA, $62, $DC, $92, $55, $C3, $4E, $18, $71, $C1, $8A, $27, $E5, $ED,
$35, $A9, $BE, $9E, $70, $53, $8F, $9C, $16, $BB, $B4, $29, $B9, $EC, $2F, $AB,
$96, $D5, $7C, $67, $C4, $8A, $17, $F4, $FA, $5D, $65, $6D, $6B, $AF, $59, $D2,
$D4, $D4, $54, $13, $D7, $6F, $D2, $8A, $9B, $AA, $4E, $D9, $2F, $29, $AF, $6C,
$42, $4F, $A8, $37, $AC, $30, $23, $65, $B7, $28, $56, $7C, $26, $EA, $5F, $A3,
$CE, $F9, $D3, $D3, $06, $6D, $74, $DA, $55, $03, $1E, $F1, $AD, $BF, $43, $42,
$C4, $0D, $E3, $5A, $65, $FD, $E3, $41, $71, $A3, $C6, $FC, $EC, $8A, $F3, $C6,
$A4, $1C, $D2, $EA, $0B, $6D, $06, $5C, $F3, $4B, $D8, $50, $D2, $3B, $9E, $F1,
$80, $B8, $AC, $93, $16, $14, $6D, $B5, $DB, $59, $25, $DD, $D2, $4A, $1A, $06,
$75, $A8, $38, $2D, $11, $56, $E8, $F2, $B6, $55, $97, $3D, $25, $6A, $D4, $66,
$07, $6C, $31, $66, $D2, $CB, $FA, $9D, $F7, $A3, $7D, $36, $3A, $27, $E3, $31,
$17, $5C, $83, $82, $79, $07, $ED, $F4, $9B, $8F, $B4, $48, $F9, $D4, $BC, $A2,
$94, $01, $93, $86, $B5, $2B, $AA, $EA, $74, $C8, $BC, $42, $68, $5C, $4C, $4C,
$5D, $9B, $AC, $39, $4D, $DB, $BC, $2F, $E2, $25, $81, $0F, $8C, $7B, $D6, $B2,
$8A, $C3, $EE, $57, $17, $13, $BB, $E5, $74, $B8, $22, $4D, $4D, $31, $43, $4E,
$38, $22, $B0, $24, $22, $72, $F7, $F2, $6C, $C0, $A2, $45, $59, $B3, $EA, $B6,
$6B, $71, $DD, $C7, $8E, $18, $42, $C1, $5E, $DF, $6B, $B5, $5D, $CD, $0D, $3B,
$2D, $5A, $24, $8A, $69, $33, $72, $16, $5C, $D4, $2B, $A3, $88, $4F, $7C, $6E,
$D8, $90, $C0, $49, $DB, $F4, $BA, $E8, $BA, $9C, $E9, $50, $32, $09, $5F, $AA,
$78, $D8, $7E, $73, $4A, $D2, $BA, $95, $D4, $D4, $0C, $EB, $96, $36, $A2, $A1,
$A0, $43, $35, $1C, $6B, $88, $57, $34, $BC, $2B, $E9, $B8, $40, $49, $97, $B4,
$84, $A4, $B4, $2E, $25, $81, $63, $12, $8E, $9A, $75, $F8, $B6, $92, $AD, $BE,
$32, $25, $AF, $DD, $71, $0D, $55, $45, $AF, $7B, $43, $51, $55, $C3, $31, $ED,
$FA, $4C, $39, $2B, $B5, $36, $36, $3D, $26, $94, $F5, $49, $1A, $74, $4A, $C5,
$B2, $65, $15, $23, $0A, $12, $FA, $94, $95, $3D, $BE, $7E, $5E, $51, $79, $13,
$A6, $BC, $A7, $43, $46, $A7, $3D, $F6, $E8, $94, $D1, $E1, $A8, $9A, $B2, $17,
$45, $EF, $8C, $68, $44, $CE, $5B, $72, $02, $DF, $B8, $A4, $8E, $87, $E4, $3C,
$6F, $93, $9F, $7C, $E8, $D2, $AD, $34, $AC, $B7, $65, $B3, $7E, $79, $8F, $CA,
$88, $B1, $26, $A2, $7F, $FC, $DF, $11, $D8, $64, $87, $AC, $B8, $A6, $C0, $B4,
$5F, $EF, $3C, $02, $F7, $8C, $FF, $00, $D3, $AE, $CE, $9E, $6E, $47, $CD, $DC,
$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,
$00, $00, $16, $00, $00, $00, $28, $00, $00, $00, $20, $00, $00, $00, $40, $00,
$00, $00, $01, $00, $20, $00, $00, $00, $00, $00, $00, $10, $00, $00, $13, $0B,
$00, $00, $13, $0B, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $37, $00, $00, $00, $A1, $00, $00, $00, $BB, $00, $00,
$00, $BA, $00, $00, $00, $BA, $00, $00, $00, $BA, $00, $00, $00, $BA, $00, $00,
$00, $BA, $00, $00, $00, $BA, $00, $00, $00, $BA, $00, $00, $00, $BA, $00, $00,
$00, $BB, $00, $00, $00, $A1, $00, $00, $00, $37, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $51, $00, $00, $00, $E5, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $E5, $00, $00, $00, $51, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $1E, $00, $00, $00, $58, $00, $00, $00, $66, $00, $00,
$00, $66, $00, $00, $00, $66, $00, $00, $00, $66, $00, $00, $00, $66, $00, $00,
$00, $66, $00, $00, $00, $66, $00, $00, $00, $66, $00, $00, $00, $66, $00, $00,
$00, $66, $00, $00, $00, $58, $00, $00, $00, $1E, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $15, $00, $00,
$00, $54, $00, $00, $00, $74, $00, $00, $00, $77, $00, $00, $00, $77, $00, $00,
$00, $77, $00, $00, $00, $77, $00, $00, $00, $77, $00, $00, $00, $77, $00, $00,
$00, $77, $00, $00, $00, $77, $00, $00, $00, $77, $00, $00, $00, $77, $00, $00,
$00, $77, $00, $00, $00, $77, $00, $00, $00, $77, $00, $00, $00, $77, $00, $00,
$00, $77, $00, $00, $00, $77, $00, $00, $00, $77, $00, $00, $00, $77, $00, $00,
$00, $77, $00, $00, $00, $77, $00, $00, $00, $74, $00, $00, $00, $54, $00, $00,
$00, $15, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $69, $00, $00,
$00, $DB, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $DA, $00, $00,
$00, $68, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $BE, $00, $00,
$00, $FF, $00, $00, $00, $C7, $00, $00, $00, $A4, $00, $00, $00, $D7, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $D7, $00, $00, $00, $A4, $00, $00, $00, $C7, $00, $00, $00, $FF, $00, $00,
$00, $BD, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $C9, $00, $00,
$00, $FF, $00, $00, $00, $55, $00, $00, $00, $00, $00, $00, $00, $88, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $88, $00, $00, $00, $00, $00, $00, $00, $55, $00, $00, $00, $FF, $00, $00,
$00, $C9, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $C9, $00, $00,
$00, $FF, $00, $00, $00, $79, $00, $00, $00, $29, $00, $00, $00, $A1, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $A1, $00, $00, $00, $29, $00, $00, $00, $79, $00, $00, $00, $FF, $00, $00,
$00, $C9, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $C9, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $C9, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $C9, $00, $00,
$00, $FF, $00, $00, $00, $EC, $00, $00, $00, $E1, $00, $00, $00, $F2, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $F2, $00, $00, $00, $E1, $00, $00, $00, $EC, $00, $00, $00, $FF, $00, $00,
$00, $C9, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $C9, $00, $00,
$00, $FF, $00, $00, $00, $5C, $00, $00, $00, $00, $00, $00, $00, $8D, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $8D, $00, $00, $00, $00, $00, $00, $00, $5C, $00, $00, $00, $FF, $00, $00,
$00, $C9, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $C9, $00, $00,
$00, $FF, $00, $00, $00, $5C, $00, $00, $00, $00, $00, $00, $00, $8D, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $8D, $00, $00, $00, $00, $00, $00, $00, $5C, $00, $00, $00, $FF, $00, $00,
$00, $C9, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $C9, $00, $00,
$00, $FF, $00, $00, $00, $EC, $00, $00, $00, $E1, $00, $00, $00, $F2, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $F2, $00, $00, $00, $E1, $00, $00, $00, $EC, $00, $00, $00, $FF, $00, $00,
$00, $C9, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $C9, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $C9, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $C9, $00, $00,
$00, $FF, $00, $00, $00, $79, $00, $00, $00, $29, $00, $00, $00, $A1, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $A1, $00, $00, $00, $29, $00, $00, $00, $79, $00, $00, $00, $FF, $00, $00,
$00, $C9, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $C9, $00, $00,
$00, $FF, $00, $00, $00, $55, $00, $00, $00, $00, $00, $00, $00, $88, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $88, $00, $00, $00, $00, $00, $00, $00, $55, $00, $00, $00, $FF, $00, $00,
$00, $C9, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $C9, $00, $00,
$00, $FF, $00, $00, $00, $C6, $00, $00, $00, $A4, $00, $00, $00, $D7, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $D7, $00, $00, $00, $A4, $00, $00, $00, $C6, $00, $00, $00, $FF, $00, $00,
$00, $C9, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $C9, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $C9, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $C9, $00, $00,
$00, $FF, $00, $00, $00, $A4, $00, $00, $00, $6E, $00, $00, $00, $C0, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $C0, $00, $00, $00, $6E, $00, $00, $00, $A4, $00, $00, $00, $FF, $00, $00,
$00, $C9, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $C9, $00, $00,
$00, $FF, $00, $00, $00, $55, $00, $00, $00, $00, $00, $00, $00, $88, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $88, $00, $00, $00, $00, $00, $00, $00, $55, $00, $00, $00, $FF, $00, $00,
$00, $C9, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $C3, $00, $00,
$00, $FF, $00, $00, $00, $99, $00, $00, $00, $5C, $00, $00, $00, $B8, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $B8, $00, $00, $00, $5C, $00, $00, $00, $99, $00, $00, $00, $FF, $00, $00,
$00, $C3, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $8A, $00, $00,
$00, $EA, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $EA, $00, $00,
$00, $89, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $26, $00, $00,
$00, $87, $00, $00, $00, $B6, $00, $00, $00, $BA, $00, $00, $00, $BA, $00, $00,
$00, $BA, $00, $00, $00, $BA, $00, $00, $00, $BA, $00, $00, $00, $BA, $00, $00,
$00, $BA, $00, $00, $00, $BA, $00, $00, $00, $BA, $00, $00, $00, $BA, $00, $00,
$00, $BA, $00, $00, $00, $BA, $00, $00, $00, $BA, $00, $00, $00, $BA, $00, $00,
$00, $BA, $00, $00, $00, $BA, $00, $00, $00, $BA, $00, $00, $00, $BA, $00, $00,
$00, $BA, $00, $00, $00, $BA, $00, $00, $00, $B6, $00, $00, $00, $86, $00, $00,
$00, $24, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $FF, $FF,
$FF, $FF, $FF, $FF, $FF, $FF, $FF, $FF, $FF, $FF, $FF, $FF, $FF, $FF, $FF, $80,
$01, $FF, $FF, $80, $01, $FF, $FF, $80, $01, $FF, $FF, $FF, $FF, $FF, $E0, $00,
$00, $07, $E0, $00, $00, $07, $E0, $00, $00, $07, $E2, $00, $00, $47, $E0, $00,
$00, $07, $E0, $00, $00, $07, $E0, $00, $00, $07, $E2, $00, $00, $47, $E2, $00,
$00, $47, $E0, $00, $00, $07, $E0, $00, $00, $07, $E0, $00, $00, $07, $E2, $00,
$00, $47, $E0, $00, $00, $07, $E0, $00, $00, $07, $E0, $00, $00, $07, $E2, $00,
$00, $47, $E0, $00, $00, $07, $E0, $00, $00, $07, $E0, $00, $00, $07, $FF, $FF,
$FF, $FF, $FF, $FF, $FF, $FF, $FF, $FF, $FF, $FF, $FF, $FF, $FF, $FF
);
implementation
end.

View file

@ -13,19 +13,10 @@ object MainForm: TMainForm
OnKeyDown = FormKeyDown
OnShow = FormShow
LCLVersion = '4.6'
object pnlMenu: TPanel
Left = 0
Height = 26
Top = 0
Width = 1600
Align = alTop
BevelOuter = bvNone
TabOrder = 4
end
object memoLog: TLogView
Left = 0
Height = 130
Top = 770
Top = 730
Width = 1600
Align = alBottom
Font.Height = -11
@ -36,7 +27,7 @@ object MainForm: TMainForm
object pnlBottom: TPanel
Left = 0
Height = 40
Top = 730
Top = 860
Width = 1600
Align = alBottom
BevelOuter = bvNone
@ -58,34 +49,59 @@ object MainForm: TMainForm
Left = 290
Height = 28
Top = 6
Width = 170
Width = 28
Down = True
Caption = 'Прилипание: вкл'
Icon = tiSnap
OnClick = btnSnapClick
ShowHint = True
TabStop = False
end
object btnGlue: TThemedButton
Left = 320
Height = 28
Top = 6
Width = 28
Down = True
Icon = tiGlue
OnClick = btnGlueClick
ShowHint = True
TabStop = False
end
object btnCenter: TThemedButton
Left = 350
Height = 28
Top = 6
Width = 28
Down = True
Icon = tiTarget
OnClick = btnCenterClick
ShowHint = True
TabStop = False
end
object btnRipple: TThemedButton
Left = 470
Left = 386
Height = 28
Top = 6
Width = 280
Caption = 'Схлопывание: текущая дорожка'
Width = 28
Icon = tiRipple
OnClick = btnRippleClick
ShowHint = True
TabStop = False
end
object btnRippleMenu: TThemedButton
Left = 752
Left = 416
Height = 28
Top = 6
Width = 26
Width = 22
Arrow = True
OnClick = btnRippleMenuClick
TabStop = False
end
object lblStatus: TLabel
Left = 790
Left = 488
Height = 20
Top = 10
Width = 840
Width = 780
Anchors = [akTop, akLeft, akRight]
AutoSize = False
Caption = 'Готов.'
@ -110,6 +126,7 @@ object MainForm: TMainForm
Align = alClient
ItemHeight = 100
OnClick = lstPoolClick
OnMouseDown = lstPoolMouseDown
OnDrawItem = lstPoolDrawItem
Style = lbOwnerDrawFixed
TabOrder = 0

File diff suppressed because it is too large Load diff

View file

@ -104,6 +104,10 @@ var
mpv_create: function: Pmpv_handle; cdecl;
mpv_initialize: function(ctx: Pmpv_handle): cint; cdecl;
mpv_terminate_destroy: procedure(ctx: Pmpv_handle); cdecl;
{ Необязательная: отсоединяет клиента, не дожидаясь, пока ядро добьёт
свои потоки. Есть в libmpv с давних версий, но если вдруг не найдётся,
работаем без неё. }
mpv_destroy: procedure(ctx: Pmpv_handle); cdecl;
mpv_set_option_string: function(ctx: Pmpv_handle; name, data: PAnsiChar): cint; cdecl;
mpv_set_property: function(ctx: Pmpv_handle; name: PAnsiChar; format: cint;
data: Pointer): cint; cdecl;
@ -279,6 +283,8 @@ begin
Need('mpv_request_log_messages', Pointer(mpv_request_log_messages));
Need('mpv_wait_event', Pointer(mpv_wait_event));
Need('mpv_event_name', Pointer(mpv_event_name));
{ Без Need: её отсутствие не повод отказываться от предпросмотра. }
Bind('mpv_destroy', Pointer(mpv_destroy));
if Missing <> '' then
begin

View file

@ -57,6 +57,23 @@ type
FInitialized: Boolean;
FVideoFilter: string;
FHwdec: string;
FAudioSource: string;
FAudioSources: array of string;
{ Наложение: список кусков оверлея и окна, когда и куда его класть. }
FOverlayUrl: string;
FOverlayWindows: TOverlayWindows;
{ Номера видеодорожек: своя (основа) и подключённая для наложения.
Пусто — наложения нет. }
FBaseVid: string;
FOverlayVid: string;
FTrackVolumes: array of Double;
{ Номера подключённых звуковых дорожек, в порядке подключения. }
FAudioIds: array of string;
FLastGraph: string;
FSubtitlePath: string;
{ Что произошло с выбором звуковой дорожки. Читается окном для
журнала: без этого понять, откуда взялся звук, нечем. }
FAudioTrackNote: string;
{ Работаем ли под Wine. Там свои правила вывода картинки, и узнать об
этом надо до создания плеера. }
FUnderWine: Boolean;
@ -80,6 +97,23 @@ type
procedure PumpEvents(Sender: TObject);
procedure HandlePropertyChange(AProp: Pmpv_event_property);
procedure HandleLogMessage(AMsg: Pmpv_event_log_message);
{ Выбирает внешнюю звуковую дорожку, если она есть.
Без этого mpv играет звук, встроенный в саму картинку: внешний файл
он лишь добавляет ещё одной дорожкой, а выбирает по своему
усмотрению. Получалось, что звук слышен там, где на монтаже его нет
вовсе — он приходил из видеофайла, а не с звуковой дорожки. }
procedure SelectExternalAudio;
{ Подключает оверлей второй видеодорожкой и запоминает её номер. }
procedure AttachOverlay;
{ Часть цепочки, которая кладёт оверлей на основу. Пусто — нечего
класть. }
function OverlayGraph: string;
{ Цепочка смешивания звуковых дорожек с их громкостями. }
{ Собственная запись в журнал программы. Идёт тем же путём, что и
сообщения самого mpv: у окна для них уже есть обработчик. }
procedure Note(const AText: string);
procedure BuildGraph;
function SetOption(const AName, AValue: string): Integer;
public
constructor Create(AVideoParent: TWinControl);
@ -135,6 +169,27 @@ type
когда фильтр действительно нужен. }
procedure SetHwdec(const AMode: string);
{ Внешняя звуковая дорожка: список кусков звука, отдельный от
картинки. Нужен потому, что звук можно двигать отдельно, и у одного
файла звук всегда свой.
Пустая строка снимает внешний звук. }
procedure SetAudioSource(const AUrl: string);
{ Несколько звуковых дорожек монтажа разом. Плеер играет только одну,
поэтому смесь собирается его же средствами — см. AttachAudio. }
procedure SetAudioSources(const AUrls: array of string);
{ Что класть поверх картинки. Пустой список кусков — ничего.
Наложение делает сам плеер, ничего заранее не собирается: две
дорожки играются одна поверх другой. }
procedure SetVideoOverlay(const AUrl: string;
const AWindows: TOverlayWindows);
{ Громкость каждой звуковой дорожки монтажа. Подставляется в цепочку
смешивания: одной общей громкостью тут не обойтись. }
procedure SetTrackVolumes(const AVolumes: array of Double);
function GetPropString(const AName: string): string;
function GetPropDouble(const AName: string; out AValue: Double): Boolean;
@ -148,6 +203,7 @@ type
property HasTimePos: Boolean read FHasTimePos;
property Initialized: Boolean read FInitialized;
property UnderWine: Boolean read FUnderWine;
property AudioTrackNote: string read FAudioTrackNote;
property TimePos: Double read FTimePos;
property Duration: Double read FDuration;
property Paused: Boolean read FPaused;
@ -155,6 +211,17 @@ type
property OnDuration: TNotifyEvent read FOnDuration write FOnDuration;
property OnPauseChange: TNotifyEvent read FOnPauseChange write FOnPauseChange;
property OnLog: TMpvLogEvent read FOnLog write FOnLog;
{ Цепочка смешивания, отданная mpv. Нужна журналу: по ней видно, какая
дорожка какую громкость получила на самом деле. }
property AudioGraph: string read FLastGraph;
{ Файл субтитров поверх картинки. Пустая строка снимает его.
Держится отдельно от списка кусков: текст правится чаще, чем
монтаж, и перезагружать из-за него картинку незачем. }
{ AForce — перечитать файл, даже если имя прежнее. Нужно всегда, когда
разметка переписана: имя у неё одно на весь проект, и по имени
изменение не видно. }
procedure SetSubtitleFile(const APath: string; AForce: Boolean = False);
end;
implementation
@ -340,16 +407,50 @@ begin
FPump.OnTimer := @PumpEvents;
end;
procedure TMpvPlayer.Note(const AText: string);
begin
if Assigned(FOnLog) then
FOnLog(Self, 'nlve', 'info', AText);
end;
{ Закрытие плеера.
Порядок здесь не случайный, и стоил зависания под Wine.
mpv_terminate_destroy не просто отпускает клиента — она ждёт, пока ядро
mpv свернёт всю свою работу и добьёт свои потоки. Под Wine это ожидание
не кончалось никогда: журнал обрывался на «закрываю плеер», и дальше
ничего. Под Linux та же функция отрабатывает мгновенно.
Поэтому отсоединяемся, не дожидаясь ядра: mpv_destroy отпускает клиента
и возвращает управление сразу, а ядро без клиентов сворачивается само.
Программа в этот момент и так закрывается — ждать её незачем.
Команду «завершиться» перед этим не посылаем намеренно: она тоже
выполняется ядром и под Wine могла бы встать точно так же. Отсоединение
ни от чего не зависит.
Окно, в которое рисовал mpv, убирается только после этого: в обратном
порядке он успел бы обратиться к уже несуществующему окну. }
destructor TMpvPlayer.Destroy;
begin
FPump.Enabled := False;
FreeAndNil(FPump);
{ Сначала гасим mpv, и только потом убираем окно, в которое он рисовал:
в обратном порядке он успел бы обратиться к уже несуществующему окну. }
if FHandle <> nil then
begin
mpv_terminate_destroy(FHandle);
if Assigned(mpv_destroy) then
begin
Note('плеер: отсоединяюсь от mpv');
mpv_destroy(FHandle);
end
else
begin
{ Старая libmpv без mpv_destroy: другого способа нет, ждём. }
Note('плеер: закрываю mpv с ожиданием');
mpv_terminate_destroy(FHandle);
end;
FHandle := nil;
Note('плеер: закрыт');
end;
{$IFDEF LCLGTK2}
if (FVideoWin <> 0) and (FDisplay <> nil) then
@ -546,6 +647,17 @@ begin
Гадать по номеру версии не нужно: те же самые настройки можно задать
свойствами перед загрузкой. Свойства называются одинаково во всех
версиях, и разбирать строку приходится нам, а не mpv. }
{ Цепочка смешивания снимается ДО загрузки.
Она ссылается на дорожки по номерам. Оставшаяся от прошлого раза
цепочка не даёт открыть новый файл вовсе: проверено на живом mpv —
после такой загрузки дорожек ноль, кадра нет, не играет ничего.
Снимать её после загрузки поздно. }
FLastGraph := '';
FAudioIds := nil;
FOverlayVid := '';
mpv_set_property_string(FHandle, 'lavfi-complex', '');
Rest := AOptions;
while Rest <> '' do
begin
@ -637,6 +749,53 @@ begin
mpv_set_property_string(FHandle, 'vf', PAnsiChar(Value));
end;
procedure TMpvPlayer.SetAudioSource(const AUrl: string);
begin
SetAudioSources([AUrl]);
end;
procedure TMpvPlayer.SetAudioSources(const AUrls: array of string);
var
I, N: Integer;
begin
{ Здесь только запоминаем. Подключается звук после загрузки картинки,
командой audio-add — см. SelectExternalAudio. }
SetLength(FAudioSources, Length(AUrls));
N := 0;
for I := 0 to High(AUrls) do
if AUrls[I] <> '' then
begin
FAudioSources[N] := AUrls[I];
Inc(N);
end;
SetLength(FAudioSources, N);
if N > 0 then
FAudioSource := FAudioSources[0]
else
FAudioSource := '';
end;
procedure TMpvPlayer.SetVideoOverlay(const AUrl: string;
const AWindows: TOverlayWindows);
var
I: Integer;
begin
FOverlayUrl := AUrl;
SetLength(FOverlayWindows, Length(AWindows));
for I := 0 to High(AWindows) do
FOverlayWindows[I] := AWindows[I];
end;
procedure TMpvPlayer.SetTrackVolumes(const AVolumes: array of Double);
var
I: Integer;
begin
SetLength(FTrackVolumes, Length(AVolumes));
for I := 0 to High(AVolumes) do
FTrackVolumes[I] := AVolumes[I];
BuildGraph;
end;
procedure TMpvPlayer.SetHwdec(const AMode: string);
begin
if FHandle = nil then
@ -717,6 +876,314 @@ begin
end;
end;
{ Наложение оверлея на основу.
Оверлей — обычная видеодорожка, подключённая рядом с основной. Класть её
поверх умеет сам плеер, поэтому ничего не собирается заранее: правка
видна сразу, а видеокарта не тратится на пересборку всего ролика.
Окна с одинаковым размером и местом объединяются в одну ветку: масштаб
во времени не меняется, а вот показывать и прятать можно по времени —
это и делает enable. }
function TMpvPlayer.OverlayGraph: string;
var
I, J, N, Count: Integer;
Filters: array of string;
Xs, Ys: array of Integer;
Enables: array of string;
Src, Prev, Next, Fit: string;
{ Номер ветки для окна: у одинаковых по виду и месту она общая. }
function GroupOf(const AWin: TOverlayWindow): Integer;
var
K: Integer;
begin
for K := 0 to Count - 1 do
if (Filters[K] = AWin.Filter) and (Xs[K] = AWin.X) and
(Ys[K] = AWin.Y) then
Exit(K);
SetLength(Filters, Count + 1);
SetLength(Xs, Count + 1);
SetLength(Ys, Count + 1);
SetLength(Enables, Count + 1);
Filters[Count] := AWin.Filter;
Xs[Count] := AWin.X;
Ys[Count] := AWin.Y;
Enables[Count] := '';
Result := Count;
Inc(Count);
end;
begin
Result := '';
if (FOverlayVid = '') or (FBaseVid = '') or
(Length(FOverlayWindows) = 0) then
Exit;
Count := 0;
for I := 0 to High(FOverlayWindows) do
begin
J := GroupOf(FOverlayWindows[I]);
if Enables[J] <> '' then
Enables[J] := Enables[J] + '+';
Enables[J] := Enables[J] + Format('between(t,%s,%s)',
[SecondsToStr(FOverlayWindows[I].TlStart),
SecondsToStr(FOverlayWindows[I].TlEnd)]);
end;
{ Одна ветка берёт оверлей напрямую, несколько — через split. }
if Count = 1 then
Result := Format('[vid%s]', [FOverlayVid])
else
begin
Result := Format('[vid%s]split=%d', [FOverlayVid, Count]);
for I := 0 to Count - 1 do
Result := Result + Format('[q%d]', [I]);
Result := Result + ';';
end;
for I := 0 to Count - 1 do
begin
if Count = 1 then
Src := ''
else
Src := Format('[q%d]', [I]);
Fit := Filters[I];
if Fit = '' then
Fit := 'null';
Result := Result + Format('%s%s[ov%d];', [Src, Fit, I]);
end;
Prev := Format('[vid%s]', [FBaseVid]);
for I := 0 to Count - 1 do
begin
if I = Count - 1 then
Next := '[vo]'
else
Next := Format('[m%d]', [I]);
{ eof_action=pass и repeatlast=0: оверлей короче основы — обычное
дело, и после его конца картинка должна идти дальше как ни в чём
не бывало. }
Result := Result + Format('%s[ov%d]overlay=%d:%d:enable=''%s''' +
':eof_action=pass:repeatlast=0%s;',
[Prev, I, Xs[I], Ys[I], Enables[I], Next]);
Prev := Next;
end;
end;
procedure TMpvPlayer.BuildGraph;
var
I: Integer;
Graph, Vol, Video: string;
NeedAudioPart: Boolean;
begin
if FHandle = nil then
Exit;
Video := OverlayGraph;
{ Цепочка отбирает дорожки: всё, что в ней не упомянуто, mpv снимает с
выбора. Поэтому при наложении звук приходится вести через неё же,
даже когда дорожка одна, — иначе он просто пропадёт. }
NeedAudioPart := (Length(FAudioIds) > 1) or
((Video <> '') and (Length(FAudioIds) > 0));
if Length(FAudioIds) = 0 then
begin
{ Дорожек нет — снимаем приглушение, иначе оно пережило бы смену
монтажа и заглушило следующий. }
SetVolume(100);
Graph := Video;
if Graph <> FLastGraph then
begin
FLastGraph := Graph;
mpv_set_property_string(FHandle, 'lavfi-complex', PAnsiChar(Graph));
end;
Exit;
end;
{ Одна дорожка без наложения играется как есть: цепочка тут только
мешала бы, да и громкость задаётся обычным способом. }
if not NeedAudioPart then
begin
Graph := '';
if (Length(FTrackVolumes) > 0) and (Abs(FTrackVolumes[0] - 1) > 0.001) then
SetVolume(FTrackVolumes[0] * 100)
else
SetVolume(100);
end
else if Length(FAudioIds) = 1 then
begin
SetVolume(100);
if Length(FTrackVolumes) > 0 then
Vol := SecondsToStr(FTrackVolumes[0])
else
Vol := '1';
Graph := Format('[aid%s]volume=%s[ao];', [FAudioIds[0], Vol]);
end
else
begin
{ Общая громкость возвращается на место обязательно.
Ветка выше выставляет её по громкости единственной дорожки, и ноль
там — обычное дело: клип с выключенным звуком. Раньше здесь она не
трогалась, и плеер, однажды заглушённый, оставался немым до конца
работы — на всём монтаже сразу, сколько бы дорожек потом ни
появилось. Здесь громкость каждой дорожки задана в самой цепочке,
поэтому общая всегда полная. }
SetVolume(100);
Graph := '';
for I := 0 to High(FAudioIds) do
begin
if I <= High(FTrackVolumes) then
Vol := SecondsToStr(FTrackVolumes[I])
else
Vol := '1';
Graph := Graph + Format('[aid%s]volume=%s[t%d];', [FAudioIds[I], Vol, I]);
end;
for I := 0 to High(FAudioIds) do
Graph := Graph + Format('[t%d]', [I]);
Graph := Graph + Format('amix=inputs=%d:normalize=0[ao];',
[Length(FAudioIds)]);
end;
Graph := Video + Graph;
if Graph = FLastGraph then
Exit;
FLastGraph := Graph;
mpv_set_property_string(FHandle, 'lavfi-complex', PAnsiChar(Graph));
end;
{ Оверлей подключается второй видеодорожкой.
Как и со звуком, через свойство это не делается: нужна команда
video-add. Дорожка добавляется без выбора — выбирать её нельзя, иначе
она заменила бы картинку. Её дело — быть входом цепочки. }
procedure TMpvPlayer.AttachOverlay;
var
Count, I: Integer;
Kind, Ext, Id: string;
begin
if FHandle = nil then
Exit;
FBaseVid := '';
FOverlayVid := '';
if (FOverlayUrl <> '') and (Length(FOverlayWindows) > 0) then
Command(['video-add', FOverlayUrl, 'auto']);
Count := StrToIntDef(GetPropString('track-list/count'), 0);
for I := 0 to Count - 1 do
begin
Kind := GetPropString(Format('track-list/%d/type', [I]));
if Kind <> 'video' then
Continue;
Id := GetPropString(Format('track-list/%d/id', [I]));
if Id = '' then
Continue;
Ext := GetPropString(Format('track-list/%d/external', [I]));
if (Ext = 'yes') or (Ext = 'true') then
begin
if FOverlayVid = '' then
FOverlayVid := Id;
end
else if FBaseVid = '' then
FBaseVid := Id;
end;
end;
{ Субтитры подключаются двумя путями, и оба нужны.
Здесь — когда файл поменялся, а картинка та же: снимаем прежние и
подключаем новые, не трогая воспроизведение. При загрузке нового списка
кусков mpv забывает все внешние дорожки, поэтому там подключение
повторяется — см. SelectExternalAudio. }
procedure TMpvPlayer.SetSubtitleFile(const APath: string; AForce: Boolean);
begin
if (FSubtitlePath = APath) and (not AForce) then
Exit;
FSubtitlePath := APath;
if FHandle = nil then
Exit;
Command(['sub-remove']);
if APath <> '' then
Command(['sub-add', APath, 'select']);
end;
procedure TMpvPlayer.SelectExternalAudio;
var
Count, I, Found: Integer;
Kind, Ext, Id: string;
begin
if FHandle = nil then
Exit;
{ Своего звука нет — пусть играет как есть. }
if FAudioSource = '' then
Exit;
{ Подключаем каждую звуковую дорожку монтажа отдельным списком кусков.
Через свойство audio-files это не работает: проверено на живом mpv —
список дорожек после загрузки остаётся прежним, внешний файл не
подключается вовсе, и играет звук, встроенный в саму картинку. Из-за
этого звук был слышен там, где на монтаже его нет, а после попытки
заглушить — пропал совсем.
Команда audio-add с признаком select делает ровно то, что нужно:
добавляет дорожку, выбирает её и снимает выбор с внутренней. }
for I := 0 to High(FAudioSources) do
if I = 0 then
Command(['audio-add', FAudioSources[I], 'select'])
else
Command(['audio-add', FAudioSources[I], 'auto']);
{ Собираем номера всех подключённых звуковых дорожек. }
FAudioIds := nil;
Found := 0;
Count := StrToIntDef(GetPropString('track-list/count'), 0);
for I := 0 to Count - 1 do
begin
Kind := GetPropString(Format('track-list/%d/type', [I]));
if Kind <> 'audio' then
Continue;
Ext := GetPropString(Format('track-list/%d/external', [I]));
if (Ext <> 'yes') and (Ext <> 'true') then
Continue;
Id := GetPropString(Format('track-list/%d/id', [I]));
if Id = '' then
Continue;
if Found = 0 then
mpv_set_property_string(FHandle, 'aid', PAnsiChar(Id));
SetLength(FAudioIds, Found + 1);
FAudioIds[Found] := Id;
Inc(Found);
end;
if Found > 0 then
begin
FAudioTrackNote := 'Звук: дорожек монтажа ' + IntToStr(Found) + '.';
{ Цепочка строится отдельно: она зависит ещё и от громкостей, а те
меняются по ходу воспроизведения. }
BuildGraph;
Exit;
end;
{ Внешней дорожки не нашлось.
Глушить звук здесь нельзя, и это проверено дорого: такая попытка
привела к тому, что звука не стало вовсе. Перебор дорожек может не
сработать по причинам, до которых отсюда не дознаться, а mpv и сам
предпочитает внешний список, когда тот открылся.
Поэтому оставляем выбор ему, но записываем в журнал: если звук вдруг
окажется не тот, будет с чего начинать. }
FAudioTrackNote := 'ВНИМАНИЕ: внешняя звуковая дорожка не подключилась ' +
'(дорожек: ' + IntToStr(Count) + '). Звук может идти не с монтажа.';
end;
procedure TMpvPlayer.HandleLogMessage(AMsg: Pmpv_event_log_message);
begin
if Assigned(FOnLog) then
@ -746,6 +1213,20 @@ begin
HandlePropertyChange(Pmpv_event_property(Ev^.data));
MPV_EVENT_LOG_MESSAGE:
HandleLogMessage(Pmpv_event_log_message(Ev^.data));
MPV_EVENT_FILE_LOADED:
begin
{ Оверлей подключается до звука: цепочка строится один раз, и в
ней должно быть сразу всё. }
AttachOverlay;
SelectExternalAudio;
{ Звуковых дорожек может не оказаться вовсе, а наложение при
этом есть — тогда цепочку никто больше не соберёт. }
BuildGraph;
{ Загрузка нового списка кусков сбрасывает все внешние дорожки,
в том числе субтитры. Подключаем заново. }
if FSubtitlePath <> '' then
Command(['sub-add', FSubtitlePath, 'select']);
end;
MPV_EVENT_SHUTDOWN:
begin
FPump.Enabled := False;

View file

@ -1,24 +1,24 @@
object PanCropForm: TPanCropForm
Left = 380
Height = 560
Height = 620
Top = 220
Width = 940
Width = 1260
BorderIcons = [biSystemMenu]
Caption = 'Кадрирование и поворот'
ClientHeight = 560
ClientWidth = 940
ClientHeight = 620
ClientWidth = 1260
OnCreate = FormCreate
OnDestroy = FormDestroy
Position = poOwnerFormCenter
LCLVersion = '4.6'
object pnlLeft: TPanel
Left = 0
Height = 560
Height = 620
Top = 0
Width = 380
Align = alLeft
BevelOuter = bvNone
ClientHeight = 560
ClientHeight = 620
ClientWidth = 380
TabOrder = 0
object lblSource: TLabel
@ -179,13 +179,33 @@ object PanCropForm: TPanCropForm
OnClick = btnCancelClick
TabOrder = 9
end
object btnKey: TThemedButton
Left = 12
Height = 30
Top = 534
Width = 356
Caption = 'Цветовой ключ...'
OnClick = btnKeyClick
TabOrder = 10
end
end
object pbSource: TPaintBox
Left = 380
Height = 620
Top = 0
Width = 460
Align = alClient
OnMouseDown = pbSourceMouseDown
OnMouseMove = pbSourceMouseMove
OnMouseUp = pbSourceMouseUp
OnPaint = pbSourcePaint
end
object imgPreview: TImage
Left = 380
Height = 560
Left = 840
Height = 620
Top = 0
Width = 560
Align = alClient
Width = 420
Align = alRight
Center = True
Proportional = True
Stretch = True

View file

@ -21,7 +21,7 @@ interface
uses
Classes, SysUtils, Forms, Controls, Graphics, ExtCtrls, StdCtrls, Spin,
ComCtrls, uTransform, uMediaProbe, uTheme;
ComCtrls, uTransform, uMediaProbe, uProject, uTheme;
type
@ -29,6 +29,7 @@ type
TPanCropForm = class(TForm)
btnCancel: TThemedButton;
btnKey: TThemedButton;
btnOk: TThemedButton;
btnReset: TThemedButton;
cbFit: TComboBox;
@ -36,6 +37,7 @@ type
chkFlipH: TCheckBox;
chkFlipV: TCheckBox;
imgPreview: TImage;
pbSource: TPaintBox;
lblFilter: TLabel;
lblFit: TLabel;
lblOffsetX: TLabel;
@ -48,20 +50,58 @@ type
tbOffsetY: TTrackBar;
tbZoom: TTrackBar;
procedure btnCancelClick(Sender: TObject);
procedure btnKeyClick(Sender: TObject);
procedure btnOkClick(Sender: TObject);
procedure btnResetClick(Sender: TObject);
procedure ControlChanged(Sender: TObject);
procedure FormCreate(Sender: TObject);
procedure FormDestroy(Sender: TObject);
procedure pbSourcePaint(Sender: TObject);
procedure pbSourceMouseDown(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
procedure pbSourceMouseMove(Sender: TObject; Shift: TShiftState;
X, Y: Integer);
procedure pbSourceMouseUp(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
private
FTransform: TMediaTransform;
FSourcePath: string;
{ Цветовой ключ правится тут же, но принадлежит не окну, а клипу:
окно лишь показывает его хозяину. Пусто — правит нечего (так бывает
у заготовки для файла, у которой клипа ещё нет), и кнопки не
будет. }
FKey: PColorKey;
FSourceInfo: TMediaInfo;
FDstWidth: Integer;
FDstHeight: Integer;
FPreviewFile: string;
FRefreshTimer: TTimer;
FUpdating: Boolean;
{ Кадр источника без всякой обработки — по нему тянут рамку. }
FSourceBmp: TBitmap;
{ Размеры кадра после поворота: рамка живёт в этих координатах. }
FRotW: Integer;
FRotH: Integer;
{ Рамка в координатах повёрнутого источника. }
FSel: TRect;
FDragMode: Integer;
FDragX: Integer;
FDragY: Integer;
FDragSel: TRect;
procedure LoadSourceFrame;
procedure UpdateRotatedSize;
{ Прямоугольник, в который вписан кадр источника на панели. }
function SourceRect: TRect;
function SelToScreen(const ASel: TRect): TRect;
function ScreenToSourceX(AX: Integer): Integer;
function ScreenToSourceY(AY: Integer): Integer;
{ Пересчёт между рамкой и настройкой. }
procedure SelFromTransform;
procedure TransformFromSel;
{ Загоняет настройку в те пределы, которые умеют ползунки. Иначе
рамка и ползунки расходятся: рамка от неограниченного значения,
фильтр от обрезанного. }
procedure ClampTransform;
procedure ReadControls;
procedure WriteControls;
procedure ScheduleRefresh;
@ -70,21 +110,28 @@ type
{ Готовит окно. ADstWidth и ADstHeight — кадр проекта. }
procedure Setup(const APath: string; const AInfo: TMediaInfo;
ADstWidth, ADstHeight: Integer; const ATransform: TMediaTransform;
const APreviewFile: string);
const APreviewFile: string; AKey: PColorKey = nil);
property Transform: TMediaTransform read FTransform;
end;
{ Показывает окно и возвращает True, если настройки приняты. }
{ AKey — цветовой ключ клипа. Нужен, чтобы правку ключа можно было
открыть отсюда: человек ищет её там же, где кадрирование, — обе
настройки про картинку одного клипа. Правка ключа сама себя
подтверждает и «Отменой» этого окна не отменяется.
nil — ключа у этой настройки нет, кнопка не показывается. }
function EditTransform(AOwner: TComponent; const APath: string;
const AInfo: TMediaInfo; ADstWidth, ADstHeight: Integer;
var ATransform: TMediaTransform; const APreviewFile: string): Boolean;
var ATransform: TMediaTransform; const APreviewFile: string;
AKey: PColorKey = nil): Boolean;
implementation
{$R *.lfm}
uses
Math, uAppTypes, uFfmpegCmd, uProcRunner;
Math, Types, LCLIntf, uAppTypes, uFfmpegCmd, uProcRunner, uColorKeyForm;
const
{ Ширина показываемого кадра. Больше незачем: тут важны пропорции и то,
@ -95,9 +142,21 @@ const
каждое движение бессмысленно. }
RefreshDelayMs = 300;
{ Что тянут мышью по кадру источника. }
dmNone = 0;
dmMove = 1;
dmResize = 2;
{ Сколько пикселей у угла считается «за угол». }
CornerGrip = 12;
ZoomMin = 50;
ZoomMax = 400;
OffsetRange = 50;
{ Предел смещения. При сильном увеличении кадр гуляет далеко за половину
своей ширины, и полсотни процентов не хватало: рамку можно было
утащить туда, куда ползунок не дотягивается, и они расходились —
рамка показывала одно, а получалось другое. }
OffsetRange = 200;
procedure TPanCropForm.FormCreate(Sender: TObject);
begin
@ -144,6 +203,7 @@ end;
procedure TPanCropForm.FormDestroy(Sender: TObject);
begin
FreeAndNil(FSourceBmp);
FRefreshTimer.Enabled := False;
if (FPreviewFile <> '') and FileExists(FPreviewFile) then
DeleteFile(FPreviewFile);
@ -151,8 +211,10 @@ end;
procedure TPanCropForm.Setup(const APath: string; const AInfo: TMediaInfo;
ADstWidth, ADstHeight: Integer; const ATransform: TMediaTransform;
const APreviewFile: string);
const APreviewFile: string; AKey: PColorKey);
begin
FKey := AKey;
btnKey.Visible := AKey <> nil;
FSourcePath := APath;
FSourceInfo := AInfo;
FDstWidth := ADstWidth;
@ -164,6 +226,8 @@ begin
[ExtractFileName(APath), AInfo.Width, AInfo.Height, ADstWidth, ADstHeight]);
WriteControls;
LoadSourceFrame;
SelFromTransform;
RefreshPreview(nil);
end;
@ -233,6 +297,9 @@ begin
if FUpdating then
Exit;
ReadControls;
{ Рамка следует за ползунками: иначе они разошлись бы с картинкой. }
SelFromTransform;
pbSource.Invalidate;
ScheduleRefresh;
end;
@ -244,6 +311,23 @@ begin
ScheduleRefresh;
end;
{ Правка цветового ключа того же клипа.
Кадр для выбора цвета окно ключа достаёт само — тот же, что и здесь:
первый кадр часто чёрный, и тыкать в него не во что. }
procedure TPanCropForm.btnKeyClick(Sender: TObject);
var
AtTime: Double;
begin
if FKey = nil then
Exit;
if FSourceInfo.Kind = mkImage then
AtTime := 0
else
AtTime := Min(5, Max(0, FSourceInfo.Duration * 0.1));
EditColorKey(FKey^, FSourcePath, AtTime, FPreviewFile + '.key.png');
end;
procedure TPanCropForm.ScheduleRefresh;
begin
{ Вызов может прийти во время сборки окна, когда таймера ещё нет. }
@ -253,6 +337,323 @@ begin
FRefreshTimer.Enabled := True;
end;
{ --- Рамка поверх кадра источника --- }
{ Размеры кадра после поворота: рамка живёт именно в них, иначе после
поворота на 90 она оказалась бы поперёк картинки. }
procedure TPanCropForm.UpdateRotatedSize;
begin
{ Рамка живёт в координатах исходника как он есть. Поворот на неё не
влияет: он применяется к уже вырезанному куску, а не к кадру целиком —
иначе рамка показывала бы не то, что останется. }
FRotW := FSourceInfo.Width;
FRotH := FSourceInfo.Height;
end;
{ Кадр источника, вписанный в панель с сохранением пропорций. }
function TPanCropForm.SourceRect: TRect;
var
K: Double;
W, H: Integer;
begin
Result := Rect(0, 0, 0, 0);
if (FRotW <= 0) or (FRotH <= 0) then
Exit;
K := Min((pbSource.Width - 20) / FRotW, (pbSource.Height - 20) / FRotH);
if K <= 0 then
Exit;
W := Round(FRotW * K);
H := Round(FRotH * K);
Result := Rect((pbSource.Width - W) div 2, (pbSource.Height - H) div 2,
(pbSource.Width - W) div 2 + W, (pbSource.Height - H) div 2 + H);
end;
function TPanCropForm.SelToScreen(const ASel: TRect): TRect;
var
R: TRect;
KX, KY: Double;
begin
R := SourceRect;
if (FRotW <= 0) or (FRotH <= 0) or (R.Right <= R.Left) then
Exit(Rect(0, 0, 0, 0));
KX := (R.Right - R.Left) / FRotW;
KY := (R.Bottom - R.Top) / FRotH;
Result := Rect(
R.Left + Round(ASel.Left * KX), R.Top + Round(ASel.Top * KY),
R.Left + Round(ASel.Right * KX), R.Top + Round(ASel.Bottom * KY));
end;
function TPanCropForm.ScreenToSourceX(AX: Integer): Integer;
var
R: TRect;
begin
R := SourceRect;
if R.Right <= R.Left then
Exit(0);
Result := Round((AX - R.Left) * FRotW / (R.Right - R.Left));
end;
function TPanCropForm.ScreenToSourceY(AY: Integer): Integer;
var
R: TRect;
begin
R := SourceRect;
if R.Bottom <= R.Top then
Exit(0);
Result := Round((AY - R.Top) * FRotH / (R.Bottom - R.Top));
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));
end;
{ Настройка по рамке.
Рамка говорит, какая часть источника займёт кадр проекта. Отсюда
увеличение — во сколько раз её надо растянуть, а смещение — насколько
она сдвинута относительно середины. }
procedure TPanCropForm.TransformFromSel;
var
KFit, KFill, KBase, K: Double;
SW, SH, CW, CH: Double;
begin
if (FRotW <= 0) or (FRotH <= 0) or (FDstWidth <= 0) or (FDstHeight <= 0) then
Exit;
if (FSel.Right - FSel.Left < 4) or (FSel.Bottom - FSel.Top < 4) then
Exit;
KFit := Min(FDstWidth / FRotW, FDstHeight / FRotH);
KFill := Max(FDstWidth / FRotW, FDstHeight / FRotH);
if FTransform.Fit = fmFill then
KBase := KFill
else
KBase := KFit;
if KBase <= 0 then
Exit;
K := FDstWidth / (FSel.Right - FSel.Left);
FTransform.Zoom := K / KBase;
SW := FRotW * K;
SH := FRotH * K;
CW := Min(SW, FDstWidth);
CH := Min(SH, FDstHeight);
{ Смещение пересчитывается по рамке только там, где рамка его и
задаёт, — то есть где картинка шире или выше кадра. Где она меньше,
рамка охватывает её целиком и о смещении ничего не знает; посчитанный
по ней ноль вернул бы картинку на середину и отменил бы то, что
выставлено ползунком. }
if SW > FDstWidth then
FTransform.OffsetX := (FSel.Left * K - (SW - CW) / 2) / FDstWidth;
if SH > FDstHeight then
FTransform.OffsetY := (FSel.Top * K - (SH - CH) / 2) / FDstHeight;
end;
procedure TPanCropForm.ClampTransform;
begin
if FTransform.Zoom < ZoomMin / 100 then
FTransform.Zoom := ZoomMin / 100;
if FTransform.Zoom > ZoomMax / 100 then
FTransform.Zoom := ZoomMax / 100;
FTransform.OffsetX := Max(-OffsetRange / 100, Min(OffsetRange / 100,
FTransform.OffsetX));
FTransform.OffsetY := Max(-OffsetRange / 100, Min(OffsetRange / 100,
FTransform.OffsetY));
end;
{ Кадр источника без обработки: по нему тянут рамку. }
procedure TPanCropForm.LoadSourceFrame;
var
Res: TProcResult;
Png: TPortableNetworkGraphic;
AtTime: Double;
Path: string;
begin
FreeAndNil(FSourceBmp);
if FSourcePath = '' then
Exit;
Path := FPreviewFile + '.src.png';
if FSourceInfo.Kind = mkImage then
AtTime := 0
else
AtTime := Min(5, Max(0, FSourceInfo.Duration * 0.1));
{ Пустая цепочка фильтров: нужен именно исходный кадр, без вписывания и
полей — иначе рамку не по чему тянуть. }
Res := RunProcess(FfmpegExe,
ArgsPreviewFrame(FSourcePath, Path, AtTime, ''), nil, nil, nil);
if (not Res.Started) or (Res.ExitCode <> 0) or (not FileExists(Path)) then
Exit;
Png := TPortableNetworkGraphic.Create;
try
try
Png.LoadFromFile(Path);
FSourceBmp := TBitmap.Create;
FSourceBmp.Assign(Png);
except
FreeAndNil(FSourceBmp);
end;
finally
Png.Free;
end;
SysUtils.DeleteFile(Path);
end;
procedure TPanCropForm.pbSourcePaint(Sender: TObject);
var
R, S: TRect;
begin
pbSource.Canvas.Brush.Style := bsSolid;
pbSource.Canvas.Brush.Color := ThemeWindow;
pbSource.Canvas.FillRect(pbSource.ClientRect);
R := SourceRect;
if R.Right <= R.Left then
Exit;
if FSourceBmp <> nil then
pbSource.Canvas.StretchDraw(R, FSourceBmp)
else
begin
pbSource.Canvas.Brush.Color := ThemeSurface;
pbSource.Canvas.FillRect(R);
end;
{ Затемнять то, что вне рамки, нечем без полупрозрачности, поэтому
просто рисуем саму рамку и её углы. }
S := SelToScreen(FSel);
pbSource.Canvas.Brush.Style := bsClear;
pbSource.Canvas.Pen.Color := ThemeAccent;
pbSource.Canvas.Pen.Width := 2;
pbSource.Canvas.Rectangle(S);
pbSource.Canvas.Pen.Width := 1;
pbSource.Canvas.Brush.Style := bsSolid;
pbSource.Canvas.Brush.Color := ThemeAccent;
pbSource.Canvas.FillRect(S.Right - 8, S.Bottom - 8, S.Right, S.Bottom);
end;
procedure TPanCropForm.pbSourceMouseDown(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
var
S: TRect;
begin
if Button <> mbLeft then
Exit;
S := SelToScreen(FSel);
FDragSel := FSel;
FDragX := X;
FDragY := Y;
if (Abs(X - S.Right) <= CornerGrip) and (Abs(Y - S.Bottom) <= CornerGrip) then
FDragMode := dmResize
else if PtInRect(S, Point(X, Y)) then
FDragMode := dmMove
else
FDragMode := dmNone;
end;
procedure TPanCropForm.pbSourceMouseMove(Sender: TObject; Shift: TShiftState;
X, Y: Integer);
var
DX, DY, W, H: Integer;
begin
if FDragMode = dmNone then
Exit;
DX := ScreenToSourceX(X) - ScreenToSourceX(FDragX);
DY := ScreenToSourceY(Y) - ScreenToSourceY(FDragY);
if FDragMode = dmMove then
begin
W := FDragSel.Right - FDragSel.Left;
H := FDragSel.Bottom - FDragSel.Top;
FSel.Left := Max(0, Min(FRotW - W, FDragSel.Left + DX));
FSel.Top := Max(0, Min(FRotH - H, FDragSel.Top + DY));
FSel.Right := FSel.Left + W;
FSel.Bottom := FSel.Top + H;
end
else
begin
{ Ширину меняем мышью, высоту считаем: рамка держит пропорции кадра
проекта, иначе картинка поехала бы. }
W := Max(16, FDragSel.Right - FDragSel.Left + DX);
W := Min(W, FRotW - FSel.Left);
H := Round(W * FDstHeight / FDstWidth);
if FSel.Top + H > FRotH then
begin
H := FRotH - FSel.Top;
W := Round(H * FDstWidth / FDstHeight);
end;
FSel.Right := FSel.Left + W;
FSel.Bottom := FSel.Top + H;
end;
TransformFromSel;
ClampTransform;
{ Рамка пересчитывается обратно из настройки. Так она всегда показывает
ровно то, что получится: если настройка упёрлась в предел, это сразу
видно, а не расходится молча. }
SelFromTransform;
FUpdating := True;
try
WriteControls;
finally
FUpdating := False;
end;
pbSource.Invalidate;
ScheduleRefresh;
end;
procedure TPanCropForm.pbSourceMouseUp(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
begin
FDragMode := dmNone;
end;
procedure TPanCropForm.RefreshPreview(Sender: TObject);
var
PrevW, PrevH: Integer;
@ -318,13 +719,15 @@ end;
function EditTransform(AOwner: TComponent; const APath: string;
const AInfo: TMediaInfo; ADstWidth, ADstHeight: Integer;
var ATransform: TMediaTransform; const APreviewFile: string): Boolean;
var ATransform: TMediaTransform; const APreviewFile: string;
AKey: PColorKey): Boolean;
var
Form: TPanCropForm;
begin
Form := TPanCropForm.Create(AOwner);
try
Form.Setup(APath, AInfo, ADstWidth, ADstHeight, ATransform, APreviewFile);
Form.Setup(APath, AInfo, ADstWidth, ADstHeight, ATransform, APreviewFile,
AKey);
Result := Form.ShowModal = mrOK;
if Result then
ATransform := Form.Transform;

View file

@ -38,6 +38,15 @@ object ProgressForm: TProgressForm
Anchors = [akTop, akLeft, akRight]
TabOrder = 0
end
object lblEta: TLabel
Left = 12
Height = 20
Top = 74
Width = 500
Anchors = [akTop, akLeft, akRight]
Caption = ''
ParentColor = False
end
object btnAction: TThemedButton
Left = 648
Height = 28
@ -49,7 +58,7 @@ object ProgressForm: TProgressForm
TabOrder = 1
end
end
object memoLog: TMemo
object memoLog: TLogView
Left = 0
Height = 310
Top = 110
@ -58,9 +67,6 @@ object ProgressForm: TProgressForm
Font.Height = -12
Font.Name = 'Monospace'
ParentFont = False
ReadOnly = True
ScrollBars = ssAutoBoth
TabOrder = 1
WordWrap = False
end
end

View file

@ -16,7 +16,8 @@ unit uProgressForm;
interface
uses
Classes, SysUtils, Forms, Controls, Graphics, ComCtrls, StdCtrls, ExtCtrls, uTheme;
Classes, SysUtils, Forms, Controls, Graphics, ComCtrls, StdCtrls, ExtCtrls,
uTheme, uLogView;
type
@ -25,7 +26,8 @@ type
TProgressForm = class(TForm)
btnAction: TThemedButton;
lblStage: TLabel;
memoLog: TMemo;
lblEta: TLabel;
memoLog: TLogView;
pbBar: TProgressBar;
pnlTop: TPanel;
procedure btnActionClick(Sender: TObject);
@ -35,6 +37,11 @@ type
FFinished: Boolean;
FCancelRequested: Boolean;
FOnCancelRequest: TNotifyEvent;
{ Когда началась работа и какую долю показывали в прошлый раз: по ним
считается, сколько осталось. }
FStartedAt: TDateTime;
FLastFraction: Double;
function FormatSpan(const ASeconds: Double): string;
public
procedure SetStage(const AText: string);
procedure SetFraction(AValue: Double);
@ -69,6 +76,9 @@ begin
pbBar.Position := 0;
memoLog.Clear;
btnAction.Caption := 'Отмена';
FStartedAt := Now;
FLastFraction := 0;
lblEta.Caption := '';
end;
procedure TProgressForm.SetStage(const AText: string);
@ -76,24 +86,62 @@ begin
lblStage.Caption := AText;
end;
{ Время в виде «3 мин 20 с»: секунды сотнями человек не читает. }
function TProgressForm.FormatSpan(const ASeconds: Double): string;
var
Total, M, S: Integer;
begin
Total := Round(ASeconds);
if Total < 0 then
Total := 0;
M := Total div 60;
S := Total mod 60;
if M >= 60 then
Result := Format('%d ч %d мин', [M div 60, M mod 60])
else if M > 0 then
Result := Format('%d мин %d с', [M, S])
else
Result := Format('%d с', [S]);
end;
{ Сколько прошло и сколько осталось.
Осталось считается по простому правилу: если четверть пути заняла
минуту, весь путь займёт четыре. На деле скорость меняется — куски,
которые копируются, идут мгновенно, а те, что пересобираются,
медленно, — поэтому число называется «примерно» и пересчитывается на
каждом шаге. Даже такая оценка отвечает на главный вопрос: успею ли я
сходить за чаем. }
procedure TProgressForm.SetFraction(AValue: Double);
var
{ Не Left: так называется свойство формы. }
Elapsed, Remain: Double;
begin
if AValue < 0 then
AValue := 0;
if AValue > 1 then
AValue := 1;
pbBar.Position := Round(AValue * BarScale);
FLastFraction := AValue;
Elapsed := (Now - FStartedAt) * SecsPerDay;
{ Пока сделано меньше сотой доли, оценка была бы гаданием: первые
секунды уходят на запуск ffmpeg, а не на работу. }
if (AValue > 0.01) and (Elapsed > 1) then
begin
Remain := Elapsed * (1 - AValue) / AValue;
lblEta.Caption := Format('Прошло %s, осталось примерно %s (%d%%)',
[FormatSpan(Elapsed), FormatSpan(Remain), Round(AValue * 100)]);
end
else
lblEta.Caption := 'Прошло ' + FormatSpan(Elapsed);
end;
procedure TProgressForm.AddLog(const AText: string);
begin
{ Журнал одной операции не должен расти без предела: ffmpeg на битом
файле способен выдать тысячи одинаковых предупреждений. }
if memoLog.Lines.Count > 1000 then
memoLog.Lines.Delete(0);
memoLog.Lines.Add(AText);
{ Держим видимой последнюю строку. }
memoLog.SelStart := Length(memoLog.Text);
{ Свой журнал сам держится конца, пока пользователь не отмотал назад, и
сам обрезает старые строки. }
memoLog.Add(AText);
end;
procedure TProgressForm.Finish(ASuccess: Boolean; const AMessage: string);
@ -101,6 +149,8 @@ begin
FFinished := True;
btnAction.Caption := 'Закрыть';
lblStage.Caption := AMessage;
{ На месте оценки — итог: сколько всё это заняло. }
lblEta.Caption := 'Заняло ' + FormatSpan((Now - FStartedAt) * SecsPerDay);
if ASuccess then
lblStage.Font.Color := clDefault
else

View file

@ -34,7 +34,60 @@ type
кадра придётся перекодировать. }
TEdgeMode = (edSnap, edPrecise);
TClipKind = (ckVideo, ckImage, ckBlack);
{ ckAudio — звуковой клип: картинку из него не берут.
Отдельный вид, а не признак дорожки, потому что решение принимается по
клипу: сборка спрашивает у куска, что из него доставать. }
TClipKind = (ckVideo, ckImage, ckBlack, ckAudio, ckText);
{ Положение текста в кадре: девять клеток, как на цифровой клавиатуре.
Числа совпадают с полем Alignment в ASS — переводить не приходится. }
TTextPlace = (
tpBottomLeft = 1, tpBottomCenter = 2, tpBottomRight = 3,
tpMiddleLeft = 4, tpMiddleCenter = 5, tpMiddleRight = 6,
tpTopLeft = 7, tpTopCenter = 8, tpTopRight = 9
);
{ Цветовой ключ: цвет, который у клипа становится прозрачным.
Нужен для накладок — плиток, значков, рамок, снятых на однотонном
фоне. Пурпурный берут потому, что в природе он почти не встречается:
ошибиться и вырезать кусок картинки труднее, чем с зелёным.
Клип с ключом собрать копированием нельзя: участок пересобирается
вместе с тем, что под ним, как и полупрозрачный. }
TColorKey = record
Enabled: Boolean;
Color: TRgb;
{ Насколько далеко от указанного цвета считать «тем же цветом», в
процентах. Ноль — только точное совпадение. }
Similarity: Integer;
{ Смягчение края, в процентах. Ноль — резкая граница. }
Blend: Integer;
end;
PColorKey = ^TColorKey;
{ Оформление текстового клипа.
Цвета хранятся как TColor, а в ASS переводятся: там свой порядок
составляющих и своя запись прозрачности. }
TTextStyle = record
FontName: string;
{ Размер в точках кадра проекта, а не экрана. Он же верхняя граница:
если текст не влезает в отведённую полосу, размер уменьшается. }
FontSize: Integer;
Color: TRgb;
OutlineColor: TRgb;
{ Толщина обводки и смещение тени в точках. Ноль — нет вовсе. }
Outline: Integer;
Shadow: Integer;
ShadowColor: TRgb;
Place: TTextPlace;
{ Какую долю высоты кадра тексту позволено занять. 0.2 — пятую часть. }
MaxHeight: Double;
Bold: Boolean;
Italic: Boolean;
end;
{ На какие дорожки действует схлопывание.
@ -80,8 +133,37 @@ type
FKind: TClipKind;
FSelected: Boolean;
FVolume: Double;
FTransform: TMediaTransform;
FGroupId: string;
FOpacity: Double;
FFadeIn: Double;
FFadeOut: Double;
FText: string;
FTextStyle: TTextStyle;
FColorKey: TColorKey;
public
constructor Create;
{ Переносит на этот клип всё, что настроено у другого: громкость,
кадрирование, прозрачность, ключ, затухания, текст.
Не переносится то, что делает клип этим клипом: имя, место на
монтаже, кусок исходника, связка и выделение.
Зачем метод, а не переписывание полей на месте: копия клипа нужна в
нескольких местах (рез — самое частое), и каждое новое свойство
иначе приходится вспоминать в каждом из них. Один раз не вспомнили —
и после реза половина клипа теряла цветовой ключ, а вместе с ним и
прозрачность: она переставала быть наложением и закрывала собой всё,
что ниже. }
procedure CopySettingsFrom(ASource: TClip);
{ Переносит только оформление: кадрирование, поворот, увеличение,
смещение, прозрачность, ключ, затухания, громкость.
От CopySettingsFrom отличается тем, что не трогает вид клипа и его
текст: свойства переносят между разными клипами, и превращать
картинку в надпись при этом незачем. Оформление надписи переносится
только между надписями. }
procedure CopyLookFrom(ASource: TClip);
function TlEnd: Double;
function SrcOut: Double;
{ Попадает ли момент внутрь клипа. Правый край не включается: там уже
@ -99,14 +181,49 @@ type
громче. Хранится множителем, а не в децибелах, чтобы не переводить
туда-сюда при каждой отрисовке; в интерфейсе показывается процентами. }
property Volume: Double read FVolume write FVolume;
{ Как кадр этого клипа ложится в кадр проекта.
Настройка принадлежит клипу, а не файлу: два куска одного файла
бывает нужно кадрировать по-разному. У файла остаётся такая же
настройка, но она лишь заготовка — её берёт новый клип при
добавлении. }
property Transform: TMediaTransform read FTransform write FTransform;
{ Непрозрачность клипа картинки: 1 — обычный, 0.5 — сквозь него видно
то, что ниже.
Стоит понимать цену: как только клип перестаёт быть непрозрачным,
его нельзя скопировать — участок приходится пересобирать вместе со
всем, что под ним. Поэтому по умолчанию единица, и программа
предупреждает перед сборкой, сколько секунд это займёт. }
property Opacity: Double read FOpacity write FOpacity;
{ Плавное появление и уход, в секундах от краёв клипа. Для картинки
это затемнение, для звука — нарастание громкости. }
property FadeIn: Double read FFadeIn write FFadeIn;
property FadeOut: Double read FFadeOut write FFadeOut;
{ Связка. Клипы с одинаковым непустым значением двигаются, режутся и
удаляются вместе. По умолчанию так связаны картинка и её звук;
Ctrl+G связывает и развязывает что угодно. }
property GroupId: string read FGroupId write FGroupId;
{ Цветовой ключ клипа. Выключен — клип обычный. }
property ColorKey: TColorKey read FColorKey write FColorKey;
{ Текст и его оформление. Имеют смысл только у клипа вида ckText;
у остальных лежат пустыми и в файл проекта не пишутся. }
property Text: string read FText write FText;
property TextStyle: TTextStyle read FTextStyle write FTextStyle;
{ Выделение живёт только в памяти и в файл проекта не пишется. }
property Selected: Boolean read FSelected write FSelected;
end;
{ Что живёт на дорожке. Картинка и звук разделены, потому что их можно
двигать порознь: звук из одного места монтажа под картинкой из
другого — обычное дело. }
TTrackKind = (tkVideo, tkAudio, tkText);
TTrack = class
private
FId: string;
FName: string;
FKind: TTrackKind;
FMuted: Boolean;
FSolo: Boolean;
FClips: TObjectList;
@ -126,6 +243,7 @@ type
property Count: Integer read GetCount;
property Id: string read FId write FId;
property Name: string read FName write FName;
property Kind: TTrackKind read FKind write FKind;
property Muted: Boolean read FMuted write FMuted;
{ Когда хоть у одной дорожки включено «соло», в результат идут только
такие дорожки. Удобно, чтобы послушать одну, не выключая остальные
@ -139,6 +257,9 @@ type
FTracks: TObjectList;
FMaster: TMediaInfo;
FRippleMode: TRippleMode;
{ Отмеченный участок монтажа: от и до. Пока не отмечен, оба нуля. }
FRegionStart: Double;
FRegionEnd: Double;
FRippleEnabled: Boolean;
FFileName: string;
FModified: Boolean;
@ -149,10 +270,12 @@ type
function GetPoolCount: Integer;
function GetTrack(AIndex: Integer): TTrack;
function GetTrackCount: Integer;
function NewId(const APrefix: string): string;
function Serialize: string;
procedure Deserialize(const AText: string);
public
{ Неповторимый номер для нового клипа или дорожки. Публичный: клипы
заводит не только сам проект — например, надпись создаёт окно. }
function NewId(const APrefix: string): string;
constructor Create;
destructor Destroy; override;
@ -164,11 +287,46 @@ type
function FindMediaByPath(const APath: string): TMediaItem;
procedure RemoveMedia(AItem: TMediaItem);
{ Номер новой связки. Отдельным именем, чтобы не пришлось помнить
префикс в каждом месте, где связка заводится. }
function NewGroupId: string;
{ Имя, которого ещё нет ни у одной дорожки: V1, V2, ... Считать
дорожки нужного вида и прибавлять единицу нельзя — после удаления
средней счёт совпадёт с уже занятым именем, и в окне окажутся две
дорожки Т2. }
function UniqueTrackName(const APrefix: string): string;
{ Разводит одинаковые имена дорожек, доставшиеся от старых проектов.
Имя — только подпись, но две дорожки Т2 в окне сбивают с толку. }
procedure EnsureUniqueTrackNames;
function AddTrack(const AName: string): TTrack;
function AddTrackOfKind(const AName: string; AKind: TTrackKind): TTrack;
function FindTrack(const AId: string): TTrack;
procedure RemoveTrack(AIndex: Integer);
procedure MoveTrack(AFrom, ATo: Integer);
{ Сколько дорожек каждого вида и как найти парную.
Пара определяется порядковым номером: у первой дорожки картинки
первая дорожка звука. Так связка «картинка плюс её звук» ложится
напротив друг друга, и монтаж читается взглядом. }
function TrackCountOf(AKind: TTrackKind): Integer;
function TrackOfKind(AKind: TTrackKind; AIndex: Integer): TTrack;
function IndexAmongKind(ATrack: TTrack): Integer;
{ Заводит недостающие звуковые дорожки, чтобы у каждой дорожки
картинки была парная. }
procedure EnsureAudioTracks;
{ Перенос проектов, записанных до разделения звука и картинки: там
звук шёл вместе с клипом картинки, и без пары он бы просто пропал. }
procedure MigrateAudio;
{ Клип на звуковой дорожке обязан быть звуковым. В проектах, записанных
до того, как вид клипа научился сохраняться, он остался «видео» — и
тогда у клипа пропадает форма звука, появляется лишняя кнопка
кадрирования, а ползунок правит прозрачность вместо громкости. }
procedure FixClipKinds;
{ Кладёт клип и, если в файле есть звук, парный звуковой клип на
соответствующую звуковую дорожку. Возвращает клип картинки. }
function AddClipPair(ATrack: TTrack; AMedia: TMediaItem;
const ASrcIn, ADuration, ATlStart: Double): TClip;
function AddClip(ATrack: TTrack; AMedia: TMediaItem;
const ASrcIn, ADuration, ATlStart: Double): TClip;
function FindClipTrack(AClip: TClip): TTrack;
@ -222,6 +380,38 @@ type
{ Режим действует всегда, даже когда автосхлопывание выключено:
он определяет, что сделает Ctrl+Shift+R. }
property RippleMode: TRippleMode read FRippleMode write FRippleMode;
{ Отмеченный участок: собирать можно только его. Нужен, чтобы
проверить кусок монтажа, не собирая весь ролик — на длинном
материале это разница между минутой и часом.
Отметок две, и обе живут в проекте, а не в настройках программы:
участок относится к этому монтажу, а не к привычкам человека. }
property RegionStart: Double read FRegionStart write FRegionStart;
property RegionEnd: Double read FRegionEnd write FRegionEnd;
function HasRegion: Boolean;
{ Наибольший битрейт среди видеофайлов проекта.
Им целятся при пересборке кусков. Брать средний или битрейт первого
файла нельзя: пересобранный кусок стоит рядом со скопированными, и
если он окажется хуже их, разница бросится в глаза именно на нём.
Лучше потратить место, чем показать шакала посреди чистой съёмки. }
function MaxVideoBitrate: Int64;
{ Какая дорожка нужна клипу этого вида. Правило одно на всю
программу: картинке — дорожка картинки, звуку — звуковая, надписи —
текстовая. }
class function TrackKindFor(AClipKind: TClipKind): TTrackKind;
{ Сколько клипов лежит на дорожках не своего вида.
Такой клип не попадает никуда: уплощение обходит дорожки по видам,
и клип с чужой дорожки не видит ни сборка, ни предпросмотр. Пропажа
при этом тихая — на монтаже он нарисован, а в ролике его нет. }
function MisplacedClipCount: Integer;
{ Переносит их на подходящие дорожки. Возвращает, сколько перенесено.
Ищется первая дорожка нужного вида, где это время свободно; если
такой нет — заводится новая. Время клипа не меняется: человек ставил
его именно туда. }
function FixMisplacedClips: Integer;
{ Автоматическое схлопывание при перетаскивании клипов. }
property RippleEnabled: Boolean read FRippleEnabled write FRippleEnabled;
property FileName: string read FFileName write FFileName;
@ -232,6 +422,23 @@ const
{ Сколько длится картинка, пока её не растянули. }
DefaultImageDuration = 5.0;
{ Цветовой ключ по умолчанию. }
function DefaultColorKey: TColorKey;
{ Одинаковы ли два ключа. Записи в Паскале сравнивать оператором нельзя,
а знать, изменилось ли что-нибудь, нужно: иначе в истории отмены
появится пустой шаг. }
function SameColorKey(const A, B: TColorKey): Boolean;
{ Оформление текста по умолчанию: белые буквы с тёмной обводкой внизу по
центру — то, что читается на любой картинке. }
function DefaultTextStyle: TTextStyle;
{ Шрифт по умолчанию. Разный по системам: одинаковых имён у них нет. }
const
DefaultTextFont =
{$IFDEF WINDOWS}'Arial'{$ELSE}'DejaVu Sans'{$ENDIF};
implementation
uses
@ -259,11 +466,44 @@ end;
{ --- TClip --- }
procedure TClip.CopySettingsFrom(ASource: TClip);
begin
if ASource = nil then
Exit;
FKind := ASource.Kind;
FVolume := ASource.Volume;
FTransform := ASource.Transform;
FOpacity := ASource.Opacity;
FColorKey := ASource.ColorKey;
FFadeIn := ASource.FadeIn;
FFadeOut := ASource.FadeOut;
FText := ASource.Text;
FTextStyle := ASource.TextStyle;
end;
procedure TClip.CopyLookFrom(ASource: TClip);
begin
if ASource = nil then
Exit;
FTransform := ASource.Transform;
FOpacity := ASource.Opacity;
FColorKey := ASource.ColorKey;
FFadeIn := ASource.FadeIn;
FFadeOut := ASource.FadeOut;
FVolume := ASource.Volume;
if (FKind = ckText) and (ASource.Kind = ckText) then
FTextStyle := ASource.TextStyle;
end;
constructor TClip.Create;
begin
inherited Create;
{ Без явного значения множитель был бы нулём, то есть тишиной. }
FVolume := 1;
FOpacity := 1;
FTransform := DefaultTransform;
FTextStyle := DefaultTextStyle;
FColorKey := DefaultColorKey;
end;
function TClip.TlEnd: Double;
@ -401,6 +641,8 @@ begin
FMaster := Default(TMediaInfo);
FRippleMode := rmCurrentTrack;
FRippleEnabled := False;
FRegionStart := 0;
FRegionEnd := 0;
FFileName := '';
FModified := False;
FNextId := 1;
@ -411,8 +653,12 @@ begin
Clear;
{ Две дорожки с самого начала: одна пустая дорожка не показывает главного —
что верхняя перекрывает нижнюю. }
{ Две дорожки картинки и две звука. Звук снизу: так принято везде, и
так он не мешает смотреть на картинку. }
AddTrack('V1');
AddTrack('V2');
AddTrackOfKind('A1', tkAudio);
AddTrackOfKind('A2', tkAudio);
FModified := False;
end;
@ -436,6 +682,11 @@ begin
Result := FTracks.Count;
end;
function TProject.NewGroupId: string;
begin
Result := NewId('g');
end;
function TProject.NewId(const APrefix: string): string;
begin
Result := APrefix + IntToStr(FNextId);
@ -496,12 +747,90 @@ begin
FModified := True;
end;
function TProject.UniqueTrackName(const APrefix: string): string;
var
N, I: Integer;
Taken: Boolean;
begin
N := 1;
repeat
Result := APrefix + IntToStr(N);
Taken := False;
for I := 0 to FTracks.Count - 1 do
if TTrack(FTracks[I]).Name = Result then
begin
Taken := True;
Break;
end;
Inc(N);
until not Taken;
end;
procedure TProject.EnsureUniqueTrackNames;
var
I, J: Integer;
Prefix: string;
begin
for I := 0 to FTracks.Count - 1 do
for J := 0 to I - 1 do
if TTrack(FTracks[J]).Name = TTrack(FTracks[I]).Name then
begin
case TTrack(FTracks[I]).Kind of
tkAudio: Prefix := 'A';
tkText: Prefix := 'Т';
else
Prefix := 'V';
end;
TTrack(FTracks[I]).Name := UniqueTrackName(Prefix);
Break;
end;
end;
function TProject.AddTrack(const AName: string): TTrack;
begin
Result := AddTrackOfKind(AName, tkVideo);
end;
function TProject.AddTrackOfKind(const AName: string;
AKind: TTrackKind): TTrack;
var
I, Pos: Integer;
begin
Result := TTrack.Create;
Result.Id := NewId('t');
Result.Name := AName;
FTracks.Add(Result);
Result.Kind := AKind;
{ Порядок дорожек не только украшение: он задаёт, кто побеждает при
перекрытии. Картинка всегда выше звука, поэтому новая дорожка
картинки встаёт перед первой звуковой, а не в самый конец. }
if AKind = tkVideo then
begin
Pos := FTracks.Count;
for I := 0 to FTracks.Count - 1 do
if TTrack(FTracks[I]).Kind in [tkAudio, tkText] then
begin
Pos := I;
Break;
end;
FTracks.Insert(Pos, Result);
end
else if AKind = tkText then
begin
{ Текст выше звука, но ниже картинки: на монтаже он читается как
надстройка над видеорядом, а не как ещё одна дорожка звука. }
Pos := FTracks.Count;
for I := 0 to FTracks.Count - 1 do
if TTrack(FTracks[I]).Kind = tkAudio then
begin
Pos := I;
Break;
end;
FTracks.Insert(Pos, Result);
end
else
FTracks.Add(Result);
FModified := True;
end;
@ -533,6 +862,168 @@ begin
FModified := True;
end;
function TProject.TrackCountOf(AKind: TTrackKind): Integer;
var
I: Integer;
begin
Result := 0;
for I := 0 to FTracks.Count - 1 do
if TTrack(FTracks[I]).Kind = AKind then
Inc(Result);
end;
function TProject.TrackOfKind(AKind: TTrackKind; AIndex: Integer): TTrack;
var
I, N: Integer;
begin
Result := nil;
N := 0;
for I := 0 to FTracks.Count - 1 do
if TTrack(FTracks[I]).Kind = AKind then
begin
if N = AIndex then
Exit(TTrack(FTracks[I]));
Inc(N);
end;
end;
function TProject.IndexAmongKind(ATrack: TTrack): Integer;
var
I, N: Integer;
begin
Result := -1;
N := 0;
for I := 0 to FTracks.Count - 1 do
if TTrack(FTracks[I]).Kind = ATrack.Kind then
begin
if TTrack(FTracks[I]) = ATrack then
Exit(N);
Inc(N);
end;
end;
procedure TProject.MigrateAudio;
var
T, C: Integer;
Track, Dst: TTrack;
Clip, Snd: TClip;
Item: TMediaItem;
Group: string;
Sources: TFPList;
begin
{ Если звуковые дорожки уже есть, проект новый и переносить нечего. }
if TrackCountOf(tkAudio) > 0 then
Exit;
if TrackCountOf(tkVideo) = 0 then
Exit;
EnsureAudioTracks;
{ Клипы собираются заранее: добавление в дорожку меняет её список, и
перебирать его на ходу нельзя. }
Sources := TFPList.Create;
try
for T := 0 to FTracks.Count - 1 do
begin
Track := TTrack(FTracks[T]);
if Track.Kind <> tkVideo then
Continue;
for C := 0 to Track.Count - 1 do
Sources.Add(Track[C]);
end;
for C := 0 to Sources.Count - 1 do
begin
Clip := TClip(Sources[C]);
if Clip.Kind = ckBlack then
Continue;
Item := FindMedia(Clip.MediaId);
if (Item = nil) or (not Item.Info.HasAudio) then
Continue;
Dst := nil;
for T := 0 to FTracks.Count - 1 do
if TTrack(FTracks[T]).IndexOf(Clip) >= 0 then
begin
Dst := TrackOfKind(tkAudio, IndexAmongKind(TTrack(FTracks[T])));
Break;
end;
if Dst = nil then
Continue;
Group := NewId('g');
Clip.GroupId := Group;
Snd := AddClip(Dst, Item, Clip.SrcIn, Clip.Duration, Clip.TlStart);
Snd.GroupId := Group;
Snd.Kind := ckAudio;
Snd.Volume := Clip.Volume;
end;
finally
Sources.Free;
end;
for T := 0 to FTracks.Count - 1 do
TTrack(FTracks[T]).Sort;
end;
procedure TProject.FixClipKinds;
var
T, C: Integer;
Track: TTrack;
begin
for T := 0 to FTracks.Count - 1 do
begin
Track := TTrack(FTracks[T]);
for C := 0 to Track.Count - 1 do
if Track.Kind = tkAudio then
begin
if Track[C].Kind <> ckBlack then
Track[C].Kind := ckAudio;
end
else if Track[C].Kind = ckAudio then
Track[C].Kind := ckVideo;
end;
end;
procedure TProject.EnsureAudioTracks;
var
Need, Have, I: Integer;
Track: TTrack;
begin
Need := TrackCountOf(tkVideo);
Have := TrackCountOf(tkAudio);
for I := Have to Need - 1 do
begin
Track := AddTrackOfKind('A' + IntToStr(I + 1), tkAudio);
end;
end;
function TProject.AddClipPair(ATrack: TTrack; AMedia: TMediaItem;
const ASrcIn, ADuration, ATlStart: Double): TClip;
var
ATrackAudio: TTrack;
Snd: TClip;
Group: string;
begin
Result := AddClip(ATrack, AMedia, ASrcIn, ADuration, ATlStart);
if (ATrack.Kind <> tkVideo) or (not AMedia.Info.HasAudio) then
Exit;
EnsureAudioTracks;
ATrackAudio := TrackOfKind(tkAudio, IndexAmongKind(ATrack));
if ATrackAudio = nil then
Exit;
Group := NewId('g');
Result.GroupId := Group;
Snd := AddClip(ATrackAudio, AMedia, ASrcIn, ADuration, ATlStart);
Snd.GroupId := Group;
{ Клип звука знает, что он звук: иначе сборка попыталась бы взять из
него картинку. }
Snd.Kind := ckAudio;
Snd.Transform := DefaultTransform;
end;
function TProject.AddClip(ATrack: TTrack; AMedia: TMediaItem;
const ASrcIn, ADuration, ATlStart: Double): TClip;
begin
@ -548,6 +1039,9 @@ begin
Result.Kind := ckImage
else
Result.Kind := ckVideo;
{ Настройка файла — заготовка: новый клип берёт её при добавлении и
дальше живёт своей. }
Result.Transform := AMedia.Transform;
ATrack.Add(Result);
FModified := True;
end;
@ -574,6 +1068,122 @@ begin
end;
end;
{ Участок считается отмеченным, только если он не пустой: две отметки в
одной точке — это отсутствие участка, а не участок нулевой длины. }
function TProject.HasRegion: Boolean;
begin
Result := FRegionEnd > FRegionStart + 0.001;
end;
class function TProject.TrackKindFor(AClipKind: TClipKind): TTrackKind;
begin
case AClipKind of
ckAudio: Result := tkAudio;
ckText: Result := tkText;
else
Result := tkVideo;
end;
end;
function TProject.MisplacedClipCount: Integer;
var
T, C: Integer;
Track: TTrack;
begin
Result := 0;
for T := 0 to TrackCount - 1 do
begin
Track := Tracks[T];
for C := 0 to Track.Count - 1 do
if TrackKindFor(Track[C].Kind) <> Track.Kind then
Inc(Result);
end;
end;
function TProject.FixMisplacedClips: Integer;
var
T, C, K: Integer;
Track, Target: TTrack;
Clip: TClip;
Need: TTrackKind;
Moved: TFPList;
{ Свободна ли дорожка в это время. }
function Fits(ATrack: TTrack; AClip: TClip): Boolean;
var
I: Integer;
begin
Result := True;
for I := 0 to ATrack.Count - 1 do
if (AClip.TlStart < ATrack[I].TlEnd - 0.001) and
(AClip.TlEnd > ATrack[I].TlStart + 0.001) then
Exit(False);
end;
begin
Result := 0;
Moved := TFPList.Create;
try
{ Сперва собираем список: переносить прямо во время обхода нельзя —
состав дорожек меняется под ногами. }
for T := 0 to TrackCount - 1 do
begin
Track := Tracks[T];
for C := 0 to Track.Count - 1 do
if TrackKindFor(Track[C].Kind) <> Track.Kind then
Moved.Add(Track[C]);
end;
for C := 0 to Moved.Count - 1 do
begin
Clip := TClip(Moved[C]);
Track := FindClipTrack(Clip);
if Track = nil then
Continue;
Need := TrackKindFor(Clip.Kind);
Target := nil;
for K := 0 to TrackCount - 1 do
if (Tracks[K].Kind = Need) and Fits(Tracks[K], Clip) then
begin
Target := Tracks[K];
Break;
end;
if Target = nil then
case Need of
tkAudio: Target := AddTrackOfKind(UniqueTrackName('A'), tkAudio);
tkText: Target := AddTrackOfKind(UniqueTrackName('Т'), tkText);
else
Target := AddTrackOfKind(UniqueTrackName('V'), tkVideo);
end;
Track.Extract(Clip);
Target.Add(Clip);
Target.Sort;
Inc(Result);
end;
finally
Moved.Free;
end;
end;
function TProject.MaxVideoBitrate: Int64;
var
I: Integer;
Item: TMediaItem;
begin
Result := FMaster.VideoBitrate;
for I := 0 to FPool.Count - 1 do
begin
Item := TMediaItem(FPool[I]);
if not Item.Info.HasVideo then
Continue;
if Item.Info.VideoBitrate > Result then
Result := Item.Info.VideoBitrate;
end;
end;
procedure TProject.ClearSelection;
var
T, C: Integer;
@ -644,7 +1254,7 @@ begin
if FFileName = '' then
{ Проект ещё не сохранён — держим кэш рядом с программой, чтобы не
разбрасывать файлы по системным каталогам. }
Result := IncludeTrailingPathDelimiter(ExtractFilePath(ParamStr(0))) +
Result := ProgramDir +
'unsaved.nlve-cache' + PathDelim
else
Result := IncludeTrailingPathDelimiter(ExtractFilePath(FFileName)) +
@ -785,11 +1395,98 @@ begin
Result := edSnap;
end;
function SameColorKey(const A, B: TColorKey): Boolean;
begin
Result := (A.Enabled = B.Enabled) and (A.Color = B.Color) and
(A.Similarity = B.Similarity) and (A.Blend = B.Blend);
end;
{ Цветовой ключ по умолчанию: выключен, пурпурный, с небольшим запасом по
сходству — сжатие видео размывает чистый цвет, и точное совпадение
оставило бы кайму. }
function DefaultColorKey: TColorKey;
begin
Result.Enabled := False;
Result.Color := TRgb($FF00FF);
Result.Similarity := 15;
Result.Blend := 5;
end;
{ Оформление текста по умолчанию: белые буквы с тёмной обводкой внизу по
центру — то, что читается на любой картинке. }
function DefaultTextStyle: TTextStyle;
begin
Result.FontName := DefaultTextFont;
Result.FontSize := 48;
Result.Color := TRgb($FFFFFF);
Result.OutlineColor := TRgb($202020);
Result.Outline := 3;
Result.Shadow := 2;
Result.ShadowColor := TRgb($000000);
Result.Place := tpBottomCenter;
Result.MaxHeight := 0.2;
Result.Bold := False;
Result.Italic := False;
end;
function JsonToColorKey(AObj: TJSONObject): TColorKey;
begin
Result := DefaultColorKey;
if AObj = nil then
Exit;
Result.Enabled := AObj.Get('enabled', Result.Enabled);
Result.Color := TRgb(AObj.Get('color', Integer(Result.Color)));
Result.Similarity := AObj.Get('similarity', Result.Similarity);
Result.Blend := AObj.Get('blend', Result.Blend);
end;
function JsonToTextStyle(AObj: TJSONObject): TTextStyle;
begin
Result := DefaultTextStyle;
if AObj = nil then
Exit;
Result.FontName := AObj.Get('font', Result.FontName);
Result.FontSize := AObj.Get('size', Result.FontSize);
Result.Color := TRgb(AObj.Get('color', Integer(Result.Color)));
Result.OutlineColor := TRgb(AObj.Get('outline_color',
Integer(Result.OutlineColor)));
Result.Outline := AObj.Get('outline', Result.Outline);
Result.Shadow := AObj.Get('shadow', Result.Shadow);
Result.ShadowColor := TRgb(AObj.Get('shadow_color',
Integer(Result.ShadowColor)));
Result.Place := TTextPlace(AObj.Get('place', Integer(Ord(Result.Place))));
Result.MaxHeight := AObj.Get('max_height', Result.MaxHeight);
Result.Bold := AObj.Get('bold', Result.Bold);
Result.Italic := AObj.Get('italic', Result.Italic);
end;
function TrackKindToStr(AKind: TTrackKind): string;
begin
case AKind of
tkAudio: Result := 'audio';
tkText: Result := 'text';
else
Result := 'video';
end;
end;
function StrToTrackKind(const AValue: string): TTrackKind;
begin
if AValue = 'audio' then
Result := tkAudio
else if AValue = 'text' then
Result := tkText
else
Result := tkVideo;
end;
function KindToStr(AKind: TClipKind): string;
begin
case AKind of
ckImage: Result := 'image';
ckBlack: Result := 'black';
ckAudio: Result := 'audio';
ckText: Result := 'text';
else
Result := 'video';
end;
@ -801,6 +1498,10 @@ begin
Result := ckImage
else if AValue = 'black' then
Result := ckBlack
else if AValue = 'audio' then
Result := ckAudio
else if AValue = 'text' then
Result := ckText
else
Result := ckVideo;
end;
@ -921,6 +1622,7 @@ begin
Obj := TJSONObject.Create;
Obj.Add('id', Track.Id);
Obj.Add('name', Track.Name);
Obj.Add('kind', TrackKindToStr(Track.Kind));
Obj.Add('muted', Track.Muted);
Obj.Add('solo', Track.Solo);
@ -937,7 +1639,42 @@ begin
'edge_in', EdgeToStr(Clip.EdgeIn),
'edge_out', EdgeToStr(Clip.EdgeOut),
'kind', KindToStr(Clip.Kind),
'volume', Clip.Volume
'group', Clip.GroupId,
'volume', Clip.Volume,
'opacity', Clip.Opacity,
'fade_in', Clip.FadeIn,
'fade_out', Clip.FadeOut,
'transform', TJSONObject.Create([
'fit', FitModeToStr(Clip.Transform.Fit),
'rotation', Clip.Transform.Rotation,
'flip_h', Clip.Transform.FlipH,
'flip_v', Clip.Transform.FlipV,
'zoom', Clip.Transform.Zoom,
'offset_x', Clip.Transform.OffsetX,
'offset_y', Clip.Transform.OffsetY
]),
{ Оформление пишется только у текстовых клипов: у остальных оно
пустое, и таскать его в файле незачем. }
'color_key', TJSONObject.Create([
'enabled', Clip.ColorKey.Enabled,
'color', Integer(Clip.ColorKey.Color),
'similarity', Clip.ColorKey.Similarity,
'blend', Clip.ColorKey.Blend
]),
'text', Clip.Text,
'text_style', TJSONObject.Create([
'font', Clip.TextStyle.FontName,
'size', Clip.TextStyle.FontSize,
'color', Integer(Clip.TextStyle.Color),
'outline_color', Integer(Clip.TextStyle.OutlineColor),
'outline', Clip.TextStyle.Outline,
'shadow', Clip.TextStyle.Shadow,
'shadow_color', Integer(Clip.TextStyle.ShadowColor),
'place', Integer(Ord(Clip.TextStyle.Place)),
'max_height', Clip.TextStyle.MaxHeight,
'bold', Clip.TextStyle.Bold,
'italic', Clip.TextStyle.Italic
])
]));
end;
Obj.Add('clips', ClipsArr);
@ -947,7 +1684,9 @@ begin
Root.Add('settings', TJSONObject.Create([
'ripple_mode', RippleModeToStr(FRippleMode),
'ripple_enabled', FRippleEnabled
'ripple_enabled', FRippleEnabled,
'region_start', FRegionStart,
'region_end', FRegionEnd
]));
Result := Root.FormatJSON;
@ -963,7 +1702,7 @@ var
Obj, ItemObj, TrackObj, ClipObj, Settings: TJSONObject;
Arr, ClipsArr: TJSONArray;
I, J: Integer;
Item: TMediaItem;
Item, Media: TMediaItem;
Track: TTrack;
Clip: TClip;
begin
@ -1015,6 +1754,7 @@ begin
Track := TTrack.Create;
Track.Id := TrackObj.Get('id', '');
Track.Name := TrackObj.Get('name', '');
Track.Kind := StrToTrackKind(TrackObj.Get('kind', 'video'));
Track.Muted := TrackObj.Get('muted', False);
Track.Solo := TrackObj.Get('solo', False);
FTracks.Add(Track);
@ -1036,16 +1776,42 @@ begin
Clip.EdgeOut := StrToEdge(ClipObj.Get('edge_out', 'snap'));
Clip.Kind := StrToKind(ClipObj.Get('kind', 'video'));
Clip.Volume := ClipObj.Get('volume', Double(1));
Clip.GroupId := ClipObj.Get('group', '');
Clip.Opacity := ClipObj.Get('opacity', Double(1));
Clip.FadeIn := ClipObj.Get('fade_in', Double(0));
Clip.FadeOut := ClipObj.Get('fade_out', Double(0));
{ Проекты, записанные до появления кадрирования у клипа,
берут его у файла — там оно и хранилось раньше. }
if ClipObj.Find('transform') <> nil then
Clip.Transform := JsonToTransform(
ClipObj.Get('transform', TJSONObject(nil)))
else
begin
Media := FindMedia(Clip.MediaId);
if Media <> nil then
Clip.Transform := Media.Transform;
end;
Clip.ColorKey := JsonToColorKey(
ClipObj.Get('color_key', TJSONObject(nil)));
Clip.Text := ClipObj.Get('text', '');
Clip.TextStyle := JsonToTextStyle(
ClipObj.Get('text_style', TJSONObject(nil)));
Track.Add(Clip);
end;
Track.Sort;
end;
MigrateAudio;
FixClipKinds;
EnsureUniqueTrackNames;
Settings := Obj.Find('settings', jtObject) as TJSONObject;
if Settings <> nil then
begin
FRippleMode := StrToRippleMode(Settings.Get('ripple_mode', 'current'));
FRippleEnabled := Settings.Get('ripple_enabled', False);
FRegionStart := Settings.Get('region_start', Double(0));
FRegionEnd := Settings.Get('region_end', Double(0));
{ Старые проекты хранили один флаг «все дорожки». }
if Settings.Get('ripple_all_tracks', False) then
FRippleMode := rmAllTracks;

View file

@ -33,7 +33,14 @@ type
FRippleAllTracks: Boolean;
FRippleEnabled: Boolean;
FTrackHeight: Integer;
FWaveGain: Double;
FVideoPanelHeight: Integer;
FLogVisible: Boolean;
FPlayheadSnap: Boolean;
FPreviewKey: Boolean;
FCenterPlayhead: Boolean;
FKeepParts: Boolean;
FDepsReminder: Boolean;
FRecentProjects: TStringList;
function FileName: string;
public
@ -58,7 +65,25 @@ type
property RippleAllTracks: Boolean read FRippleAllTracks write FRippleAllTracks;
property RippleEnabled: Boolean read FRippleEnabled write FRippleEnabled;
property TrackHeight: Integer read FTrackHeight write FTrackHeight;
{ Во сколько раз растянута форма звука на клипах. Только рисунок. }
property WaveGain: Double read FWaveGain write FWaveGain;
property VideoPanelHeight: Integer read FVideoPanelHeight write FVideoPanelHeight;
{ Видна ли нижняя полоса с журналом. По умолчанию видна: пока
программа молодая, её сообщения нужнее, чем лишние строки монтажа. }
property LogVisible: Boolean read FLogVisible write FLogVisible;
{ Прилипание курсора воспроизведения к границам клипов. }
property PlayheadSnap: Boolean read FPlayheadSnap write FPlayheadSnap;
{ Применять ли ключ и прозрачность прямо в предпросмотре. }
property PreviewKey: Boolean read FPreviewKey write FPreviewKey;
{ Держать ли курсор посередине монтажа при воспроизведении. }
property CenterPlayhead: Boolean read FCenterPlayhead write FCenterPlayhead;
{ Оставлять ли временные куски после удачной сборки. По умолчанию нет:
они весят столько же, сколько готовый ролик, а нужны только чтобы
продолжить прерванную сборку. }
property KeepParts: Boolean read FKeepParts write FKeepParts;
{ Напоминать ли о недостающих зависимостях при запуске. Гасится
кнопкой «Не напоминать» в самом напоминании. }
property DepsReminder: Boolean read FDepsReminder write FDepsReminder;
end;
implementation
@ -70,6 +95,7 @@ const
DefaultWidth = 1600;
DefaultHeight = 900;
DefaultTrackHeight = 64;
DefaultWaveGain = 1.0;
DefaultVideoHeight = 400;
{ Больше десятка в меню не помещается по-человечески, да и не нужно. }
@ -84,7 +110,14 @@ begin
FWindowWidth := DefaultWidth;
FWindowHeight := DefaultHeight;
FTrackHeight := DefaultTrackHeight;
FWaveGain := DefaultWaveGain;
FVideoPanelHeight := DefaultVideoHeight;
FLogVisible := True;
FPlayheadSnap := True;
FPreviewKey := True;
FCenterPlayhead := True;
FKeepParts := False;
FDepsReminder := True;
end;
destructor TSettings.Destroy;
@ -161,7 +194,14 @@ begin
FRippleAllTracks := Obj.Get('ripple_all_tracks', FRippleAllTracks);
FRippleEnabled := Obj.Get('ripple_enabled', FRippleEnabled);
FTrackHeight := Obj.Get('track_height', FTrackHeight);
FWaveGain := Obj.Get('wave_gain', FWaveGain);
FVideoPanelHeight := Obj.Get('video_panel_height', FVideoPanelHeight);
FLogVisible := Obj.Get('log_visible', FLogVisible);
FPlayheadSnap := Obj.Get('playhead_snap', FPlayheadSnap);
FPreviewKey := Obj.Get('preview_key', FPreviewKey);
FCenterPlayhead := Obj.Get('center_playhead', FCenterPlayhead);
FKeepParts := Obj.Get('keep_parts', FKeepParts);
FDepsReminder := Obj.Get('deps_reminder', FDepsReminder);
Arr := Obj.Find('recent_projects', jtArray) as TJSONArray;
FRecentProjects.Clear;
@ -181,6 +221,8 @@ begin
FWindowWidth := DefaultWidth;
if FWindowHeight < 480 then
FWindowHeight := DefaultHeight;
if (FWaveGain < 1) or (FWaveGain > 64) then
FWaveGain := DefaultWaveGain;
if (FTrackHeight < 24) or (FTrackHeight > 300) then
FTrackHeight := DefaultTrackHeight;
if FVideoPanelHeight < 100 then
@ -207,7 +249,14 @@ begin
Root.Add('ripple_all_tracks', FRippleAllTracks);
Root.Add('ripple_enabled', FRippleEnabled);
Root.Add('track_height', FTrackHeight);
Root.Add('wave_gain', FWaveGain);
Root.Add('video_panel_height', FVideoPanelHeight);
Root.Add('log_visible', FLogVisible);
Root.Add('playhead_snap', FPlayheadSnap);
Root.Add('preview_key', FPreviewKey);
Root.Add('center_playhead', FCenterPlayhead);
Root.Add('keep_parts', FKeepParts);
Root.Add('deps_reminder', FDepsReminder);
Arr := TJSONArray.Create;
for I := 0 to FRecentProjects.Count - 1 do

538
src/uSubtitles.pas Normal file
View file

@ -0,0 +1,538 @@
{
uSubtitles — текст поверх картинки.
Рисует не эта программа, а libass: он есть и внутри mpv, и внутри
ffmpeg, поэтому предпросмотр и готовый ролик показывают одно и то же.
Наше дело — собрать для него файл ASS.
Почему переносы считаем сами. libass умеет переносить, но тогда
неизвестно, сколько выйдет строк, и подобрать размер под отведённую
полосу нечем. Поэтому меряем и переносим здесь, а в файл отдаём готовые
переводы строк: что померили, то и нарисуется.
Чем меряем. Тем же шрифтом, что уйдёт в libass, через холст LCL — за
обоими стоит FreeType, и ширины сходятся. Небольшой запас всё равно
берём: настройки сглаживания у них разные.
Замер, на котором держится подбор размера (DejaVu Sans, кадр 1280x720):
шаг строк почти равен размеру шрифта, высота блока из N строк ≈
размер × (N − 0.16). В полосу 20% высоты влезает три строки размером до
50, четыре до 37, пять до 30.
}
unit uSubtitles;
{$mode objfpc}{$H+}
interface
uses
Classes, SysUtils, uAppTypes, uProject, uFlatten;
type
{ Как померить ширину строки. Возвращает ширину в точках кадра. }
TTextMeasure = function(const AText: string; const AStyle: TTextStyle;
ASize: Integer): Integer;
var
{ Замерщик ширины текста.
Подключается снаружи: точный замер требует холста, а холст —
работающего оконного набора. Служебной программе без экрана он
недоступен, и она обходится оценкой (см. EstimateWidth) — для
проверки самого файла ASS этого хватает.
Окно ставит сюда замер тем же шрифтом, каким libass будет рисовать. }
TextMeasure: TTextMeasure = nil;
{ Собирает ASS из всех текстовых клипов проекта.
Возвращает пустую строку, если текста нет вовсе — тогда и файл писать
незачем. }
function BuildAss(AProject: TProject): string;
{ Пишет ASS в файл. False — писать было нечего или не вышло. }
function WriteAssFile(AProject: TProject; const APath: string): Boolean;
{ Есть ли в проекте хоть один текстовый клип. }
function HasText(AProject: TProject): Boolean;
{ Подбор размера и переносов — для окна редактора: оно показывает, во что
превратится надпись, ещё до сборки разметки. Тот же код, что и в самой
сборке, поэтому расхождения быть не может. }
function FitTextForPreview(const AText: string; const AStyle: TTextStyle;
AFrameWidth, AFrameHeight: Integer; ALines: TStringList): Integer;
{ Занят ли текстом этот отрезок монтажа. Нужно сборке: вжигать текст
приходится только в те куски, которые он покрывает. }
function TextCovers(AProject: TProject; const AFrom, ATo: Double): Boolean;
{ Разметка для одного куска монтажа: события сдвинуты к его началу и
обрезаны по его длине.
Нужно вжиганию. Фильтр subtitles рисует по времени того файла, который
ему дали, а кусок начинается со своего нуля — общая разметка легла бы
не туда. }
function WriteAssForRange(AProject: TProject; const AFrom, ATo: Double;
const APath: string): Boolean;
{ Готовит куски к вжиганию: тем, которые задевает текст, ставит признак
пересборки и подкладывает свою разметку.
Возвращает, сколько секунд придётся пересобрать сверх того, что и так
пересобиралось: человеку это говорят до начала.
AStartTime — время монтажа, которому соответствует начало списка.
Нужен, когда собирают не весь ролик, а отмеченный участок: иначе текст
брался бы с начала монтажа и лёг бы не на те куски. }
function BurnTextIntoSegments(AProject: TProject;
var ASegments: TFlatSegmentArray; const ACacheDir: string;
const AStartTime: Double = 0): Double;
implementation
uses
Math, LazUTF8;
const
{ Запас по ширине: наши мерки и мерки libass расходятся на проценты. }
WidthSafety = 0.95;
{ Поля от краёв кадра в долях ширины и высоты. }
MarginShare = 0.05;
{ Ниже этого размер не опускаем: дальше текст всё равно не прочесть,
и лучше показать его крупным с выходом за полосу, чем нечитаемым. }
MinFontSize = 14;
{ Время в записи ASS: часы:минуты:секунды.сотые. }
function AssTime(const ASeconds: Double): string;
var
T: Double;
H, M: Integer;
S: Double;
begin
T := Max(0, ASeconds);
H := Trunc(T) div 3600;
M := (Trunc(T) mod 3600) div 60;
S := T - H * 3600 - M * 60;
{ Дробную часть добиваем нулём вручную: %05.2f у Паскаля дополняет
пробелом, а ASS с пробелом внутри времени не читается. }
Result := Format('%d:%.2d:%.2d.%.2d',
[H, M, Trunc(S), Round(Frac(S) * 100)]);
end;
{ Цвет в записи ASS: &HAABBGGRR. Порядок обратный привычному, а первым
идёт не прозрачность, а её отсутствие — ноль значит «непрозрачно». }
function AssColor(AColor: TRgb; ATransparent: Boolean = False): string;
var
Alpha: Integer;
begin
if ATransparent then
Alpha := $FF
else
Alpha := 0;
Result := Format('&H%.2X%.2X%.2X%.2X',
[Alpha, (AColor shr 16) and $FF, (AColor shr 8) and $FF, AColor and $FF]);
end;
{ Внутри строки ASS перевод строки записывается как \N, а фигурные скобки
начинают команду — их надо обезвредить. }
function AssEscape(const AText: string): string;
begin
Result := StringReplace(AText, '\', '\\', [rfReplaceAll]);
Result := StringReplace(Result, '{', '(', [rfReplaceAll]);
Result := StringReplace(Result, '}', ')', [rfReplaceAll]);
Result := StringReplace(Result, LineEnding, '\N', [rfReplaceAll]);
Result := StringReplace(Result, #10, '\N', [rfReplaceAll]);
Result := StringReplace(Result, #13, '\N', [rfReplaceAll]);
end;
{ Оценка ширины, когда точного замера нет.
Множитель взят из замера: DejaVu Sans, размер 54, строка из двадцати
знаков кириллицы заняла 530 точек — это 0.49 размера на знак. Оценка
грубая, но для проверки самой разметки годится, а окно всегда ставит
точный замер. }
function EstimateWidth(const AText: string; ASize: Integer): Integer;
begin
Result := Round(UTF8Length(AText) * ASize * 0.49);
end;
function MeasureWidth(const AText: string; const AStyle: TTextStyle;
ASize: Integer): Integer;
begin
if Assigned(TextMeasure) then
Result := TextMeasure(AText, AStyle, ASize)
else
Result := EstimateWidth(AText, ASize);
end;
{ Перенос по словам под заданную ширину. Возвращает число строк, сами
строки складывает в ALines. }
function WrapText(const AText: string; const AStyle: TTextStyle;
ASize, AMaxWidth: Integer; ALines: TStringList): Integer;
var
Src: TStringList;
I: Integer;
Rest, Word, Line: string;
Space: Integer;
begin
ALines.Clear;
Src := TStringList.Create;
try
{ Свои переводы строк человек ставил не зря — они остаются. }
Src.Text := AText;
for I := 0 to Src.Count - 1 do
begin
Rest := Src[I];
if Trim(Rest) = '' then
begin
ALines.Add('');
Continue;
end;
Line := '';
while Rest <> '' do
begin
Space := Pos(' ', Rest);
if Space = 0 then
begin
Word := Rest;
Rest := '';
end
else
begin
Word := Copy(Rest, 1, Space - 1);
Rest := Copy(Rest, Space + 1, Length(Rest));
end;
if Line = '' then
Line := Word
else if MeasureWidth(Line + ' ' + Word, AStyle, ASize) <= AMaxWidth then
Line := Line + ' ' + Word
else
begin
ALines.Add(Line);
Line := Word;
end;
end;
if Line <> '' then
ALines.Add(Line);
end;
finally
Src.Free;
end;
Result := ALines.Count;
end;
{ Подбор размера и переносов под отведённую полосу.
Идём от заданного размера вниз: он же и верхняя граница. Уменьшаем,
пока блок не влезет по высоте — по ширине перенос справляется сам. }
function FitText(const AText: string; const AStyle: TTextStyle;
AFrameWidth, AFrameHeight: Integer; ALines: TStringList): Integer;
var
MaxWidth, MaxHeight, Size, BlockHeight: Integer;
Lo, Hi, Mid, Best, Count: Integer;
begin
MaxWidth := Round(AFrameWidth * (1 - MarginShare * 2) * WidthSafety);
MaxHeight := Round(AFrameHeight * AStyle.MaxHeight);
Size := AStyle.FontSize;
if Size < MinFontSize then
Size := MinFontSize;
{ Ищем наибольший размер, при котором блок влезает, делением отрезка
пополам.
Прикидка «уменьшить во столько же раз, во сколько не влезли» даёт
слишком мелкий шрифт: после переноса строк становится меньше, и
места остаётся вдоволь. Проверено на длинной надписи — вместо 29
помещался 40. Десятка кругов хватает на любой разумный размер. }
Lo := MinFontSize;
Hi := Size;
Best := MinFontSize;
while Lo <= Hi do
begin
Mid := (Lo + Hi) div 2;
Count := WrapText(AText, AStyle, Mid, MaxWidth, ALines);
if Count < 1 then
Count := 1;
{ Высота блока по замеру: шаг строк равен размеру, последняя строка
занимает чуть меньше. }
BlockHeight := Round(Mid * (Count - 0.16));
if BlockHeight <= MaxHeight then
begin
Best := Mid;
Lo := Mid + 1;
end
else
Hi := Mid - 1;
end;
{ Последний перенос должен соответствовать выбранному размеру: во время
поиска он мог остаться от неподошедшей примерки. }
WrapText(AText, AStyle, Best, MaxWidth, ALines);
Result := Best;
end;
function FitTextForPreview(const AText: string; const AStyle: TTextStyle;
AFrameWidth, AFrameHeight: Integer; ALines: TStringList): Integer;
begin
Result := FitText(AText, AStyle, AFrameWidth, AFrameHeight, ALines);
end;
function HasText(AProject: TProject): Boolean;
var
T, C: Integer;
begin
Result := False;
if AProject = nil then
Exit;
for T := 0 to AProject.TrackCount - 1 do
begin
if AProject.Tracks[T].Kind <> tkText then
Continue;
if not AProject.TrackAudible(AProject.Tracks[T]) then
Continue;
for C := 0 to AProject.Tracks[T].Count - 1 do
if Trim(AProject.Tracks[T][C].Text) <> '' then
Exit(True);
end;
end;
function TextCovers(AProject: TProject; const AFrom, ATo: Double): Boolean;
var
T, C: Integer;
Clip: TClip;
begin
Result := False;
if AProject = nil then
Exit;
for T := 0 to AProject.TrackCount - 1 do
begin
if AProject.Tracks[T].Kind <> tkText then
Continue;
if not AProject.TrackAudible(AProject.Tracks[T]) then
Continue;
for C := 0 to AProject.Tracks[T].Count - 1 do
begin
Clip := AProject.Tracks[T][C];
if Trim(Clip.Text) = '' then
Continue;
{ Пересечение отрезков, а не попадание точки: кусок может быть
длиннее надписи и наоборот. }
if (Clip.TlStart < ATo - 0.0005) and (Clip.TlEnd > AFrom + 0.0005) then
Exit(True);
end;
end;
end;
{ Общая сборка. AFrom и ATo ограничивают выборку; ATo меньше нуля значит
«до конца монтажа». При ненулевом AFrom времена сдвигаются к нулю — так
разметка годится для отдельного куска. }
function BuildAssRange(AProject: TProject; const AFrom, ATo: Double): string;
var
Lines: TStringList;
Styles, Events: TStringList;
T, C, I, Size: Integer;
Clip: TClip;
Track: TTrack;
Name, Body: string;
W, H, MarginH, MarginV: Integer;
StartAt, EndAt: Double;
begin
Result := '';
if (AProject = nil) or (not HasText(AProject)) then
Exit;
W := AProject.Master.Width;
H := AProject.Master.Height;
if (W < 2) or (H < 2) then
begin
W := 1920;
H := 1080;
end;
MarginH := Round(W * MarginShare);
MarginV := Round(H * MarginShare);
Lines := TStringList.Create;
Styles := TStringList.Create;
Events := TStringList.Create;
try
for T := 0 to AProject.TrackCount - 1 do
begin
Track := AProject.Tracks[T];
if Track.Kind <> tkText then
Continue;
if not AProject.TrackAudible(Track) then
Continue;
for C := 0 to Track.Count - 1 do
begin
Clip := Track[C];
if Trim(Clip.Text) = '' then
Continue;
{ Надписи, не попадающие в нужный отрезок, разбирать незачем. }
if (ATo >= 0) and (Clip.TlStart >= ATo - 0.0005) then
Continue;
if Clip.TlEnd <= AFrom + 0.0005 then
Continue;
Size := FitText(Clip.Text, Clip.TextStyle, W, H, Lines);
{ У каждого клипа своё оформление, поэтому и свой стиль. Имя
выводим из номера дорожки и клипа — оно должно быть
неповторимым и не содержать запятых. }
Name := Format('s%d_%d', [T, C]);
Styles.Add(Format(
'Style: %s,%s,%d,%s,%s,%s,%s,%d,%d,0,0,100,100,0,0,1,%d,%d,%d,' +
'%d,%d,%d,1',
[Name, Clip.TextStyle.FontName, Size,
AssColor(Clip.TextStyle.Color),
AssColor(Clip.TextStyle.Color),
AssColor(Clip.TextStyle.OutlineColor),
AssColor(Clip.TextStyle.ShadowColor,
Clip.TextStyle.Shadow <= 0),
Ord(Clip.TextStyle.Bold) * -1, Ord(Clip.TextStyle.Italic) * -1,
Clip.TextStyle.Outline, Clip.TextStyle.Shadow,
Ord(Clip.TextStyle.Place),
MarginH, MarginH, MarginV]));
Body := '';
for I := 0 to Lines.Count - 1 do
begin
if Body <> '' then
Body := Body + '\N';
Body := Body + AssEscape(Lines[I]);
end;
{ Обрезаем по границам куска и сдвигаем к его началу. }
StartAt := Max(Clip.TlStart, AFrom) - AFrom;
EndAt := Clip.TlEnd - AFrom;
if ATo >= 0 then
EndAt := Min(EndAt, ATo - AFrom);
if EndAt <= StartAt then
Continue;
Events.Add(Format('Dialogue: 0,%s,%s,%s,,0,0,0,,%s',
[AssTime(StartAt), AssTime(EndAt), Name, Body]));
end;
end;
if Events.Count = 0 then
Exit;
Result :=
'[Script Info]' + LineEnding +
'ScriptType: v4.00+' + LineEnding +
Format('PlayResX: %d', [W]) + LineEnding +
Format('PlayResY: %d', [H]) + LineEnding +
{ Переносы мы уже расставили, libass переносить нечего. }
'WrapStyle: 2' + LineEnding +
'ScaledBorderAndShadow: yes' + LineEnding +
'YCbCr Matrix: None' + LineEnding +
LineEnding +
'[V4+ Styles]' + LineEnding +
'Format: Name,Fontname,Fontsize,PrimaryColour,SecondaryColour,' +
'OutlineColour,BackColour,Bold,Italic,Underline,StrikeOut,ScaleX,' +
'ScaleY,Spacing,Angle,BorderStyle,Outline,Shadow,Alignment,' +
'MarginL,MarginR,MarginV,Encoding' + LineEnding +
Styles.Text +
LineEnding +
'[Events]' + LineEnding +
'Format: Layer,Start,End,Style,Name,MarginL,MarginR,MarginV,' +
'Effect,Text' + LineEnding +
Events.Text;
finally
Events.Free;
Styles.Free;
Lines.Free;
end;
end;
function BuildAss(AProject: TProject): string;
begin
Result := BuildAssRange(AProject, 0, -1);
end;
function WriteText(const AText, APath: string): Boolean;
var
Stream: TFileStream;
begin
Result := False;
try
ForceDirectories(ExtractFilePath(APath));
Stream := TFileStream.Create(APath, fmCreate);
try
if Length(AText) > 0 then
Stream.WriteBuffer(AText[1], Length(AText));
finally
Stream.Free;
end;
Result := True;
except
Result := False;
end;
end;
function WriteAssForRange(AProject: TProject; const AFrom, ATo: Double;
const APath: string): Boolean;
var
Text: string;
begin
Text := BuildAssRange(AProject, AFrom, ATo);
Result := (Text <> '') and WriteText(Text, APath);
end;
function BurnTextIntoSegments(AProject: TProject;
var ASegments: TFlatSegmentArray; const ACacheDir: string;
const AStartTime: Double): Double;
var
I: Integer;
At, SegEnd: Double;
Path: string;
begin
Result := 0;
if (AProject = nil) or (not HasText(AProject)) then
Exit;
At := AStartTime;
for I := 0 to High(ASegments) do
begin
SegEnd := At + ASegments[I].Duration;
if TextCovers(AProject, At, SegEnd) then
begin
Path := IncludeTrailingPathDelimiter(ACacheDir) +
Format('burn_%.3d.ass', [I]);
if WriteAssForRange(AProject, At, SegEnd, Path) then
begin
{ Кусок, который и так пересобирался, дороже не становится —
считаем только те, что раньше копировались. }
if not (ASegments[I].Transcode or (Length(ASegments[I].Layers) > 0)) then
Result := Result + ASegments[I].Duration;
ASegments[I].SubFile := Path;
ASegments[I].Transcode := True;
end;
end;
At := SegEnd;
end;
end;
function WriteAssFile(AProject: TProject; const APath: string): Boolean;
var
Text: string;
begin
Result := False;
Text := BuildAss(AProject);
if Text = '' then
begin
{ Текста не стало — старый файл убираем, иначе плеер покажет то,
чего на монтаже уже нет. }
if FileExists(APath) then
SysUtils.DeleteFile(APath);
Exit;
end;
Result := WriteText(Text, APath);
end;
end.

372
src/uTextForm.pas Normal file
View file

@ -0,0 +1,372 @@
{
uTextForm — окно текстового клипа.
Всё, что можно задать надписи: сам текст, шрифт, размер, три цвета,
толщина обводки, тень, место в кадре и доля высоты, которую тексту
позволено занять.
Место выбирается сеткой три на три — это девять положений ASS, ровно
тех, что понимает libass. Кнопки сетки хранят номер положения прямо в
Tag: перекладывать его в отдельный список незачем.
Размер шрифта здесь — верхняя граница, а не приказ. Если текст не
влезает в отведённую полосу, при сборке разметки он уменьшится; здесь
же рядом показано, до чего именно.
}
unit uTextForm;
{$mode objfpc}{$H+}
interface
uses
Classes, SysUtils, Controls, Forms, Graphics, StdCtrls, Dialogs, ExtCtrls,
uAppTypes, uTheme, uProject;
{ Показывает окно. True — человек нажал «Готово», и в AText с AStyle лежит
изменённое. }
function EditTextClip(var AText: string; var AStyle: TTextStyle;
AFrameWidth, AFrameHeight: Integer): Boolean;
implementation
uses
uSubtitles;
type
TTextDialog = class(TComponent)
public
Form: TForm;
Memo: TMemo;
cbFont: TComboBox;
edSize: TEdit;
edOutline: TEdit;
edShadow: TEdit;
edHeight: TEdit;
cbBold: TCheckBox;
cbItalic: TCheckBox;
BtnColor: TThemedButton;
BtnOutlineColor: TThemedButton;
BtnShadowColor: TThemedButton;
Places: array[1..9] of TThemedButton;
lblFit: TLabel;
Style: TTextStyle;
FrameW: Integer;
FrameH: Integer;
procedure PlaceClick(Sender: TObject);
procedure ColorClick(Sender: TObject);
procedure AnyChange(Sender: TObject);
procedure ReadControls;
procedure UpdateFit;
end;
{ Кнопка цвета показывает сам цвет: подпись «выбрать» ничего не сообщает,
а образец нужного цвета сообщает всё. }
procedure PaintColorButton(ABtn: TThemedButton; AColor: TRgb);
begin
ABtn.Swatch := TColor(AColor);
end;
procedure TTextDialog.PlaceClick(Sender: TObject);
var
I: Integer;
begin
Style.Place := TTextPlace((Sender as TThemedButton).Tag);
for I := 1 to 9 do
Places[I].Down := Places[I].Tag = Ord(Style.Place);
UpdateFit;
end;
procedure TTextDialog.ColorClick(Sender: TObject);
var
Dlg: TColorDialog;
Btn: TThemedButton;
begin
Btn := Sender as TThemedButton;
Dlg := TColorDialog.Create(nil);
try
if Btn = BtnColor then
Dlg.Color := TColor(Style.Color)
else if Btn = BtnOutlineColor then
Dlg.Color := TColor(Style.OutlineColor)
else
Dlg.Color := TColor(Style.ShadowColor);
if not Dlg.Execute then
Exit;
if Btn = BtnColor then
Style.Color := TRgb(Dlg.Color)
else if Btn = BtnOutlineColor then
Style.OutlineColor := TRgb(Dlg.Color)
else
Style.ShadowColor := TRgb(Dlg.Color);
PaintColorButton(Btn, TRgb(Dlg.Color));
finally
Dlg.Free;
end;
end;
procedure TTextDialog.ReadControls;
var
V: Integer;
D: Double;
begin
if cbFont.ItemIndex >= 0 then
Style.FontName := cbFont.Items[cbFont.ItemIndex];
V := StrToIntDef(Trim(edSize.Text), Style.FontSize);
if (V >= 8) and (V <= 400) then
Style.FontSize := V;
V := StrToIntDef(Trim(edOutline.Text), Style.Outline);
if (V >= 0) and (V <= 20) then
Style.Outline := V;
V := StrToIntDef(Trim(edShadow.Text), Style.Shadow);
if (V >= 0) and (V <= 20) then
Style.Shadow := V;
{ Доля вводится процентами: «двадцать» понятнее, чем «ноль целых два». }
V := StrToIntDef(Trim(edHeight.Text), Round(Style.MaxHeight * 100));
if (V >= 5) and (V <= 100) then
begin
D := V / 100;
Style.MaxHeight := D;
end;
Style.Bold := cbBold.Checked;
Style.Italic := cbItalic.Checked;
end;
{ Показывает, во что превратится надпись: сколько строк и каким размером.
Считает тот же код, что и сборка разметки, поэтому расхождения быть не
может. }
procedure TTextDialog.UpdateFit;
var
Lines: TStringList;
Size: Integer;
begin
ReadControls;
Lines := TStringList.Create;
try
Size := FitTextForPreview(Memo.Lines.Text, Style, FrameW, FrameH, Lines);
if Size < Style.FontSize then
lblFit.Caption := Format(
'Не влезает: размер уменьшится с %d до %d, строк — %d',
[Style.FontSize, Size, Lines.Count])
else
lblFit.Caption := Format('Влезает: размер %d, строк — %d',
[Size, Lines.Count]);
finally
Lines.Free;
end;
end;
procedure TTextDialog.AnyChange(Sender: TObject);
begin
UpdateFit;
end;
function EditTextClip(var AText: string; var AStyle: TTextStyle;
AFrameWidth, AFrameHeight: Integer): Boolean;
const
Rows: array[0..2] of Integer = (7, 4, 1);
var
Dlg: TTextDialog;
BtnOk, BtnCancel: TThemedButton;
Lbl: TLabel;
I, R, C, Place: Integer;
Grid: TPanel;
begin
Result := False;
Dlg := TTextDialog.Create(nil);
try
Dlg.Style := AStyle;
Dlg.FrameW := AFrameWidth;
Dlg.FrameH := AFrameHeight;
Dlg.Form := TForm.CreateNew(nil);
Dlg.Form.Caption := 'Текст';
Dlg.Form.Position := poScreenCenter;
Dlg.Form.ClientWidth := 640;
Dlg.Form.ClientHeight := 470;
Lbl := TLabel.Create(Dlg.Form);
Lbl.Parent := Dlg.Form;
Lbl.SetBounds(12, 10, 200, 20);
Lbl.Caption := 'Текст надписи';
Dlg.Memo := TMemo.Create(Dlg.Form);
Dlg.Memo.Parent := Dlg.Form;
Dlg.Memo.SetBounds(12, 32, 616, 100);
Dlg.Memo.Anchors := [akTop, akLeft, akRight];
Dlg.Memo.ScrollBars := ssAutoVertical;
Dlg.Memo.Text := AText;
Dlg.Memo.OnChange := @Dlg.AnyChange;
Lbl := TLabel.Create(Dlg.Form);
Lbl.Parent := Dlg.Form;
Lbl.SetBounds(12, 146, 60, 20);
Lbl.Caption := 'Шрифт';
Dlg.cbFont := TComboBox.Create(Dlg.Form);
Dlg.cbFont.Parent := Dlg.Form;
Dlg.cbFont.SetBounds(80, 142, 260, 26);
Dlg.cbFont.Style := csDropDownList;
Dlg.cbFont.Items.Assign(Screen.Fonts);
Dlg.cbFont.ItemIndex := Dlg.cbFont.Items.IndexOf(AStyle.FontName);
if Dlg.cbFont.ItemIndex < 0 then
Dlg.cbFont.ItemIndex := Dlg.cbFont.Items.IndexOf(DefaultTextFont);
Dlg.cbFont.OnChange := @Dlg.AnyChange;
Lbl := TLabel.Create(Dlg.Form);
Lbl.Parent := Dlg.Form;
Lbl.SetBounds(356, 146, 60, 20);
Lbl.Caption := 'Размер';
Dlg.edSize := TEdit.Create(Dlg.Form);
Dlg.edSize.Parent := Dlg.Form;
Dlg.edSize.SetBounds(420, 142, 60, 26);
Dlg.edSize.Text := IntToStr(AStyle.FontSize);
Dlg.edSize.OnChange := @Dlg.AnyChange;
Dlg.cbBold := TCheckBox.Create(Dlg.Form);
Dlg.cbBold.Parent := Dlg.Form;
Dlg.cbBold.SetBounds(492, 144, 76, 22);
Dlg.cbBold.Caption := 'Жирный';
Dlg.cbBold.Checked := AStyle.Bold;
Dlg.cbBold.OnChange := @Dlg.AnyChange;
Dlg.cbItalic := TCheckBox.Create(Dlg.Form);
Dlg.cbItalic.Parent := Dlg.Form;
Dlg.cbItalic.SetBounds(568, 144, 66, 22);
Dlg.cbItalic.Caption := 'Косой';
Dlg.cbItalic.Checked := AStyle.Italic;
Dlg.cbItalic.OnChange := @Dlg.AnyChange;
{ --- Цвета --- }
Lbl := TLabel.Create(Dlg.Form);
Lbl.Parent := Dlg.Form;
Lbl.SetBounds(12, 186, 60, 20);
Lbl.Caption := 'Цвет';
Dlg.BtnColor := TThemedButton.Create(Dlg.Form);
Dlg.BtnColor.Parent := Dlg.Form;
Dlg.BtnColor.SetBounds(80, 182, 70, 26);
Dlg.BtnColor.OnClick := @Dlg.ColorClick;
PaintColorButton(Dlg.BtnColor, AStyle.Color);
Lbl := TLabel.Create(Dlg.Form);
Lbl.Parent := Dlg.Form;
Lbl.SetBounds(160, 186, 70, 20);
Lbl.Caption := 'Обводка';
Dlg.BtnOutlineColor := TThemedButton.Create(Dlg.Form);
Dlg.BtnOutlineColor.Parent := Dlg.Form;
Dlg.BtnOutlineColor.SetBounds(232, 182, 70, 26);
Dlg.BtnOutlineColor.OnClick := @Dlg.ColorClick;
PaintColorButton(Dlg.BtnOutlineColor, AStyle.OutlineColor);
Dlg.edOutline := TEdit.Create(Dlg.Form);
Dlg.edOutline.Parent := Dlg.Form;
Dlg.edOutline.SetBounds(310, 182, 40, 26);
Dlg.edOutline.Text := IntToStr(AStyle.Outline);
Dlg.edOutline.OnChange := @Dlg.AnyChange;
Lbl := TLabel.Create(Dlg.Form);
Lbl.Parent := Dlg.Form;
Lbl.SetBounds(360, 186, 50, 20);
Lbl.Caption := 'Тень';
Dlg.BtnShadowColor := TThemedButton.Create(Dlg.Form);
Dlg.BtnShadowColor.Parent := Dlg.Form;
Dlg.BtnShadowColor.SetBounds(412, 182, 70, 26);
Dlg.BtnShadowColor.OnClick := @Dlg.ColorClick;
PaintColorButton(Dlg.BtnShadowColor, AStyle.ShadowColor);
Dlg.edShadow := TEdit.Create(Dlg.Form);
Dlg.edShadow.Parent := Dlg.Form;
Dlg.edShadow.SetBounds(490, 182, 40, 26);
Dlg.edShadow.Text := IntToStr(AStyle.Shadow);
Dlg.edShadow.OnChange := @Dlg.AnyChange;
{ --- Место в кадре --- }
Lbl := TLabel.Create(Dlg.Form);
Lbl.Parent := Dlg.Form;
Lbl.SetBounds(12, 224, 160, 20);
Lbl.Caption := 'Место в кадре';
Grid := TPanel.Create(Dlg.Form);
Grid.Parent := Dlg.Form;
Grid.SetBounds(12, 246, 130, 130);
Grid.BevelOuter := bvNone;
for R := 0 to 2 do
for C := 0 to 2 do
begin
Place := Rows[R] + C;
Dlg.Places[Place] := TThemedButton.Create(Dlg.Form);
Dlg.Places[Place].Parent := Grid;
Dlg.Places[Place].SetBounds(C * 44, R * 44, 40, 40);
Dlg.Places[Place].Tag := Place;
Dlg.Places[Place].TabStop := False;
Dlg.Places[Place].Down := Place = Ord(AStyle.Place);
Dlg.Places[Place].OnClick := @Dlg.PlaceClick;
end;
Lbl := TLabel.Create(Dlg.Form);
Lbl.Parent := Dlg.Form;
Lbl.SetBounds(160, 250, 260, 20);
Lbl.Caption := 'Доля высоты кадра под текст, %';
Dlg.edHeight := TEdit.Create(Dlg.Form);
Dlg.edHeight.Parent := Dlg.Form;
Dlg.edHeight.SetBounds(420, 246, 60, 26);
Dlg.edHeight.Text := IntToStr(Round(AStyle.MaxHeight * 100));
Dlg.edHeight.OnChange := @Dlg.AnyChange;
Dlg.lblFit := TLabel.Create(Dlg.Form);
Dlg.lblFit.Parent := Dlg.Form;
Dlg.lblFit.SetBounds(160, 284, 460, 40);
Dlg.lblFit.AutoSize := False;
Dlg.lblFit.WordWrap := True;
BtnOk := TThemedButton.Create(Dlg.Form);
BtnOk.Parent := Dlg.Form;
BtnOk.SetBounds(420, 424, 100, 30);
BtnOk.Anchors := [akRight, akBottom];
BtnOk.Caption := 'Готово';
BtnOk.ModalResult := mrOk;
BtnOk.Default := True;
BtnCancel := TThemedButton.Create(Dlg.Form);
BtnCancel.Parent := Dlg.Form;
BtnCancel.SetBounds(528, 424, 100, 30);
BtnCancel.Anchors := [akRight, akBottom];
BtnCancel.Caption := 'Отмена';
BtnCancel.ModalResult := mrCancel;
BtnCancel.Cancel := True;
ApplyDarkTheme(Dlg.Form);
{ Цвета кнопок ставятся после раскраски: она сбрасывает цвет надписи
всем кнопкам разом. }
PaintColorButton(Dlg.BtnColor, Dlg.Style.Color);
PaintColorButton(Dlg.BtnOutlineColor, Dlg.Style.OutlineColor);
PaintColorButton(Dlg.BtnShadowColor, Dlg.Style.ShadowColor);
Dlg.UpdateFit;
if Dlg.Form.ShowModal <> mrOk then
Exit;
Dlg.ReadControls;
AText := TrimRight(Dlg.Memo.Lines.Text);
AStyle := Dlg.Style;
Result := True;
finally
Dlg.Form.Free;
Dlg.Free;
end;
end;
end.

62
src/uTextMeasure.pas Normal file
View file

@ -0,0 +1,62 @@
{
uTextMeasure — точный замер ширины текста для подбора размера.
Живёт отдельно от uSubtitles нарочно: там модель, а здесь холст, то есть
оконная библиотека. Служебная программа собирает субтитры без окна и
обходится приблизительной оценкой; окно ставит сюда точный замер.
Меряем тем же шрифтом и тем же движком, каким libass будет рисовать: за
обоими стоит FreeType. Полного совпадения нет — настройки сглаживания
разные, — поэтому в подборе размера заложен запас.
}
unit uTextMeasure;
{$mode objfpc}{$H+}
interface
{ Ставит точный замер в uSubtitles. Вызывается один раз при запуске. }
procedure InstallTextMeasure;
implementation
uses
SysUtils, Graphics, uProject, uSubtitles;
var
{ Холст для замеров. Один на всё время работы: заводить растр на каждое
слово было бы расточительно, а слов бывают тысячи. }
Bitmap: TBitmap = nil;
function MeasureText(const AText: string; const AStyle: TTextStyle;
ASize: Integer): Integer;
begin
if Bitmap = nil then
begin
Bitmap := TBitmap.Create;
Bitmap.SetSize(4, 4);
end;
Bitmap.Canvas.Font.Name := AStyle.FontName;
{ Height, а не Size: он задаётся в точках холста, и отношение к размеру
в кадре получается прямым. Size пересчитывался бы через разрешение
экрана, а оно к кадру проекта отношения не имеет. }
Bitmap.Canvas.Font.Height := ASize;
Bitmap.Canvas.Font.Style := [];
if AStyle.Bold then
Bitmap.Canvas.Font.Style := Bitmap.Canvas.Font.Style + [fsBold];
if AStyle.Italic then
Bitmap.Canvas.Font.Style := Bitmap.Canvas.Font.Style + [fsItalic];
Result := Bitmap.Canvas.TextWidth(AText);
end;
procedure InstallTextMeasure;
begin
uSubtitles.TextMeasure := @MeasureText;
end;
finalization
Bitmap.Free;
end.

View file

@ -18,8 +18,10 @@
- полосы прокрутки внутри списков и журнала рисует система;
- рамка окна и её кнопки принадлежат оконному менеджеру;
- меню под gtk2 своими руками не рисуется — в LCL это прямо помечено
как неподдерживаемое (interfaces/gtk2/issues.xml). Под Windows
рисуется, там это и нужно.
как неподдерживаемое (interfaces/gtk2/issues.xml). Зато сам gtk
принимает описание стиля текстом, и цвет меню задаётся так
(ApplyMenuStyle). Под Windows наоборот: строк стиля нет, зато
работает отрисовка пунктов своими руками (ApplyDarkMenu).
}
unit uTheme;
@ -29,7 +31,8 @@ interface
uses
Classes, SysUtils, Types, Controls, Graphics, Forms, StdCtrls, ExtCtrls,
ComCtrls, Grids, Menus, Dialogs, LCLType, LCLIntf, LCLProc;
ComCtrls, Grids, Menus, Dialogs, Math, IntfGraphics, FPimage,
LCLType, LCLIntf, LCLProc, uIconData;
const
{ Порядок байт в TColor — $00BBGGRR, а не привычный RGB. У серых цветов
@ -49,8 +52,46 @@ const
ThemeSelection = TColor($785434); // приглушённый синий: выделение
ThemeSelText = TColor($FFFFFF);
ThemeGridLine = TColor($3A3A3A);
ThemeLink = TColor($E0A060); // голубой: ссылка
type
{ Значок на кнопке.
Картинки вшиты в программу (uIconData): зависеть от того, лежит ли
рядом с ней файл, она не должна. Исходники — в icons_online, оттуда
же требование указать авторство, см. окно «О программе».
Цвет значку задаётся при отрисовке: берётся прозрачность точки, а
цвет — тот, каким сейчас рисуется кнопка. Поэтому один значок годится
и для тёмной кнопки, и для оранжевой, и для недоступной. }
TThemedIcon = (
tiNone,
tiSnap, // прилипание: края притягиваются к общей линии
tiRipple, // схлопывание: дыра между кусками закрывается
tiLog, // журнал: полоса с сообщениями
tiGlue, // курсор липнет к границам клипов
tiTarget // курсор держится середины монтажа
);
{ Полоска выполнения, которая рисует себя сама.
Системная берёт цвета у темы оформления и на тёмном окне остаётся
светлой — та же причина, по которой здесь своя кнопка и свои полосы
прокрутки.
Доля хранится числом от нуля до единицы, а не «позицией» и
«максимумом»: снаружи она всегда получается делением одного на
другое, и хранить обе части незачем. }
TThemedProgress = class(TGraphicControl)
private
FFraction: Double;
procedure SetFraction(const AValue: Double);
protected
procedure Paint; override;
public
constructor Create(AOwner: TComponent); override;
property Fraction: Double read FFraction write SetFraction;
end;
{ Кнопка, которая рисует себя сама.
@ -60,6 +101,8 @@ type
TThemedButton = class(TCustomControl)
private
FArrow: Boolean;
FSwatch: TColor;
FIcon: TThemedIcon;
FFlat: Boolean;
FDown: Boolean;
FHover: Boolean;
@ -68,6 +111,9 @@ type
FCancel: Boolean;
FModalResult: TModalResult;
procedure SetArrow(AValue: Boolean);
procedure SetIcon(AValue: TThemedIcon);
procedure SetSwatch(AValue: TColor);
procedure DrawIcon(const ARect: TRect; AColor: TColor);
procedure SetFlat(AValue: Boolean);
procedure SetDown(AValue: Boolean);
procedure SetDefault(AValue: Boolean);
@ -100,6 +146,12 @@ type
property Arrow: Boolean read FArrow write SetArrow default False;
property Cancel: Boolean read FCancel write SetCancel default False;
property Caption;
{ Значок вместо надписи. Надпись при этом не пропадает зря: её место —
подсказка, которая всплывает по наведению. }
property Icon: TThemedIcon read FIcon write SetIcon default tiNone;
{ Образец цвета вместо надписи: кнопка выбора цвета должна показывать
сам цвет, а не слово «выбрать». clNone — обычная кнопка. }
property Swatch: TColor read FSwatch write SetSwatch default clNone;
property Default: Boolean read FDefault write SetDefault default False;
property Down: Boolean read FDown write SetDown default False;
property Enabled;
@ -131,9 +183,41 @@ procedure ApplyDarkTheme(AComponent: TComponent);
при запуске. }
procedure HookNewForms;
{ Меню в наших цветах. Под gtk2 не действует — там меню рисует GTK. }
{ Меню в наших цветах отрисовкой своими руками. Действует только под
win32; под gtk2 цвет задаётся строкой стиля, см. ApplyMenuStyle. }
procedure ApplyDarkMenu(AMenu: TMenu);
{ Закрашивает остаток полосы меню — то место правее последнего названия,
которое не занято ни одним пунктом.
Нужно потому, что до него не дотянуться ни отрисовкой пунктов (это не
пункт), ни подложкой через SetMenuInfo (проверено под Wine: пункты
тёмные, полоса светлая). Полоса живёт в неклиентской части окна,
поэтому рисуем прямо в её область.
Под gtk2 ничего не делает: там всю полосу красит строка стиля. }
procedure PaintMenuBarTail(AForm: TCustomForm);
{ Что вышло у последней закраски: какие прямоугольники выдала система и
куда мы рисовали. Нужно, чтобы не гадать, почему полоса осталась
светлой. }
function MenuBarTailNote: string;
{ Цвета для меню, которое рисует не программа, а система.
Вызывать до создания окон: строка стиля разбирается один раз, и уже
созданные виджеты о ней не узнают.
Под gtk2 это единственный способ. Собственных средств у LCL там нет —
OwnerDraw, OnDrawItem и OnMeasureItem в gtk2 прямо помечены как
неподдерживаемые (lcl/interfaces/gtk2/issues.xml), и событие из
инспектора объектов просто никогда не наступит. Зато сам gtk принимает
описание стиля текстом.
Под win32 ничего не делает: там работает отрисовка своими руками через
ApplyDarkMenu. }
procedure ApplyMenuStyle;
{ Окно сообщения в наших цветах вместо системного.
Кнопки задаются готовым набором, как у MessageDlg, но рисуются нашими.
@ -142,8 +226,26 @@ function ThemedMessageDlg(const ACaption, AText: string;
ADlgType: TMsgDlgType; AButtons: TMsgDlgButtons;
AHelpCtx: Longint = 0): TModalResult;
{ Вопрос со своими кнопками. Возвращает номер нажатой, считая с нуля, или
-1, если окно просто закрыли.
Нужен там, где готовых «Да / Нет / Отмена» не хватает: например,
третьей кнопкой «Больше не напоминать». }
function ThemedChoiceDlg(const ACaption, AText: string;
const ACaptions: array of string): Integer;
{ Ввод одной строки в тёмном окне. Системный InputQuery выглядел бы белым
пятном посреди тёмной программы. }
function ThemedInputDlg(const ACaption, APrompt: string;
var AValue: string): Boolean;
implementation
{$IFDEF LCLGTK2}
uses
glib2, gtk2;
{$ENDIF}
{$IFDEF WINDOWS}
{ Модуль Windows целиком подключать нельзя: он объявляет свои TRect и
RegisterClass, а имена в Паскале нечувствительны к регистру — и обычные
@ -166,6 +268,42 @@ const
function SetMenuInfo(AMenu: HMENU; const AInfo: TMenuInfoRec): LongBool;
stdcall; external 'user32' name 'SetMenuInfo';
{ Имя своё, а не DrawMenuBar: так называется и функция LCL, а имена в
Паскале нечувствительны к регистру. По той же причине своё имя у всех
остальных вызовов ниже. }
function SysDrawMenuBar(AWnd: HWND): LongBool;
stdcall; external 'user32' name 'DrawMenuBar';
type
TMenuBarInfoRec = record
cbSize: DWORD;
rcBar: TRect;
hMenu: HMENU;
hwndMenu: HWND;
Flags: DWORD; { два битовых признака, нам не нужны }
end;
const
{ Знаковое, как в заголовках Windows: -3. }
OBJID_MENU = LongInt(-3);
function SysGetMenuBarInfo(AWnd: HWND; AObject, AItem: LongInt;
var AInfo: TMenuBarInfoRec): LongBool;
stdcall; external 'user32' name 'GetMenuBarInfo';
function SysGetMenuItemRect(AWnd: HWND; AMenu: HMENU; AItem: UINT;
var ARect: TRect): LongBool;
stdcall; external 'user32' name 'GetMenuItemRect';
function SysGetMenuItemCount(AMenu: HMENU): Integer;
stdcall; external 'user32' name 'GetMenuItemCount';
function SysGetWindowDC(AWnd: HWND): HDC;
stdcall; external 'user32' name 'GetWindowDC';
function SysReleaseDC(AWnd: HWND; ADC: HDC): Integer;
stdcall; external 'user32' name 'ReleaseDC';
function SysFillRect(ADC: HDC; const ARect: TRect; ABrush: HBRUSH): Integer;
stdcall; external 'user32' name 'FillRect';
function SysGetWindowRect(AWnd: HWND; var ARect: TRect): LongBool;
stdcall; external 'user32' name 'GetWindowRect';
{$ENDIF}
type
@ -183,6 +321,8 @@ type
var
Painter: TThemePainter;
{ Перекрашенные значки: ключ «номер значка : цвет». }
IconCache: TStringList;
{$IFDEF WINDOWS}
{ Кисть под полосу меню. Создаётся один раз: Windows держит на неё
ссылку, пока меню живо, и удалять её раньше нельзя. }
@ -193,6 +333,54 @@ const
MenuTextLeft = 26; { отступ слева: место под галочку }
MenuTextRight = 16; { отступ справа: место под стрелку подменю }
MenuGap = 28; { просвет между названием и сочетанием клавиш }
MenuBarSide = 10; { отступ по краям названия в полосе меню }
{ TThemedProgress }
constructor TThemedProgress.Create(AOwner: TComponent);
begin
inherited Create(AOwner);
FFraction := 0;
Height := 6;
Width := 120;
end;
procedure TThemedProgress.SetFraction(const AValue: Double);
var
V: Double;
begin
V := AValue;
if V < 0 then
V := 0;
if V > 1 then
V := 1;
{ Перерисовываем только при заметном изменении: полоска обновляется по
таймеру, и дёргать отрисовку на каждую тысячную незачем. }
if Abs(V - FFraction) < 0.002 then
Exit;
FFraction := V;
Invalidate;
end;
procedure TThemedProgress.Paint;
var
R: TRect;
W: Integer;
begin
R := ClientRect;
Canvas.Brush.Style := bsSolid;
Canvas.Brush.Color := ThemeButtonDown;
Canvas.FillRect(R);
W := Round(R.Right * FFraction);
if W > 0 then
begin
Canvas.Brush.Color := ThemeAccent;
Canvas.FillRect(Rect(0, 0, W, R.Bottom));
end;
Canvas.Brush.Style := bsClear;
Canvas.Pen.Color := ThemeBorder;
Canvas.Rectangle(R);
end;
{ TThemedButton }
@ -204,6 +392,9 @@ begin
ControlStyle := ControlStyle + [csHasDefaultAction, csHasCancelAction];
TabStop := True;
DoubleBuffered := True;
{ Без явного значения образец был бы чёрным (ноль — это clBlack), и
чёрные прямоугольники полезли бы на все кнопки разом. }
FSwatch := clNone;
with GetControlClassDefaultSize do
SetInitialBounds(0, 0, cx, cy);
end;
@ -214,6 +405,121 @@ begin
Result.cy := 28;
end;
procedure TThemedButton.SetSwatch(AValue: TColor);
begin
if FSwatch = AValue then
Exit;
FSwatch := AValue;
Invalidate;
end;
procedure TThemedButton.SetIcon(AValue: TThemedIcon);
begin
if FIcon = AValue then
Exit;
FIcon := AValue;
Invalidate;
end;
{ Значок в нужном цвете.
Картинка чёрная с прозрачностью, поэтому перекрашивается прямо по
точкам: прозрачность остаётся своя, цвет ставится наш. Результат
запоминается — цветов ровно три (обычный, на оранжевом, недоступный), и
пересчитывать их на каждую перерисовку незачем. }
function ThemedIconImage(AIcon: TThemedIcon; AColor: TColor):
TPortableNetworkGraphic;
var
Key: string;
Idx, X, Y: Integer;
Stream: TMemoryStream;
Img: TLazIntfImage;
Px: TFPColor;
R, G, B: Byte;
begin
Result := nil;
if AIcon = tiNone then
Exit;
Key := IntToStr(Ord(AIcon)) + ':' + IntToStr(AColor);
if IconCache = nil then
begin
IconCache := TStringList.Create;
IconCache.Sorted := True;
IconCache.OwnsObjects := True;
end;
Idx := IconCache.IndexOf(Key);
if Idx >= 0 then
Exit(TPortableNetworkGraphic(IconCache.Objects[Idx]));
Stream := TMemoryStream.Create;
try
case AIcon of
tiSnap: Stream.WriteBuffer(IconSnapData[0], Length(IconSnapData));
tiRipple: Stream.WriteBuffer(IconRippleData[0], Length(IconRippleData));
tiLog: Stream.WriteBuffer(IconLogData[0], Length(IconLogData));
tiGlue: Stream.WriteBuffer(IconGlueData[0], Length(IconGlueData));
tiTarget: Stream.WriteBuffer(IconTargetData[0], Length(IconTargetData));
else
Exit;
end;
Stream.Position := 0;
Result := TPortableNetworkGraphic.Create;
try
Result.LoadFromStream(Stream);
{ Порядок байт в TColor обратный привычному. }
R := AColor and $FF;
G := (AColor shr 8) and $FF;
B := (AColor shr 16) and $FF;
Img := Result.CreateIntfImage;
try
for Y := 0 to Img.Height - 1 do
for X := 0 to Img.Width - 1 do
begin
Px := Img.Colors[X, Y];
{ Прозрачное трогать незачем, а видимое красим целиком. }
if Px.Alpha = 0 then
Continue;
Px.Red := R * 257;
Px.Green := G * 257;
Px.Blue := B * 257;
Img.Colors[X, Y] := Px;
end;
Result.LoadFromIntfImage(Img);
finally
Img.Free;
end;
except
FreeAndNil(Result);
end;
finally
Stream.Free;
end;
{ Неудачу тоже запоминаем: иначе разбирать картинку заново пришлось бы
на каждой перерисовке. }
IconCache.AddObject(Key, Result);
end;
{ Значок рисуется без растягивания: картинка вшита ровно того размера, под
который сделана кнопка. Если кнопка оказалась крупнее — значок остаётся
своего размера и стоит по центру, а не расплывается. }
procedure TThemedButton.DrawIcon(const ARect: TRect; AColor: TColor);
var
Img: TPortableNetworkGraphic;
X, Y: Integer;
begin
Img := ThemedIconImage(FIcon, AColor);
if (Img = nil) or (Img.Width = 0) then
Exit;
X := (ARect.Left + ARect.Right - Img.Width) div 2;
Y := (ARect.Top + ARect.Bottom - Img.Height) div 2;
Canvas.Draw(X, Y, Img);
end;
procedure TThemedButton.Paint;
var
R: TRect;
@ -279,6 +585,16 @@ begin
Canvas.Polygon([Point(CX - 5, CY - 2), Point(CX + 5, CY - 2),
Point(CX, CY + 4)]);
end
else if FSwatch <> clNone then
begin
{ Образец с рамкой: без неё белый цвет на светлой кнопке сливается. }
Canvas.Brush.Color := FSwatch;
Canvas.Brush.Style := bsSolid;
Canvas.Pen.Color := ThemeBorder;
Canvas.Rectangle(R.Left + 5, R.Top + 5, R.Right - 5, R.Bottom - 5);
end
else if FIcon <> tiNone then
DrawIcon(R, Fore)
else
begin
Canvas.Font.Assign(Font);
@ -287,7 +603,10 @@ begin
Canvas.TextOut((R.Right - TS.cx) div 2, (R.Bottom - TS.cy) div 2, Caption);
end;
if Focused then
{ Рамка фокуса рисуется только там, где фокус вообще предусмотрен.
Кнопки полосы меню его не берут, а рамка на них появлялась и
выглядела как испорченный пункт. }
if Focused and TabStop then
begin
Canvas.Pen.Color := ThemeTextDim;
Canvas.Pen.Style := psDot;
@ -390,18 +709,22 @@ end;
procedure TThemedButton.MouseUp(Button: TMouseButton; Shift: TShiftState;
X, Y: Integer);
var
WasPressed: Boolean;
begin
WasPressed := FPressed;
FPressed := False;
Invalidate;
inherited MouseUp(Button, Shift, X, Y);
{ Щелчком считается только отпускание внутри кнопки: увёл курсор в
сторону — значит передумал. }
if WasPressed and (Button = mbLeft) and
PtInRect(ClientRect, Point(X, Y)) then
Click;
{ Click отсюда не вызывается.
Его вызывает сама LCL при отпускании внутри кнопки — TControl.
WMLButtonUp, с той же проверкой «внутри», какая напрашивается здесь.
Написанный руками вызов оказывался вторым, и каждый щелчок срабатывал
дважды. На кнопке-переключателе это значит «ничего не произошло»:
первое срабатывание меняло состояние, второе возвращало обратно.
Найдено на невидимом экране: в журнал легли две записи «обработчик
кнопки журнала вызван» на один щелчок мышью, тогда как то же действие
с клавиатуры давало одну. }
end;
procedure TThemedButton.KeyDown(var Key: Word; Shift: TShiftState);
@ -497,6 +820,15 @@ begin
H := ACanvas.TextHeight('Wg');
Y := ARect.Top + (ARect.Bottom - ARect.Top - H) div 2;
{ В полосе меню отступ слева другой: место под галочку и под сочетание
клавиш там не нужно, а с отступом раскрытого списка названия разошлись
бы с шириной, которую мы же и посчитали. }
if Item.IsInMenuBar then
begin
ACanvas.TextOut(ARect.Left + MenuBarSide, Y, Item.Caption);
Exit;
end;
if Item.Checked then
ACanvas.TextOut(ARect.Left + 8, Y, '•');
@ -529,6 +861,13 @@ begin
end;
AHeight := ACanvas.TextHeight('Wg') + 10;
if Item.IsInMenuBar then
begin
AWidth := ACanvas.TextWidth(Item.Caption) + MenuBarSide * 2;
Exit;
end;
W := MenuTextLeft + ACanvas.TextWidth(Item.Caption) + MenuTextRight;
if Item.ShortCut <> 0 then
Inc(W, MenuGap + ACanvas.TextWidth(ShortCutToText(Item.ShortCut)));
@ -702,6 +1041,153 @@ begin
ApplyToChildren(TWinControl(AControl));
end;
{ Цвет Паскаля хранит составляющие в обратном порядке, а gtk ждёт
привычную запись «решётка, красный, зелёный, синий». }
function GtkColorText(AColor: TColor): string;
begin
Result := Format('#%.2x%.2x%.2x',
[AColor and $FF, (AColor shr 8) and $FF, (AColor shr 16) and $FF]);
end;
procedure ApplyMenuStyle;
{$IFDEF LCLGTK2}
var
Rc: string;
{$ENDIF}
begin
{$IFDEF LCLGTK2}
Rc :=
'style "nlve-menu" {' + LineEnding +
' bg[NORMAL] = "' + GtkColorText(ThemePanel) + '"' + LineEnding +
' bg[PRELIGHT] = "' + GtkColorText(ThemeAccent) + '"' + LineEnding +
' bg[ACTIVE] = "' + GtkColorText(ThemePanel) + '"' + LineEnding +
' bg[INSENSITIVE] = "' + GtkColorText(ThemePanel) + '"' + LineEnding +
' fg[NORMAL] = "' + GtkColorText(ThemeText) + '"' + LineEnding +
' fg[PRELIGHT] = "' + GtkColorText(ThemeAccentText) + '"' + LineEnding +
' fg[ACTIVE] = "' + GtkColorText(ThemeText) + '"' + LineEnding +
' fg[INSENSITIVE] = "' + GtkColorText(ThemeTextDim) + '"' + LineEnding +
' text[NORMAL] = "' + GtkColorText(ThemeText) + '"' + LineEnding +
' base[NORMAL] = "' + GtkColorText(ThemePanel) + '"' + LineEnding +
'}' + LineEnding +
'class "GtkMenuBar" style "nlve-menu"' + LineEnding +
'class "GtkMenu" style "nlve-menu"' + LineEnding +
'class "GtkMenuItem" style "nlve-menu"' + LineEnding +
'widget_class "*<GtkMenuBar>*" style "nlve-menu"' + LineEnding +
'widget_class "*<GtkMenuItem>*" style "nlve-menu"' + LineEnding;
gtk_rc_parse_string(PChar(Rc));
{$ENDIF}
end;
var
TailNote: string = 'закраска полосы меню не вызывалась';
{ Идёт ли закраска прямо сейчас. См. пояснение у PaintMenuBarTail. }
TailBusy: Boolean = False;
function MenuBarTailNote: string;
begin
Result := TailNote;
end;
procedure PaintMenuBarTail(AForm: TCustomForm);
{$IFDEF LCLWIN32}
{ Защита от повторного входа.
Закраска вызывается из обработчика перерисовки рамки окна, а сама
рисует в эту рамку — и под Wine рисование снова просило перерисовку.
Получалась воронка: при появлении любого модального окна стек кончался
и программа падала с «stack overflow». Найдено на невидимом экране в
отдельном префиксе Wine: падало и из напоминания о зависимостях, и из
меню «Файл → Зависимости». }
{$ENDIF}
{$IFDEF LCLWIN32}
var
Info: TMenuBarInfoRec;
WinRect, ItemRect, Fill: TRect;
DC: HDC;
Count: Integer;
Menu: HMENU;
{$ENDIF}
begin
{$IFDEF LCLWIN32}
if TailBusy then
Exit;
if (AForm = nil) or (not AForm.HandleAllocated) or (MenuBrush = 0) then
begin
TailNote := 'полоса: нет окна или кисти';
Exit;
end;
if (AForm.Menu = nil) or (not AForm.Menu.HandleAllocated) then
begin
TailNote := 'полоса: у меню нет дескриптора';
Exit;
end;
Menu := AForm.Menu.Handle;
FillChar(Info{%H-}, SizeOf(Info), 0);
Info.cbSize := SizeOf(Info);
if not SysGetMenuBarInfo(AForm.Handle, OBJID_MENU, 0, Info) then
begin
TailNote := 'полоса: GetMenuBarInfo отказала';
Exit;
end;
Count := SysGetMenuItemCount(Menu);
if Count <= 0 then
begin
TailNote := 'полоса: пунктов не найдено';
Exit;
end;
{ Правый край последнего названия. Всё, что правее, — ничьё, его и
закрашиваем. }
if not SysGetMenuItemRect(AForm.Handle, Menu, Count - 1, ItemRect{%H-}) then
begin
TailNote := 'полоса: GetMenuItemRect отказала';
Exit;
end;
if not SysGetWindowRect(AForm.Handle, WinRect{%H-}) then
begin
TailNote := 'полоса: GetWindowRect отказала';
Exit;
end;
{ Обе рамки система выдаёт в координатах экрана, а рисуем мы в
координатах окна. }
Fill.Left := ItemRect.Right - WinRect.Left;
Fill.Top := Info.rcBar.Top - WinRect.Top;
Fill.Right := Info.rcBar.Right - WinRect.Left;
Fill.Bottom := Info.rcBar.Bottom - WinRect.Top;
TailNote := Format(
'полоса: пунктов %d, полоса экрана %d,%d..%d,%d, край пункта %d, ' +
'окно %d,%d, красим %d,%d..%d,%d',
[Count, Info.rcBar.Left, Info.rcBar.Top, Info.rcBar.Right,
Info.rcBar.Bottom, ItemRect.Right, WinRect.Left, WinRect.Top,
Fill.Left, Fill.Top, Fill.Right, Fill.Bottom]);
if (Fill.Right <= Fill.Left) or (Fill.Bottom <= Fill.Top) then
begin
TailNote := TailNote + ' — красить нечего';
Exit;
end;
TailBusy := True;
try
DC := SysGetWindowDC(AForm.Handle);
if DC = 0 then
begin
TailNote := TailNote + ' — окно не дало холста';
Exit;
end;
try
SysFillRect(DC, Fill, MenuBrush);
finally
SysReleaseDC(AForm.Handle, DC);
end;
finally
TailBusy := False;
end;
{$ENDIF}
end;
procedure ApplyDarkMenu(AMenu: TMenu);
procedure Walk(AItem: TMenuItem);
@ -732,7 +1218,11 @@ begin
создаёт его принудительно, и у всплывающего меню, до показа которого
ещё далеко, это ломает само меню: оно перестаёт открываться.
Цена отказа — тонкая рамка вокруг раскрытого списка остаётся системной;
сами пункты рисуются нами и цвет берут. }
сами пункты рисуются нами и цвет берут.
Отсюда следует, что для полосы меню эту процедуру надо звать дважды:
до присваивания окну — чтобы расставить обработчики отрисовки, и после
— когда дескриптор уже есть и подложку можно задать. }
if AMenu.HandleAllocated then
begin
FillChar(Info{%H-}, SizeOf(Info), 0);
@ -740,6 +1230,13 @@ begin
Info.fMask := MIM_BACKGROUND or MIM_APPLYTOSUBMENUS;
Info.hbrBack := MenuBrush;
SetMenuInfo(AMenu.Handle, Info);
{ Полоса меню живёт в неклиентской части окна и сама себя не
перерисовывает: без этого новая подложка появилась бы только при
следующем изменении размера окна. }
if (AMenu.Parent is TCustomForm) and
TCustomForm(AMenu.Parent).HandleAllocated then
SysDrawMenuBar(TCustomForm(AMenu.Parent).Handle);
end;
{$ENDIF}
end;
@ -768,6 +1265,170 @@ end;
{ Окно сообщения }
{ Высота надписи с переносом.
Считать по общей ширине текста нельзя: в сообщении бывают свои переводы
строк, и оценка «ширина поделить на ширину поля» их не видит — текст
обрезается снизу. Здесь каждая строка считается отдельно, а пустые
строки дают пустой ряд. }
function WrappedTextHeight(ACanvas: TCanvas; const AText: string;
AWidth: Integer): Integer;
var
Lines: TStringList;
I, Rows, RowH: Integer;
begin
RowH := ACanvas.TextHeight('Wg');
Rows := 0;
Lines := TStringList.Create;
try
Lines.Text := AText;
if Lines.Count = 0 then
Rows := 1;
for I := 0 to Lines.Count - 1 do
if Lines[I] = '' then
Inc(Rows)
else
Inc(Rows, 1 + (ACanvas.TextWidth(Lines[I]) div AWidth));
finally
Lines.Free;
end;
Result := Rows * RowH + RowH div 2;
end;
function ThemedChoiceDlg(const ACaption, AText: string;
const ACaptions: array of string): Integer;
const
MarginX = 20;
MarginY = 18;
ButtonH = 30;
ButtonGap = 10;
var
Dlg: TForm;
Lbl: TLabel;
Btn: TThemedButton;
I, X, TotalW, Wid: Integer;
Widths: array of Integer;
begin
Result := -1;
if Length(ACaptions) = 0 then
Exit;
Dlg := TForm.CreateNew(nil);
try
Dlg.Caption := ACaption;
Dlg.BorderStyle := bsDialog;
Dlg.Position := poScreenCenter;
Dlg.Color := ThemeWindow;
Dlg.Font.Color := ThemeText;
Lbl := TLabel.Create(Dlg);
Lbl.Parent := Dlg;
Lbl.Left := MarginX;
Lbl.Top := MarginY;
Lbl.WordWrap := True;
Lbl.AutoSize := False;
Lbl.Width := 520;
Lbl.Caption := AText;
Lbl.Font.Color := ThemeText;
Lbl.Transparent := True;
Lbl.Height := WrappedTextHeight(Dlg.Canvas, AText, Lbl.Width);
{ Ширина у каждой кнопки своя: надписи разной длины, а одинаковая
ширина по самой длинной растянула бы «ОК» на пол-окна. }
SetLength(Widths, Length(ACaptions));
TotalW := 0;
for I := 0 to High(ACaptions) do
begin
Wid := Dlg.Canvas.TextWidth(ACaptions[I]) + 32;
if Wid < 90 then
Wid := 90;
Widths[I] := Wid;
Inc(TotalW, Wid);
end;
Inc(TotalW, ButtonGap * High(ACaptions));
Dlg.ClientWidth := Lbl.Width + MarginX * 2;
if TotalW + MarginX * 2 > Dlg.ClientWidth then
Dlg.ClientWidth := TotalW + MarginX * 2;
Dlg.ClientHeight := MarginY + Lbl.Height + MarginY + ButtonH + MarginY;
X := (Dlg.ClientWidth - TotalW) div 2;
for I := 0 to High(ACaptions) do
begin
Btn := TThemedButton.Create(Dlg);
Btn.Parent := Dlg;
Btn.SetBounds(X, Dlg.ClientHeight - MarginY - ButtonH, Widths[I],
ButtonH);
Btn.Caption := ACaptions[I];
{ Номер кнопки возвращается через ModalResult: mrNone равен нулю,
поэтому считаем с единицы и вычитаем её обратно. }
Btn.ModalResult := I + 1;
Btn.Default := I = 0;
Btn.Cancel := I = High(ACaptions);
Inc(X, Widths[I] + ButtonGap);
end;
Result := Dlg.ShowModal - 1;
if Result > High(ACaptions) then
Result := -1;
finally
Dlg.Free;
end;
end;
function ThemedInputDlg(const ACaption, APrompt: string;
var AValue: string): Boolean;
var
Dlg: TForm;
Lbl: TLabel;
Edit: TEdit;
BtnOk, BtnCancel: TThemedButton;
begin
Result := False;
Dlg := TForm.CreateNew(nil);
try
Dlg.Caption := ACaption;
Dlg.BorderStyle := bsDialog;
Dlg.Position := poScreenCenter;
Dlg.ClientWidth := 360;
Dlg.ClientHeight := 130;
Lbl := TLabel.Create(Dlg);
Lbl.Parent := Dlg;
Lbl.SetBounds(16, 16, 328, 20);
Lbl.Caption := APrompt;
Edit := TEdit.Create(Dlg);
Edit.Parent := Dlg;
Edit.SetBounds(16, 40, 328, 26);
Edit.Text := AValue;
BtnOk := TThemedButton.Create(Dlg);
BtnOk.Parent := Dlg;
BtnOk.SetBounds(136, 84, 100, 30);
BtnOk.Caption := 'Готово';
BtnOk.ModalResult := mrOk;
BtnOk.Default := True;
BtnCancel := TThemedButton.Create(Dlg);
BtnCancel.Parent := Dlg;
BtnCancel.SetBounds(244, 84, 100, 30);
BtnCancel.Caption := 'Отмена';
BtnCancel.ModalResult := mrCancel;
BtnCancel.Cancel := True;
ApplyDarkTheme(Dlg);
Edit.SetFocus;
if Dlg.ShowModal <> mrOk then
Exit;
AValue := Trim(Edit.Text);
Result := AValue <> '';
finally
Dlg.Free;
end;
end;
function ThemedMessageDlg(const ACaption, AText: string;
ADlgType: TMsgDlgType; AButtons: TMsgDlgButtons;
AHelpCtx: Longint): TModalResult;
@ -784,7 +1445,7 @@ var
Order: array[0..3] of TModalResult = (mrOk, mrYes, mrNo, mrCancel);
Names: array[0..3] of string = ('ОК', 'Да', 'Нет', 'Отмена');
Flags: array[0..3] of TMsgDlgBtn = (mbOK, mbYes, mbNo, mbCancel);
I, Count, X, TextW, TextH, TotalW: Integer;
I, Count, X, TotalW: Integer;
begin
Dlg := TForm.CreateNew(nil);
try
@ -814,9 +1475,7 @@ begin
{ Высоту надписи считаем по её содержимому: сообщения бывают и в одну
строку, и в десять. }
TextH := Dlg.Canvas.TextHeight('Wg');
TextW := Dlg.Canvas.TextWidth(AText);
Lbl.Height := TextH * (1 + TextW div Lbl.Width) + TextH div 2;
Lbl.Height := WrappedTextHeight(Dlg.Canvas, AText, Lbl.Width);
TotalW := Count * ButtonW + (Count - 1) * ButtonGap;
Dlg.ClientWidth := Lbl.Width + MarginX * 2;
@ -861,5 +1520,6 @@ initialization
finalization
Painter.Free;
IconCache.Free;
end.

View file

@ -93,6 +93,8 @@ type
FVBar: TThemedScrollBar;
FHBar: TThemedScrollBar;
FUpdating: Boolean;
{ Глубина вложенности пересчёта размещения. Ограничена: см. SyncBars. }
FDepth: Integer;
{ Сколько места занимает всё содержимое таблицы. Считается сложением
ширин столбцов и высот строк, а не свойствами GridWidth и
GridHeight: с выключенными полосами прокрутки те возвращают не
@ -475,10 +477,28 @@ begin
finally
FUpdating := False;
end;
{ Пересчёт размещения сам вызовет согласование ещё раз, но уже без
расхождения — второго круга не будет. }
if NeedRelayout then
Relayout;
{ Пересчёт размещения сам вызовет согласование ещё раз.
Обещать, что второго круга не будет, нельзя, и это стоило падения:
появление вертикальной полосы сужает таблицу, от этого перестаёт
помещаться содержимое по ширине, появляется горизонтальная полоса,
от неё убавляется высота — и решение о вертикальной принимается
заново. Под Wine эти качели не сходились никогда: стек в шестнадцать
мегабайт съедался целиком, и программа падала при открытии окна
зависимостей.
Поэтому глубина ограничена. Три круга хватает любому разумному
случаю, а на четвёртом мы просто оставляем как есть: лишняя полоса
прокрутки не беда, а падение — беда. }
if NeedRelayout and (FDepth < 3) then
begin
Inc(FDepth);
try
Relayout;
finally
Dec(FDepth);
end;
end;
end;
procedure TScrolledGrid.GridTopLeftChanged(Sender: TObject);

View file

@ -45,6 +45,7 @@ type
private
FRequests: TThumbRequests;
FCurrent: Integer;
FDone: Integer;
FOnReady: TThumbReadyEvent;
procedure SyncReady;
function CancelQuery: Boolean;
@ -53,6 +54,9 @@ type
public
constructor Create(const ARequests: TThumbRequests);
property OnReady: TThumbReadyEvent read FOnReady write FOnReady;
{ Столько же, сколько у разбора звука: очередь и сделанное. }
function Total: Integer;
property Done: Integer read FDone;
end;
{ Путь к миниатюре в кэше проекта. Имя выводится из пути к исходнику,
@ -91,11 +95,17 @@ var
begin
inherited Create(True);
FreeOnTerminate := False;
FDone := 0;
SetLength(FRequests, Length(ARequests));
for I := 0 to High(ARequests) do
FRequests[I] := ARequests[I];
end;
function TThumbThread.Total: Integer;
begin
Result := Length(FRequests);
end;
function TThumbThread.CancelQuery: Boolean;
begin
Result := Terminated;
@ -122,11 +132,13 @@ begin
Exit;
FCurrent := I;
FDone := I;
{ Уже готовая миниатюра — просто сообщаем о ней. }
if FileExists(FRequests[I].TargetPath) then
begin
Synchronize(@SyncReady);
FDone := I + 1;
Continue;
end;
@ -143,6 +155,7 @@ begin
if Res.Started and (Res.ExitCode = 0) and
FileExists(FRequests[I].TargetPath) then
Synchronize(@SyncReady);
FDone := I + 1;
end;
end;

File diff suppressed because it is too large Load diff

View file

@ -22,7 +22,7 @@ unit uTransform;
interface
uses
Classes, SysUtils;
Classes, SysUtils, uAppTypes;
type
{ Как соотносятся кадр исходника и кадр проекта. }
@ -69,6 +69,29 @@ function TransformFilter(const AT: TMediaTransform;
Отсюда честное ограничение: предпросмотр показывает кадр проекта верно,
но собственную настройку кадрирования файла не учитывает. Её видно в
окне кадрирования и в готовом ролике. }
{ То же самое, но для наложения в плеере: цепочка без обкладывания
полями и отдельно точка, куда встанет получившаяся картинка.
Поля и наложение — две записи одного и того же. Сборка обкладывает
картинку полями до кадра проекта, потому что кусок должен быть
самостоятельным файлом. Плееру поля не нужны и вредны: чёрное поле
закрыло бы нижнее видео. Ему нужны те же самые координаты, но как место
наложения. Считаются они одним и тем же кодом — иначе окно показывало
бы одно, а сборка давала другое. }
function OverlayFilter(const AT: TMediaTransform;
ASrcWidth, ASrcHeight, ADstWidth, ADstHeight: Integer;
out AX, AY, AWidth, AHeight: Integer): string;
{ Фильтр цветового ключа: этот цвет становится прозрачным.
Один на всех: и сборка, и предпросмотр должны выбивать одинаково, иначе
окно обещает одно, а готовый ролик даёт другое. Цвет записывается как
0xRRGGBB — порядок обратный тому, в каком он хранится у нас. }
function ColorKeyFilter(AColor: TRgb; ASimilarity, ABlend: Integer): string;
{ Фильтр приглушения по альфа-каналу. Пусто, если приглушать нечего. }
function OpacityFilter(const AOpacity: Double): string;
function PreviewFitFilter(ADstWidth, ADstHeight: Integer): string;
function FitModeToStr(AMode: TFitMode): string;
@ -78,7 +101,7 @@ function FitModeCaption(AMode: TFitMode): string;
implementation
uses
Math, uAppTypes;
Math;
function DefaultTransform: TMediaTransform;
begin
@ -130,6 +153,28 @@ begin
(ASrcWidth = ADstWidth) and (ASrcHeight = ADstHeight);
end;
{ Дробное число через точку: у ffmpeg разделитель всегда точка, а в
русской локали Format вставил бы запятую и развалил бы всю цепочку. }
function FilterNum(const AValue: Double): string;
begin
Result := SecondsToStr(AValue);
end;
function ColorKeyFilter(AColor: TRgb; ASimilarity, ABlend: Integer): string;
begin
Result := Format('colorkey=0x%.2x%.2x%.2x:%s:%s',
[AColor and $FF, (AColor shr 8) and $FF, (AColor shr 16) and $FF,
FilterNum(ASimilarity / 100), FilterNum(ABlend / 100)]);
end;
function OpacityFilter(const AOpacity: Double): string;
begin
if AOpacity >= 0.999 then
Result := ''
else
Result := 'colorchannelmixer=aa=' + FilterNum(Max(0, AOpacity));
end;
function PreviewFitFilter(ADstWidth, ADstHeight: Integer): string;
var
W, H: string;
@ -160,15 +205,17 @@ begin
Result := 2;
end;
function TransformFilter(const AT: TMediaTransform;
ASrcWidth, ASrcHeight, ADstWidth, ADstHeight: Integer): string;
{ Общая часть: цепочка до обкладывания полями и место, куда встанет
получившаяся картинка в кадре проекта. }
function BuildTransform(const AT: TMediaTransform;
ASrcWidth, ASrcHeight, ADstWidth, ADstHeight: Integer;
out AX, AY, AWidth, AHeight: Integer): string;
var
Chain: string;
W0, H0, Tmp: Integer;
K, KFit, KFill: Double;
SW, SH: Integer;
CW, CH, CX, CY: Integer;
PX, PY: Integer;
PW, PH, PX, PY: Integer;
Rot: Integer;
procedure Add(const AFilter: string);
@ -185,32 +232,14 @@ begin
Exit;
Chain := '';
W0 := ASrcWidth;
H0 := ASrcHeight;
{ --- Поворот и отражение --- }
Rot := ((AT.Rotation mod 360) + 360) mod 360;
case Rot of
90:
begin
{ transpose=1 — поворот на 90 по часовой стрелке. }
Add('transpose=1');
Tmp := W0; W0 := H0; H0 := Tmp;
end;
180:
{ Два поворота подряд дешевле и точнее, чем поворот на произвольный
угол: последний пересчитывает каждый пиксель. }
Add('transpose=1,transpose=1');
270:
begin
Add('transpose=2');
Tmp := W0; W0 := H0; H0 := Tmp;
end;
end;
if AT.FlipH then
Add('hflip');
if AT.FlipV then
Add('vflip');
{ Порядок действий: сначала выбирается кусок кадра, и только потом он
поворачивается и отражается.
Так это устроено в монтажных программах, и так понятнее: рамка
показывает, что́ останется, а поворот применяется к оставшемуся. При
обратном порядке обрезался бы уже перевёрнутый кадр, и в рамку
попадало бы совсем не то, что видно. }
{ --- Масштаб --- }
if AT.Fit = fmStretch then
@ -220,16 +249,16 @@ begin
end
else
begin
KFit := Min(ADstWidth / W0, ADstHeight / H0);
KFill := Max(ADstWidth / W0, ADstHeight / H0);
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;
SW := MakeEven(Round(W0 * K));
SH := MakeEven(Round(H0 * K));
SW := MakeEven(Round(ASrcWidth * K));
SH := MakeEven(Round(ASrcHeight * K));
end;
Add(Format('scale=%d:%d', [SW, SH]));
@ -247,20 +276,91 @@ begin
Add(Format('crop=%d:%d:%d:%d', [CW, CH, CX, CY]));
end;
{ --- Поля, если картинка меньше кадра --- }
if (CW < ADstWidth) or (CH < ADstHeight) then
{ --- Поворот и отражение оставшегося куска --- }
Rot := ((AT.Rotation mod 360) + 360) mod 360;
case Rot of
90:
begin
{ transpose=1 — поворот на 90 по часовой стрелке. }
Add('transpose=1');
PW := CH; PH := CW;
end;
180:
begin
{ Два поворота подряд дешевле и точнее, чем поворот на
произвольный угол: последний пересчитывает каждый пиксель. }
Add('transpose=1,transpose=1');
PW := CW; PH := CH;
end;
270:
begin
Add('transpose=2');
PW := CH; PH := CW;
end;
else
PW := CW; PH := CH;
end;
if AT.FlipH then
Add('hflip');
if AT.FlipV then
Add('vflip');
{ --- Приведение к кадру проекта --- }
{ После поворота на 90 кусок лежит поперёк и в кадр проекта уже не
вписывается. Вписываем целиком, с полями. }
if (PW > ADstWidth) or (PH > ADstHeight) then
begin
PX := Round((ADstWidth - CW) / 2 + AT.OffsetX * ADstWidth);
PY := Round((ADstHeight - CH) / 2 + AT.OffsetY * ADstHeight);
PX := Max(0, Min(ADstWidth - CW, PX));
PY := Max(0, Min(ADstHeight - CH, PY));
Add(Format('pad=%d:%d:%d:%d', [ADstWidth, ADstHeight, PX, PY]));
K := Min(ADstWidth / PW, ADstHeight / PH);
PW := MakeEven(Round(PW * K));
PH := MakeEven(Round(PH * K));
Add(Format('scale=%d:%d', [PW, PH]));
end;
{ Пиксель считаем квадратным: иначе плеер растянет кадр по своему
разумению, и результат разойдётся с тем, что видно в предпросмотре. }
Add('setsar=1');
{ Смещение двигает картинку и там, где её не обрезали. Когда картинка
больше кадра, двигать можно лишь окно, через которое на неё смотрят,
— это делает crop выше. Когда она меньше, двигать надо её саму по
полю, иначе уменьшенный кадр навсегда прибит к середине и в угол его
не отправить. Обе стороны считают смещение в долях кадра проекта, так
что число значит одно и то же. }
PX := (ADstWidth - PW) div 2 + Round(AT.OffsetX * ADstWidth);
PY := (ADstHeight - PH) div 2 + Round(AT.OffsetY * ADstHeight);
PX := Max(0, Min(Max(0, ADstWidth - PW), PX));
PY := Max(0, Min(Max(0, ADstHeight - PH), PY));
AX := PX;
AY := PY;
AWidth := PW;
AHeight := PH;
Result := Chain;
end;
function TransformFilter(const AT: TMediaTransform;
ASrcWidth, ASrcHeight, ADstWidth, ADstHeight: Integer): string;
var
X, Y, W, H: Integer;
begin
Result := BuildTransform(AT, ASrcWidth, ASrcHeight, ADstWidth, ADstHeight,
X, Y, W, H);
if Result = '' then
Exit;
if (W < ADstWidth) or (H < ADstHeight) then
Result := Result + Format(',pad=%d:%d:%d:%d', [ADstWidth, ADstHeight, X, Y]);
{ Пиксель считаем квадратным: иначе плеер растянет кадр по своему
разумению, и результат разойдётся с тем, что видно в предпросмотре. }
Result := Result + ',setsar=1';
end;
function OverlayFilter(const AT: TMediaTransform;
ASrcWidth, ASrcHeight, ADstWidth, ADstHeight: Integer;
out AX, AY, AWidth, AHeight: Integer): string;
begin
Result := BuildTransform(AT, ASrcWidth, ASrcHeight, ADstWidth, ADstHeight,
AX, AY, AWidth, AHeight);
if Result <> '' then
Result := Result + ',setsar=1';
end;
end.

396
src/uWaveform.pas Normal file
View file

@ -0,0 +1,396 @@
{
uWaveform — форма звука для отрисовки на клипах.
Что считается. Для каждого файла один раз строится список пиков: сто
значений в секунду, каждое — наибольший модуль отсчёта за свои десять
миллисекунд. Часовая запись даёт 360 тысяч байт, это ничто.
Почему не полная звуковая дорожка. Рисовать по настоящим отсчётам
нельзя: в секунде их 48 тысяч, а в клипе на экране — сотни пикселей.
Пришлось бы каждый раз перечитывать файл целиком.
Почему через временный файл, а не по трубе. Запуск внешних программ у
нас режет вывод на строки, а тут поток байтов, где перевод строки —
такой же отсчёт, как любой другой. Временный файл на четырнадцать
мегабайт для часовой записи проще и надёжнее, чем отдельный двоичный
путь чтения.
Считается в отдельном потоке: список файлов должен работать, пока звук
разбирается.
}
unit uWaveform;
{$mode objfpc}{$H+}
interface
uses
Classes, SysUtils, uProcRunner;
const
{ Пиков в секунду. Сто — это десять миллисекунд на пик: мельче глазу
всё равно не видно, а крупнее уже теряются короткие звуки. }
PeaksPerSecond = 100;
{ Частота, на которой разбирается звук. Восьми килогерц хватает, чтобы
отличить тишину от звука, а объём вчетверо меньше исходного. }
DecodeRate = 8000;
type
TPeaks = record
Valid: Boolean;
Rate: Integer;
{ Наибольший модуль отсчёта за интервал, приведённый к 0..255. }
Data: array of Byte;
end;
{ Путь к файлу пиков в кэше проекта. }
function PeaksPath(const ACacheDir, ASourcePath: string): string;
{ Разбирает звук файла и записывает пики. Долгая операция, вызывать из
рабочего потока. }
function BuildPeaks(const ASourcePath, ATargetPath: string;
ACancel: TProcCancelQuery; out AError: string): Boolean;
{ Читает готовые пики. }
function LoadPeaks(const APath: string; out APeaks: TPeaks): Boolean;
{ Пики файла с запоминанием в памяти. Если их ещё нет на диске, вернёт
False — считать надо отдельно, в потоке. }
function CachedPeaks(const ACacheDir, ASourcePath: string;
out APeaks: TPeaks): Boolean;
{ Забыть запомненное. Нужно при закрытии проекта: следующий проект живёт
в другом кэше. }
procedure ForgetPeaks;
type
TPeaksRequest = record
SourcePath: string;
TargetPath: string;
end;
TPeaksRequests = array of TPeaksRequest;
TPeaksReadyEvent = procedure(Sender: TObject;
const ASourcePath: string) of object;
TPeaksThread = class(TThread)
private
FRequests: TPeaksRequests;
FCurrent: Integer;
FDone: Integer;
FOnReady: TPeaksReadyEvent;
procedure SyncReady;
function CancelQuery: Boolean;
protected
procedure Execute; override;
public
constructor Create(const ARequests: TPeaksRequests);
property OnReady: TPeaksReadyEvent read FOnReady write FOnReady;
{ Сколько всего файлов в очереди и сколько уже разобрано. Читаются из
главного потока для показа хода работы; целое читается и пишется
одним действием, поэтому замок здесь не нужен. }
function Total: Integer;
property Done: Integer read FDone;
end;
implementation
uses
md5, uFfmpegCmd;
const
{ Заголовок файла пиков: подпись и номер вида. Номер понадобится, если
когда-нибудь поменяется частота или формат. }
PeaksMagic = 'NLVW';
PeaksVersion = 1;
{ Сколько отсчётов приходится на один пик. }
SamplesPerPeak = DecodeRate div PeaksPerSecond;
ReadChunk = 64 * 1024;
type
TPeaksCacheItem = class
Peaks: TPeaks;
end;
var
{ Запомненные пики: путь к исходнику — объект с данными. }
FCache: TStringList = nil;
function PeaksPath(const ACacheDir, ASourcePath: string): string;
begin
Result := IncludeTrailingPathDelimiter(ACacheDir) + 'peaks' + PathDelim +
MD5Print(MD5String(ASourcePath)) + '.pk';
end;
function BuildPeaks(const ASourcePath, ATargetPath: string;
ACancel: TProcCancelQuery; out AError: string): Boolean;
var
RawPath: string;
Res: TProcResult;
Src: TFileStream;
Dst: TFileStream;
Buffer: array[0..ReadChunk - 1] of SmallInt;
Got, I, Count: Integer;
Acc, Peak: Integer;
InPeak: Integer;
Peaks: array of Byte;
PeakCount: Integer;
Rate: Integer;
V: SmallInt;
procedure PushPeak(AValue: Integer);
begin
if PeakCount >= Length(Peaks) then
SetLength(Peaks, Length(Peaks) * 2 + 1024);
{ Отсчёт занимает 15 бит со знаком, а в байте помещается 8. Делим на
129, а не на 128: иначе полная амплитуда дала бы 256. }
Peaks[PeakCount] := AValue div 129;
Inc(PeakCount);
end;
begin
Result := False;
AError := '';
Rate := PeaksPerSecond;
if not ForceDirectories(ExtractFilePath(ATargetPath)) then
begin
AError := 'Не удалось создать каталог для пиков.';
Exit;
end;
RawPath := ATargetPath + '.raw';
{ Один звуковой поток, одна дорожка, восемь килогерц. Видео отбрасываем
целиком: оно здесь только мешает и тратит время. }
Res := RunProcess(FfmpegExe,
['-hide_banner', '-nostdin', '-loglevel', 'error', '-y',
'-i', ASourcePath, '-vn', '-map', '0:a:0',
'-ac', '1', '-ar', IntToStr(DecodeRate),
'-f', 's16le', RawPath],
nil, nil, ACancel);
if not Res.Started then
begin
AError := 'Не удалось запустить ffmpeg: ' + Res.ErrorText;
Exit;
end;
if Res.Cancelled then
begin
SysUtils.DeleteFile(RawPath);
Exit;
end;
if (Res.ExitCode <> 0) or (not FileExists(RawPath)) then
begin
{ Обычная причина — в файле нет звука. Это не беда, просто рисовать
нечего. }
AError := 'Звук не разобран (код ' + IntToStr(Res.ExitCode) + ').';
SysUtils.DeleteFile(RawPath);
Exit;
end;
Peaks := nil;
PeakCount := 0;
InPeak := 0;
Peak := 0;
try
Src := TFileStream.Create(RawPath, fmOpenRead or fmShareDenyNone);
try
repeat
Got := Src.Read(Buffer, SizeOf(Buffer)) div SizeOf(SmallInt);
if Got <= 0 then
Break;
for I := 0 to Got - 1 do
begin
V := Buffer[I];
if V < 0 then
Acc := -Integer(V)
else
Acc := V;
if Acc > Peak then
Peak := Acc;
Inc(InPeak);
if InPeak >= SamplesPerPeak then
begin
PushPeak(Peak);
Peak := 0;
InPeak := 0;
end;
end;
until False;
{ Хвост короче полного интервала тоже показываем. }
if InPeak > 0 then
PushPeak(Peak);
finally
Src.Free;
end;
Dst := TFileStream.Create(ATargetPath, fmCreate);
try
Dst.WriteBuffer(PeaksMagic[1], 4);
Count := PeaksVersion;
Dst.WriteBuffer(Count, SizeOf(Count));
Dst.WriteBuffer(Rate, SizeOf(Rate));
Dst.WriteBuffer(PeakCount, SizeOf(PeakCount));
if PeakCount > 0 then
Dst.WriteBuffer(Peaks[0], PeakCount);
finally
Dst.Free;
end;
Result := True;
except
on E: Exception do
AError := E.Message;
end;
SysUtils.DeleteFile(RawPath);
end;
function LoadPeaks(const APath: string; out APeaks: TPeaks): Boolean;
var
Src: TFileStream;
Sig: array[0..3] of AnsiChar;
Version, Rate, Count: Integer;
begin
Result := False;
APeaks := Default(TPeaks);
if not FileExists(APath) then
Exit;
try
Src := TFileStream.Create(APath, fmOpenRead or fmShareDenyNone);
try
if Src.Size < 16 then
Exit;
Src.ReadBuffer(Sig, 4);
if (Sig[0] <> 'N') or (Sig[1] <> 'L') or (Sig[2] <> 'V') or
(Sig[3] <> 'W') then
Exit;
Src.ReadBuffer(Version, SizeOf(Version));
if Version <> PeaksVersion then
Exit;
Src.ReadBuffer(Rate, SizeOf(Rate));
Src.ReadBuffer(Count, SizeOf(Count));
if (Count < 0) or (Count > Src.Size - Src.Position) then
Exit;
SetLength(APeaks.Data, Count);
if Count > 0 then
Src.ReadBuffer(APeaks.Data[0], Count);
APeaks.Rate := Rate;
APeaks.Valid := True;
Result := True;
finally
Src.Free;
end;
except
{ Испорченный кэш — не беда: осциллограммы просто не будет. }
APeaks := Default(TPeaks);
Result := False;
end;
end;
function CachedPeaks(const ACacheDir, ASourcePath: string;
out APeaks: TPeaks): Boolean;
var
Idx: Integer;
Item: TPeaksCacheItem;
Path: string;
begin
APeaks := Default(TPeaks);
if FCache = nil then
begin
FCache := TStringList.Create;
FCache.Sorted := True;
FCache.OwnsObjects := True;
end;
Idx := FCache.IndexOf(ASourcePath);
if Idx >= 0 then
begin
APeaks := TPeaksCacheItem(FCache.Objects[Idx]).Peaks;
Exit(APeaks.Valid);
end;
Path := PeaksPath(ACacheDir, ASourcePath);
Item := TPeaksCacheItem.Create;
{ Неудачу тоже запоминаем: иначе при каждой отрисовке мы бы стучались
на диск за файлом, которого нет. }
LoadPeaks(Path, Item.Peaks);
FCache.AddObject(ASourcePath, Item);
APeaks := Item.Peaks;
Result := APeaks.Valid;
end;
procedure ForgetPeaks;
begin
FreeAndNil(FCache);
end;
{ TPeaksThread }
constructor TPeaksThread.Create(const ARequests: TPeaksRequests);
var
I: Integer;
begin
inherited Create(True);
{ Освобождает поток окно, а не он сам: окно обращается к нему и после
завершения, чтобы понять, надо ли начинать заново. }
FreeOnTerminate := False;
FDone := 0;
SetLength(FRequests, Length(ARequests));
for I := 0 to High(ARequests) do
FRequests[I] := ARequests[I];
end;
function TPeaksThread.Total: Integer;
begin
Result := Length(FRequests);
end;
function TPeaksThread.CancelQuery: Boolean;
begin
Result := Terminated;
end;
procedure TPeaksThread.SyncReady;
begin
if Assigned(FOnReady) and (FCurrent >= 0) and
(FCurrent <= High(FRequests)) then
FOnReady(Self, FRequests[FCurrent].SourcePath);
end;
procedure TPeaksThread.Execute;
var
I: Integer;
Err: string;
begin
for I := 0 to High(FRequests) do
begin
if Terminated then
Break;
FCurrent := I;
if FileExists(FRequests[I].TargetPath) then
begin
Synchronize(@SyncReady);
FDone := I + 1;
Continue;
end;
if BuildPeaks(FRequests[I].SourcePath, FRequests[I].TargetPath,
@CancelQuery, Err) then
Synchronize(@SyncReady);
FDone := I + 1;
end;
end;
finalization
FreeAndNil(FCache);
end.

View file

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<CONFIG>
<ProjectOptions>
<Version Value="12"/>
<General>
<Flags>
<CompatibilityMode Value="True"/>
</Flags>
<SessionStorage Value="InProjectDir"/>
<Title Value="lfmcheck"/>
</General>
<BuildModes Count="1">
<Item1 Name="Default" Default="True"/>
</BuildModes>
<RequiredPackages Count="1">
<Item1>
<PackageName Value="LCL"/>
</Item1>
</RequiredPackages>
<Units Count="1">
<Unit0>
<Filename Value="lfmcheck.lpr"/>
<IsPartOfProject Value="True"/>
</Unit0>
</Units>
</ProjectOptions>
<CompilerOptions>
<Version Value="11"/>
<Target>
<Filename Value="lfmcheck"/>
</Target>
<SearchPaths>
<OtherUnitFiles Value="../../src"/>
<UnitOutputDirectory Value="units"/>
</SearchPaths>
</CompilerOptions>
</CONFIG>

287
tests/lfmcheck/lfmcheck.lpr Normal file
View file

@ -0,0 +1,287 @@
{
Проверка того, что свойства, записанные в файле формы, у наших
элементов действительно есть.
Зачем отдельно. Файл формы разбирается при запуске, а не при сборке:
опечатка в имени свойства или в значении перечисления даёт не ошибку
компиляции, а падение окна на старте. Здесь то же самое проверяется без
окна — по сведениям о типах.
}
program lfmcheck;
{$mode objfpc}{$H+}
uses
{ Interfaces нужен, чтобы связалась вся LCL и заработала загрузка
файлов форм. }
Interfaces, Forms, Classes, SysUtils, Math, TypInfo, uTheme,
uMediaProbe, uProject, uTransform, uTimelineCtrl,
uPanCropForm, uDepsForm, uMismatchForm, uProgressForm, uProjectInfoForm,
uRemuxForm;
var
Failed: Integer = 0;
procedure CheckProp(AClass: TClass; const AName: string);
begin
if GetPropInfo(AClass, AName) <> nil then
WriteLn(' ok ', AClass.ClassName, '.', AName)
else
begin
WriteLn(' ПЛОХО ', AClass.ClassName, '.', AName, ' — свойства нет');
Inc(Failed);
end;
end;
procedure CheckEnum(ATypeInfo: PTypeInfo; const AName: string);
begin
if GetEnumValue(ATypeInfo, AName) >= 0 then
WriteLn(' ok значение ', AName)
else
begin
WriteLn(' ПЛОХО значение ', AName, ' — такого нет');
Inc(Failed);
end;
end;
{ Создаёт окно и тут же убирает.
Смысл в самом создании: файл формы разбирается именно тогда. Элемент,
который есть в файле формы и которого нет в классе, или обработчик с
опечаткой в имени — всё это здесь и вскрывается, а иначе вскрылось бы у
человека при открытии окна.
Главное окно не проверяется: оно при создании читает настройки, строит
меню и заводит плеер — это уже не проверка формы, а запуск программы. }
procedure CheckForm(AClass: TFormClass);
var
F: TForm;
begin
try
F := AClass.Create(nil);
try
WriteLn(' ok ', AClass.ClassName, ' — форма загружена');
finally
F.Free;
end;
except
on E: Exception do
begin
WriteLn(' ПЛОХО ', AClass.ClassName, ': ', E.Message);
Inc(Failed);
end;
end;
end;
procedure CheckTrue(const AName: string; AValue: Boolean);
begin
if AValue then
WriteLn(' ok ', AName)
else
begin
WriteLn(' ПЛОХО ', AName);
Inc(Failed);
end;
end;
{ Прилипание курсора воспроизведения к границам клипов.
Проверяется числами, а не мышью: расчёт открыт наружу именно ради
этого. Мышью пришлось бы поднимать окно, а окно в проверках — это уже
не проверка расчёта, а проверка окружения. }
procedure CheckPlayheadSnap;
var
Proj: TProject;
Info: TMediaInfo;
Media: TMediaItem;
Timeline: TTimelineControl;
begin
WriteLn;
WriteLn('Прилипание курсора к границам клипов');
WriteLn;
Info := Default(TMediaInfo);
Info.Valid := True;
Info.Kind := mkVideo;
Info.Duration := 600;
Info.HasVideo := True;
Info.VideoCodec := 'hevc';
Info.Width := 1920;
Info.Height := 1080;
Info.FpsNum := 30;
Info.FpsDen := 1;
Info.PixFmt := 'yuv420p';
Proj := TProject.Create;
Timeline := TTimelineControl.Create(nil);
try
Proj.Clear;
Proj.AddTrack('V1');
Media := Proj.AddMedia('/несуществующий/видео.mp4', Info);
{ Два куска встык: границы на 0, 1.5 и 3. }
Proj.AddClip(Proj.Tracks[0], Media, 0, 1.5, 0);
Proj.AddClip(Proj.Tracks[0], Media, 10, 1.5, 1.5);
Timeline.Project := Proj;
{ Сто точек в секунду: порог прилипания в восемь точек — это 0.08 с. }
Timeline.PixelsPerSecond := 100;
Timeline.PlayheadSnap := True;
CheckTrue('стык рядом — притянуло',
Abs(Timeline.SnapPlayhead(1.53) - 1.5) < 0.0001);
CheckTrue('с другой стороны — тоже',
Abs(Timeline.SnapPlayhead(1.47) - 1.5) < 0.0001);
CheckTrue('далеко от границ — не тронуло',
Abs(Timeline.SnapPlayhead(0.7) - 0.7) < 0.0001);
CheckTrue('начало монтажа притягивает',
Abs(Timeline.SnapPlayhead(0.05)) < 0.0001);
CheckTrue('конец последнего клипа притягивает',
Abs(Timeline.SnapPlayhead(2.96) - 3) < 0.0001);
{ На мелком масштабе порог в точках даёт больший запас по времени. }
Timeline.PixelsPerSecond := 10;
CheckTrue('масштаб мельче — притягивает дальше',
Abs(Timeline.SnapPlayhead(1.7) - 1.5) < 0.0001);
Timeline.PixelsPerSecond := 100;
Timeline.PlayheadSnap := False;
CheckTrue('выключено — не притягивает',
Abs(Timeline.SnapPlayhead(1.53) - 1.53) < 0.0001);
finally
Timeline.Free;
Proj.Free;
end;
end;
{ Рез не должен терять настройки клипа.
Половина клипа после реза — тот же клип, и цветовой ключ, кадрирование,
прозрачность, громкость и текст должны остаться при обеих половинах.
Однажды не остались: у хвоста пропадал ключ, клип переставал быть
наложением и закрывал собой всё, что под ним. }
procedure CheckSplitKeepsSettings;
var
Proj: TProject;
Info: TMediaInfo;
Media: TMediaItem;
Timeline: TTimelineControl;
Head, Tail, Cl: TClip;
Key: TColorKey;
Tr: TMediaTransform;
I: Integer;
begin
WriteLn;
WriteLn('Рез сохраняет настройки клипа');
WriteLn;
Info := Default(TMediaInfo);
Info.Valid := True;
Info.Kind := mkVideo;
Info.Duration := 600;
Info.HasVideo := True;
Info.VideoCodec := 'hevc';
Info.Width := 1920;
Info.Height := 1080;
Info.FpsNum := 30;
Info.FpsDen := 1;
Info.PixFmt := 'yuv420p';
Proj := TProject.Create;
Timeline := TTimelineControl.Create(nil);
try
Proj.Clear;
Proj.AddTrack('V1');
Media := Proj.AddMedia('/несуществующий/видео.mp4', Info);
Cl := Proj.AddClip(Proj.Tracks[0], Media, 0, 10, 0);
Key := DefaultColorKey;
Key.Enabled := True;
Key.Similarity := 22;
Cl.ColorKey := Key;
Cl.Opacity := 0.5;
Cl.Volume := 0.3;
Tr := Cl.Transform;
Tr.Zoom := 0.4;
Tr.OffsetX := 0.7;
Cl.Transform := Tr;
Cl.GroupId := 'g1';
Timeline.Project := Proj;
Timeline.PixelsPerSecond := 100;
Timeline.Playhead := 4;
Timeline.SplitAtPlayhead(False, False);
CheckTrue('клипов стало два', Proj.Tracks[0].Count = 2);
if Proj.Tracks[0].Count <> 2 then
Exit;
Head := nil;
Tail := nil;
for I := 0 to Proj.Tracks[0].Count - 1 do
if Proj.Tracks[0][I].TlStart < 0.001 then
Head := Proj.Tracks[0][I]
else
Tail := Proj.Tracks[0][I];
CheckTrue('обе половины нашлись', (Head <> nil) and (Tail <> nil));
if (Head = nil) or (Tail = nil) then
Exit;
CheckTrue('ключ остался у хвоста', Tail.ColorKey.Enabled);
CheckTrue('и с тем же цветом', Tail.ColorKey.Color = Key.Color);
CheckTrue('и с тем же допуском', Tail.ColorKey.Similarity = 22);
CheckTrue('прозрачность осталась', Abs(Tail.Opacity - 0.5) < 0.001);
CheckTrue('громкость осталась', Abs(Tail.Volume - 0.3) < 0.001);
CheckTrue('кадрирование осталось',
(Abs(Tail.Transform.Zoom - 0.4) < 0.001) and
(Abs(Tail.Transform.OffsetX - 0.7) < 0.001));
CheckTrue('ключ остался и у головы', Head.ColorKey.Enabled);
{ Связки разные, и обе не пустые: иначе удаление одной половины
уносило бы вторую. }
CheckTrue('связка у половин разная и не пустая',
(Head.GroupId <> Tail.GroupId) and (Head.GroupId <> '') and
(Tail.GroupId <> ''));
CheckTrue('появление у хвоста снято', Abs(Tail.FadeIn) < 0.001);
finally
Timeline.Free;
Proj.Free;
end;
end;
begin
WriteLn('Свойства из файлов форм');
WriteLn;
CheckProp(TThemedButton, 'Icon');
CheckProp(TThemedButton, 'Down');
CheckProp(TThemedButton, 'Arrow');
CheckProp(TThemedButton, 'Caption');
CheckProp(TThemedButton, 'ShowHint');
CheckEnum(TypeInfo(TThemedIcon), 'tiSnap');
CheckEnum(TypeInfo(TThemedIcon), 'tiRipple');
CheckEnum(TypeInfo(TThemedIcon), 'tiLog');
CheckEnum(TypeInfo(TThemedIcon), 'tiNone');
WriteLn;
WriteLn('Загрузка файлов форм');
WriteLn;
Application.Initialize;
CheckForm(TPanCropForm);
CheckForm(TDepsForm);
CheckForm(TMismatchForm);
CheckForm(TProgressForm);
CheckForm(TProjectInfoForm);
CheckForm(TRemuxForm);
CheckPlayheadSnap;
CheckSplitKeepsSettings;
WriteLn;
if Failed = 0 then
WriteLn('ВЕРДИКТ: все проверки пройдены')
else
begin
WriteLn('ВЕРДИКТ: не пройдено проверок: ', Failed);
Halt(1);
end;
end.

View file

@ -23,6 +23,16 @@
render <проект.nlve> <выход>
уплотить проект, создать вставки, собрать ролик
и сверить число кадров
subs <проект.nlve>
собрать разметку субтитров из текстовых клипов
и напечатать её
preview <проект.nlve>
собрать список кусков так же, как это делает
предпросмотр: с готовыми наложениями вместо
участков с прозрачностью и переходами
audio <проект.nlve>
разложить звуковые дорожки проекта на куски и
напечатать их вместе со строками edl://
buildcut <проект.nlve> <видео>
проект из одного файла с точным резом в заведомо
неопорной точке
@ -37,7 +47,7 @@ uses
{$ENDIF}
Classes, SysUtils, DateUtils, Math, StrUtils,
uAppTypes, uEdl, uKeyframeIndex, uFfmpegCmd, uExport, uRemuxQueue,
uMediaProbe, uProject, uFlatten, uGenerate;
uMediaProbe, uProject, uFlatten, uGenerate, uSubtitles, uTransform;
procedure PrintUsage;
begin
@ -306,8 +316,9 @@ begin
WriteLn('остановка через: ', SecondsToStr(CancelAfter), ' с');
WriteLn;
Thread := TExportThread.Create(Segments, ParamStr(FirstArg - 1), True,
Default(TMediaInfo));
{ Проверка вырезания и склейки картинки: звук здесь не собирается. }
Thread := TExportThread.Create(Segments, nil, ParamStr(FirstArg - 1), True,
Default(TMediaInfo), Default(TEncodeProfile));
Watcher := TExportWatcher.Create(Thread);
try
Thread.OnProgress := @Watcher.Progress;
@ -550,8 +561,12 @@ procedure CmdFlatTest;
var
Proj: TProject;
Media: TMediaItem;
Segs: TFlatSegmentArray;
Segs, AudioSegs: TFlatSegmentArray;
Overlays: TPreviewOverlays;
Missing: TGenRequests;
Clip: TClip;
Key: TColorKey;
Tr: TMediaTransform;
TempFile, Err: string;
begin
WriteLn('Проверка уплощения монтажа');
@ -563,7 +578,7 @@ begin
try
Proj.AddClip(Proj.Tracks[0], Media, 10, 5, 0);
Proj.AddClip(Proj.Tracks[0], Media, 15, 5, 5);
FlattenProject(Proj, Segs, Missing);
FlattenStream(Proj, tkVideo, 0, Segs, Missing);
DumpSegments(Segs);
Check(Length(Segs) = 1, 'склеились в один кусок');
if Length(Segs) = 1 then
@ -582,7 +597,7 @@ begin
try
Proj.AddClip(Proj.Tracks[0], Media, 100, 5, 0);
Proj.AddClip(Proj.Tracks[0], Media, 200, 5, 5);
FlattenProject(Proj, Segs, Missing);
FlattenStream(Proj, tkVideo, 0, Segs, Missing);
DumpSegments(Segs);
Check(Length(Segs) = 2, 'остались двумя кусками');
finally
@ -597,7 +612,7 @@ begin
{ Дорожка 0 — верхняя, она побеждает. }
Proj.AddClip(Proj.Tracks[1], Media, 0, 30, 0);
Proj.AddClip(Proj.Tracks[0], Media, 100, 10, 10);
FlattenProject(Proj, Segs, Missing);
FlattenStream(Proj, tkVideo, 0, Segs, Missing);
DumpSegments(Segs);
Check(Length(Segs) = 3, 'получилось три куска');
if Length(Segs) = 3 then
@ -621,7 +636,7 @@ begin
try
Proj.AddClip(Proj.Tracks[0], Media, 0, 5, 3);
Proj.AddClip(Proj.Tracks[0], Media, 50, 5, 12);
FlattenProject(Proj, Segs, Missing);
FlattenStream(Proj, tkVideo, 0, Segs, Missing);
DumpSegments(Segs);
Check(Length(Segs) = 4, 'четыре куска: чёрное, видео, чёрное, видео');
if Length(Segs) = 4 then
@ -651,7 +666,7 @@ begin
try
Proj.AddClip(Proj.Tracks[1], Media, 0, 10, 0);
Proj.AddClip(Proj.Tracks[0], Media, 200, 10, 0);
FlattenProject(Proj, Segs, Missing);
FlattenStream(Proj, tkVideo, 0, Segs, Missing);
DumpSegments(Segs);
Check(Length(Segs) = 1, 'остался один кусок');
if Length(Segs) = 1 then
@ -668,7 +683,7 @@ begin
Proj.AddClip(Proj.Tracks[1], Media, 0, 10, 0);
Proj.AddClip(Proj.Tracks[0], Media, 200, 10, 0);
Proj.Tracks[0].Muted := True;
FlattenProject(Proj, Segs, Missing);
FlattenStream(Proj, tkVideo, 0, Segs, Missing);
DumpSegments(Segs);
Check(Length(Segs) = 1, 'остался один кусок');
if Length(Segs) = 1 then
@ -688,7 +703,7 @@ begin
Proj.Tracks[0][0].EdgeIn := edPrecise;
Proj.RippleMode := rmAllTracks;
Proj.RippleEnabled := True;
FlattenProject(Proj, Segs, Missing);
FlattenStream(Proj, tkVideo, 0, Segs, Missing);
Check(Length(Segs) = 3, 'до записи три куска');
Check(Proj.SaveToFile(TempFile, Err), 'записан: ' + Err);
finally
@ -698,13 +713,17 @@ begin
Proj := TProject.Create;
try
Check(Proj.LoadFromFile(TempFile, Err), 'прочитан: ' + Err);
Check(Proj.TrackCount = 2, 'дорожек снова две');
{ Четыре: две картинки и две звука. Звуковые заводятся сразу, чтобы
каждому клипу картинки было куда положить парный звук. }
Check(Proj.TrackCount = 4, 'дорожек снова четыре');
Check(Proj.TrackCountOf(tkVideo) = 2, 'из них две для картинки');
Check(Proj.TrackCountOf(tkAudio) = 2, 'и две для звука');
Check(Proj.PoolCount = 1, 'файл в списке один');
Check(Proj.RippleMode = rmAllTracks, 'режим схлопывания сохранился');
Check(Proj.RippleEnabled, 'состояние схлопывания сохранилось');
if Proj.TrackCount = 2 then
Check(Proj.Tracks[0][0].EdgeIn = edPrecise, 'признак точного реза сохранился');
FlattenProject(Proj, Segs, Missing);
FlattenStream(Proj, tkVideo, 0, Segs, Missing);
DumpSegments(Segs);
Check(Length(Segs) = 3, 'после чтения снова три куска');
if Length(Segs) = 3 then
@ -715,6 +734,185 @@ begin
DeleteFile(TempFile);
WriteLn;
{ --- 8. Оверлей в предпросмотре не закрывает нижний клип --- }
WriteLn(' 8. Клип с ключом поверх: основа и оверлей врозь');
Proj := MakeProject(2, Media);
try
Proj.Master := Media.Info;
{ Нижняя дорожка — обычный кусок, верхняя — тот же файл с выбитым
цветом во всю длину. Так выглядит компас или карта поверх ролика. }
Proj.AddClip(Proj.Tracks[1], Media, 0, 10, 0);
Clip := Proj.AddClip(Proj.Tracks[0], Media, 100, 10, 0);
Key := DefaultColorKey;
Key.Enabled := True;
Clip.ColorKey := Key;
Tr := Clip.Transform;
Tr.Zoom := 0.5;
Clip.Transform := Tr;
{ Обычное уплощение: побеждает верхний, нижнего не видно. }
FlattenStream(Proj, tkVideo, 0, Segs, Missing);
Check(Length(Segs) = 1, 'при сборке кусок один');
if Length(Segs) = 1 then
Check(Abs(Segs[0].SrcStart - 100) < 0.001, 'и это верхний клип');
{ Уплощение для предпросмотра: верхний ушёл в оверлей. }
Check(FlattenPreview(Proj, Segs, AudioSegs, Overlays, Missing),
'уплощение для предпросмотра прошло');
DumpSegments(Segs);
Check(Length(Segs) = 1, 'основа из одного куска');
if Length(Segs) = 1 then
Check(Abs(Segs[0].SrcStart - 0) < 0.001, 'в основе виден нижний клип');
Check(Length(Overlays) = 1, 'оверлей один');
if Length(Overlays) = 1 then
begin
Check(Abs(Overlays[0].TlStart) < 0.001, 'оверлей с начала');
Check(Abs(Overlays[0].TlEnd - 10) < 0.001, 'и до конца');
Check(Abs(Overlays[0].SrcStart - 100) < 0.001, 'берётся с 100 с');
Check((Overlays[0].Width = 960) and (Overlays[0].Height = 540),
'уменьшен вдвое: ' + IntToStr(Overlays[0].Width) + 'x' +
IntToStr(Overlays[0].Height));
Check((Overlays[0].X = 480) and (Overlays[0].Y = 270),
'и стоит в середине кадра: ' + IntToStr(Overlays[0].X) + ':' +
IntToStr(Overlays[0].Y));
end;
finally
Proj.Free;
end;
WriteLn;
{ --- 9. Обрезка списка кусков по отмеченному участку --- }
WriteLn(' 9. Сборка только отмеченного участка');
Proj := MakeProject(1, Media);
try
{ Три куска по 5 секунд из разных мест файла: склеиться в один они не
могут, и границы участка приходятся на середину крайних. }
Proj.AddClip(Proj.Tracks[0], Media, 100, 5, 0);
Proj.AddClip(Proj.Tracks[0], Media, 200, 5, 5);
Proj.AddClip(Proj.Tracks[0], Media, 300, 5, 10);
FlattenStream(Proj, tkVideo, 0, Segs, Missing);
Check(Length(Segs) = 3, 'до обрезки три куска');
Check(Abs(ClipSegmentsToRange(Segs, 2, 12) - 10) < 0.001,
'осталось десять секунд');
DumpSegments(Segs);
Check(Length(Segs) = 3, 'куски остались все три');
if Length(Segs) = 3 then
begin
Check(Abs(Segs[0].SrcStart - 102) < 0.001,
'первый начинается на две секунды позже');
Check(Abs(Segs[0].Duration - 3) < 0.001, 'и длится три секунды');
Check(Abs(Segs[1].SrcStart - 200) < 0.001, 'средний не тронут');
Check(Abs(Segs[1].Duration - 5) < 0.001, 'и целиком');
Check(Abs(Segs[2].SrcStart - 300) < 0.001, 'последний с начала');
Check(Abs(Segs[2].Duration - 2) < 0.001, 'и укорочен до двух секунд');
end;
{ Участок целиком внутри одного куска. }
FlattenStream(Proj, tkVideo, 0, Segs, Missing);
Check(Abs(ClipSegmentsToRange(Segs, 6, 8) - 2) < 0.001,
'внутри одного куска остались две секунды');
Check(Length(Segs) = 1, 'и один кусок');
if Length(Segs) = 1 then
Check(Abs(Segs[0].SrcStart - 201) < 0.001, 'взят с нужного места');
{ Участок мимо монтажа. }
FlattenStream(Proj, tkVideo, 0, Segs, Missing);
Check(Abs(ClipSegmentsToRange(Segs, 100, 200)) < 0.001,
'за концом монтажа не осталось ничего');
Check(Length(Segs) = 0, 'и ни одного куска');
finally
Proj.Free;
end;
WriteLn;
{ --- 10. Клипы на дорожках чужого вида --- }
WriteLn(' 10. Клип на дорожке не своего вида');
Proj := MakeProject(1, Media);
try
Proj.EnsureAudioTracks;
Proj.AddTrackOfKind('Т1', tkText);
{ Картинка на текстовой дорожке — ровно то, что нашлось в живом
проекте: клип виден на монтаже, а в ролик не попадает. }
Clip := Proj.AddClip(Proj.Tracks[Proj.TrackCount - 1], Media, 0, 3, 10);
Clip.Kind := ckImage;
Check(Proj.MisplacedClipCount = 1, 'нашёлся один такой клип');
FlattenStream(Proj, tkVideo, 0, Segs, Missing);
Check(Length(Segs) = 0, 'в уплощении его нет');
Check(Proj.FixMisplacedClips = 1, 'перенесён один клип');
Check(Proj.MisplacedClipCount = 0, 'чужих клипов не осталось');
FlattenStream(Proj, tkVideo, 0, Segs, Missing);
DumpSegments(Segs);
Check(Length(Segs) > 0, 'после переноса он попадает в уплощение');
Clip := Proj.Tracks[0].ClipAt(11);
Check((Clip <> nil) and (Abs(Clip.TlStart - 10) < 0.001),
'и стоит на прежнем месте');
finally
Proj.Free;
end;
WriteLn;
{ --- 11. Оверлей под сплошным клипом в окне не показывается --- }
WriteLn(' 11. Оверлей закрыт сверху сплошным клипом');
Proj := MakeProject(3, Media);
try
Proj.Master := Media.Info;
{ Снизу вверх: V3 — съёмка, V2 — оверлей с ключом на весь ролик,
V1 — фотография поверх куска. Так и было в живом проекте. }
Proj.AddClip(Proj.Tracks[2], Media, 0, 20, 0);
Clip := Proj.AddClip(Proj.Tracks[1], Media, 0, 20, 0);
Key := DefaultColorKey;
Key.Enabled := True;
Clip.ColorKey := Key;
Clip := Proj.AddClip(Proj.Tracks[0], Media, 0, 5, 5);
Clip.Kind := ckImage;
Check(FlattenPreview(Proj, Segs, AudioSegs, Overlays, Missing),
'уплощение для предпросмотра прошло');
{ Фотография лежит с 5 по 10 секунду и закрывает оверлей: показывать
его там нельзя — в ролике его там не будет. Значит, от оверлея
остаются два окна: до фотографии и после. }
Check(Length(Overlays) = 2, 'оверлей показывается двумя окнами: ' +
IntToStr(Length(Overlays)));
if Length(Overlays) = 2 then
begin
Check(Abs(Overlays[0].TlStart) < 0.001, 'первое окно с начала');
Check(Abs(Overlays[0].TlEnd - 5) < 0.001, 'и до фотографии');
Check(Abs(Overlays[1].TlStart - 10) < 0.001, 'второе после неё');
Check(Abs(Overlays[1].TlEnd - 20) < 0.001, 'и до конца');
Check(Abs(Overlays[1].SrcStart - 10) < 0.001,
'и берётся с того же места исходника');
end;
finally
Proj.Free;
end;
WriteLn;
{ --- 12. Имена дорожек не повторяются --- }
WriteLn(' 12. Имена дорожек');
Proj := MakeProject(3, Media);
try
Check(Proj.UniqueTrackName('V') = 'V4', 'следующее имя V4: ' +
Proj.UniqueTrackName('V'));
{ Убираем среднюю: счёт дорожек снова три, но имя V3 уже занято, и
считать их для имени нельзя. }
Proj.RemoveTrack(1);
Check(Proj.UniqueTrackName('V') = 'V2', 'после удаления средней — V2: ' +
Proj.UniqueTrackName('V'));
Proj.AddTrack(Proj.UniqueTrackName('V'));
Check(Proj.Tracks[2].Name = 'V2', 'новая дорожка названа V2');
{ Одинаковые имена, доставшиеся от старого проекта, разводятся. }
Proj.Tracks[2].Name := 'V1';
Proj.EnsureUniqueTrackNames;
Check(Proj.Tracks[2].Name <> 'V1', 'повтор разведён: ' + Proj.Tracks[2].Name);
finally
Proj.Free;
end;
WriteLn;
WriteLn;
if FailCount = 0 then
begin
@ -739,6 +937,7 @@ begin
Proj.Clear;
Proj.AddTrack('V1');
Proj.AddTrack('V2');
Proj.EnsureAudioTracks;
if not ProbeMedia(ExpandFileName(ParamStr(3)), Info, Err) then
begin
@ -750,8 +949,10 @@ begin
{ Нижняя дорожка: два куска видео из разных мест, между ними дыра
в две секунды, и ещё дыра в начале. }
Proj.AddClip(Proj.Tracks[1], Video, 100, 6, 1.5);
Proj.AddClip(Proj.Tracks[1], Video, 400, 6, 9.5);
{ Парой: клип картинки и его звук на соответствующей звуковой
дорожке. Так собранный проект похож на настоящий. }
Proj.AddClipPair(Proj.Tracks[1], Video, 100, 6, 1.5);
Proj.AddClipPair(Proj.Tracks[1], Video, 400, 6, 9.5);
if ParamCount >= 4 then
begin
@ -779,6 +980,58 @@ begin
end;
end;
{ Проект для проверки цветового ключа: нижний клип во всю длину и
верхний с выбитым пурпурным поверх него. Собранное потом смотрим по
точкам кадра: где был пурпурный, должно быть видно нижний клип. }
procedure CmdKeyTest;
var
Proj: TProject;
Info: TMediaInfo;
Bottom, Top: TMediaItem;
Clip: TClip;
Key: TColorKey;
Err: string;
begin
Proj := TProject.Create;
try
Proj.Clear;
Proj.AddTrack('V1');
Proj.AddTrack('V2');
Proj.EnsureAudioTracks;
if not ProbeMedia(ExpandFileName(ParamStr(3)), Info, Err) then
begin
WriteLn('Не удалось прочитать нижнее видео: ', Err);
Halt(1);
end;
Bottom := Proj.AddMedia(ExpandFileName(ParamStr(3)), Info);
WriteLn('низ: ', DescribeMedia(Info));
Proj.AddClipPair(Proj.Tracks[1], Bottom, 0, 3, 0);
if not ProbeMedia(ExpandFileName(ParamStr(4)), Info, Err) then
begin
WriteLn('Не удалось прочитать верхнее видео: ', Err);
Halt(1);
end;
Top := Proj.AddMedia(ExpandFileName(ParamStr(4)), Info);
WriteLn('верх: ', DescribeMedia(Info));
Clip := Proj.AddClip(Proj.Tracks[0], Top, 0, 3, 0);
Key := DefaultColorKey;
Key.Enabled := True;
Clip.ColorKey := Key;
if not Proj.SaveToFile(ExpandFileName(ParamStr(2)), Err) then
begin
WriteLn('Не удалось записать проект: ', Err);
Halt(1);
end;
WriteLn('проект записан: ', Proj.FileName);
finally
Proj.Free;
end;
end;
procedure CmdBuildCut;
var
Proj: TProject;
@ -850,12 +1103,208 @@ begin
WriteLn(' ', FThread.PendingLog);
end;
procedure CmdRender;
{ Раскладка звуковых дорожек.
Нужна потому, что «звука нет вот здесь» глазами не разобрать: громкость
может быть верной, а кусок в этом месте — ссылаться не туда. Здесь
печатается ровно то, что уходит в плеер: место на монтаже, файл, место
в файле, длина. }
procedure CmdAudio;
var
Proj: TProject;
Segs: TFlatSegmentArray;
Missing: TGenRequests;
Err: string;
I, J: Integer;
Total, At: Double;
Kind: string;
begin
Proj := TProject.Create;
try
if not Proj.LoadFromFile(ExpandFileName(ParamStr(2)), Err) then
begin
WriteLn('Не удалось прочитать проект: ', Err);
Halt(1);
end;
Total := Max(AudibleEnd(Proj, tkVideo), AudibleEnd(Proj, tkAudio));
WriteLn('проект: ', Proj.FileName);
WriteLn('длина слышимого: ', SecondsToStr(Total), ' с');
WriteLn;
for I := 0 to Proj.TrackCount - 1 do
begin
if Proj.Tracks[I].Kind <> tkAudio then
Continue;
WriteLn('=== ', Proj.Tracks[I].Name, ' ===');
if not Proj.TrackAudible(Proj.Tracks[I]) then
begin
WriteLn(' не слышна (заглушена или пуста)');
WriteLn;
Continue;
end;
if Proj.Tracks[I].Count = 0 then
begin
WriteLn(' клипов нет');
WriteLn;
Continue;
end;
if not FlattenAudioTrack(Proj, Proj.Tracks[I], Total, Segs, Missing) then
begin
WriteLn(' разложить не удалось');
WriteLn;
Continue;
end;
At := 0;
for J := 0 to High(Segs) do
begin
if Segs[J].Kind = skBlack then
Kind := 'тишина'
else
Kind := 'звук ';
WriteLn(Format(' [%2d] %s монтаж %8s..%8s длина %8s из %8s %s',
[J, Kind, SecondsToStr(At), SecondsToStr(At + Segs[J].Duration),
SecondsToStr(Segs[J].Duration), SecondsToStr(Segs[J].SrcStart),
ExtractFileName(Segs[J].FilePath)]));
if (Segs[J].Kind = skBlack) and (not FileExists(Segs[J].FilePath)) then
WriteLn(' ВНИМАНИЕ: вставки тишины нет на диске');
At := At + Segs[J].Duration;
end;
WriteLn(' всего по дорожке: ', SecondsToStr(At), ' с');
WriteLn;
WriteLn(' ', BuildEdlUri(Segs));
WriteLn;
end;
finally
Proj.Free;
end;
end;
{ Список кусков предпросмотра.
Отличается от сборки одним: участки с прозрачностью и переходами плеер
показать не может, поэтому они заранее сводятся в отдельные файлы. Здесь
видно, какие именно участки и во что превратились. }
{ Разметка субтитров из текстовых клипов.
Точного замера шрифта здесь нет — он требует холста, а холст оконного
набора. Поэтому ширины оцениваются приблизительно; для проверки самой
разметки этого хватает, а окно ставит точный замер. }
procedure CmdSubs;
var
Proj: TProject;
Err, Text: string;
begin
Proj := TProject.Create;
try
if not Proj.LoadFromFile(ExpandFileName(ParamStr(2)), Err) then
begin
WriteLn('Не удалось прочитать проект: ', Err);
Halt(1);
end;
Text := BuildAss(Proj);
if Text = '' then
begin
WriteLn('Текстовых клипов в проекте нет.');
Halt(1);
end;
Write(Text);
finally
Proj.Free;
end;
end;
procedure CmdPreview;
var
Proj: TProject;
Segs, AudioSegs: TFlatSegmentArray;
Missing: TGenRequests;
Err: string;
I: Integer;
At: Double;
Gen: TGenerateThread;
GenWatch: TGenWatcher;
Kind: string;
begin
Proj := TProject.Create;
try
if not Proj.LoadFromFile(ExpandFileName(ParamStr(2)), Err) then
begin
WriteLn('Не удалось прочитать проект: ', Err);
Halt(1);
end;
if not FlattenProject(Proj, Segs, AudioSegs, Missing) then
begin
WriteLn('Уплотить нечего.');
Halt(1);
end;
ResolveLayers(Proj, Segs, Missing);
if Length(Missing) > 0 then
begin
WriteLn('Не хватает вставок: ', Length(Missing), '. Делаю.');
Gen := TGenerateThread.Create(Missing, Proj.Master);
GenWatch := TGenWatcher.Create(Gen);
try
Gen.OnProgress := @GenWatch.Progress;
Gen.OnLog := @GenWatch.LogLine;
Gen.Start;
while not Gen.Finished do
CheckSynchronize(100);
CheckSynchronize(0);
if not Gen.Succeeded then
begin
WriteLn('Не вышло: ', Gen.Message);
Halt(1);
end;
finally
GenWatch.Free;
Gen.Free;
end;
{ Второй заход: теперь файлы на месте и должны подставиться. }
Missing := nil;
ResolveLayers(Proj, Segs, Missing);
if Length(Missing) > 0 then
begin
WriteLn('Часть наложений так и не появилась.');
Halt(1);
end;
end;
At := 0;
for I := 0 to High(Segs) do
begin
if Length(Segs[I].Layers) > 0 then
Kind := 'НАЛОЖЕНИЕ (плеер не покажет)'
else if Segs[I].Kind = skBlack then
Kind := 'чёрное'
else
Kind := 'кусок ';
WriteLn(Format(' [%2d] %s монтаж %8s..%8s из %8s %s',
[I, Kind, SecondsToStr(At), SecondsToStr(At + Segs[I].Duration),
SecondsToStr(Segs[I].SrcStart), ExtractFileName(Segs[I].FilePath)]));
At := At + Segs[I].Duration;
end;
WriteLn;
WriteLn(BuildEdlUri(Segs));
finally
Proj.Free;
end;
end;
procedure CmdRender;
var
Proj: TProject;
Segs, AudioSegs: TFlatSegmentArray;
Missing: TGenRequests;
Err: string;
Gen: TGenerateThread;
GenWatch: TGenWatcher;
Thread: TExportThread;
@ -877,7 +1326,7 @@ begin
WriteLn('длина: ', SecondsToStr(Proj.EndTime), ' с');
WriteLn;
if not FlattenProject(Proj, Segs, Missing) then
if not FlattenProject(Proj, Segs, AudioSegs, Missing) then
begin
WriteLn('Уплотить нечего.');
Halt(1);
@ -914,7 +1363,7 @@ begin
WriteLn;
{ После создания уплощаем заново: теперь все файлы на месте. }
FlattenProject(Proj, Segs, Missing);
FlattenProject(Proj, Segs, AudioSegs, Missing);
if Length(Missing) > 0 then
begin
WriteLn('Часть вставок так и не появилась.');
@ -922,8 +1371,23 @@ begin
end;
end;
Thread := TExportThread.Create(Segs, ExpandFileName(ParamStr(3)), True,
Proj.Master);
{ Вжигание текста — последним, уже по окончательному списку кусков:
повторное уплощение стёрло бы отметки о разметке. Так же устроено и
окно сборки. }
if (ParamCount >= 4) and (LowerCase(ParamStr(4)) = 'burn') then
WriteLn('вжигаю текст, пересобрать придётся ',
SecondsToStr(BurnTextIntoSegments(Proj, Segs, Proj.CacheDir)), ' с');
{ Куски после удачной сборки убираются — как и в самой программе.
Словом keep их можно оставить: пригодится, когда разбираешься, из
чего собрался ролик. }
Thread := TExportThread.Create(Segs, AudioSegs,
ExpandFileName(ParamStr(3)),
(ParamCount >= 4) and (LowerCase(ParamStr(4)) = 'keep'),
Proj.Master, Default(TEncodeProfile));
{ Словом resume просят продолжить прерванную сборку: готовые куски
берутся с диска, а не считаются заново. }
Thread.Resume := (ParamCount >= 4) and (LowerCase(ParamStr(4)) = 'resume');
Watcher := TExportWatcher.Create(Thread);
try
Thread.OnProgress := @Watcher.Progress;
@ -989,8 +1453,16 @@ begin
CmdFlatTest
else if (Cmd = 'build') and (ParamCount >= 3) then
CmdBuild
else if (Cmd = 'keytest') and (ParamCount >= 4) then
CmdKeyTest
else if (Cmd = 'buildcut') and (ParamCount >= 3) then
CmdBuildCut
else if (Cmd = 'subs') and (ParamCount >= 2) then
CmdSubs
else if (Cmd = 'preview') and (ParamCount >= 2) then
CmdPreview
else if (Cmd = 'audio') and (ParamCount >= 2) then
CmdAudio
else if (Cmd = 'render') and (ParamCount >= 3) then
CmdRender
else

110
tests/progress/progtest.lpr Normal file
View file

@ -0,0 +1,110 @@
{
Проверка того, как ползёт полоска выполнения.
Проверяется не картинка, а поведение: назад не идёт, между вехами
замедляется, при затянувшейся работе не упирается в конец.
Отдельная программа, а не проверка внутри редактора: расчёт вынесен в
uAppTypes именно затем, чтобы его можно было прогнать числами.
}
program progtest;
{$mode objfpc}{$H+}
uses
SysUtils, uAppTypes;
const
Step = 0.07;
var
Failed: Integer = 0;
procedure Check(const AName: string; ACondition: Boolean);
begin
if ACondition then
WriteLn(' ok ', AName)
else
begin
WriteLn(' ПЛОХО ', AName);
Inc(Failed);
end;
end;
{ Сколько шагов таймера пройдёт, пока полоска идёт от одного значения к
другому при неизменных вехах. }
function StepsFrom(AShown, AReal, ANext, ATarget: Double): Integer;
var
V: Double;
begin
Result := 0;
V := AShown;
while (V < ATarget) and (Result < 100000) do
begin
V := AdvanceProgress(V, AReal, ANext, Step);
Inc(Result);
end;
end;
var
V, Prev: Double;
I, N1, N2: Integer;
begin
WriteLn('Ход полоски выполнения');
WriteLn;
{ 1. Назад не идёт никогда, даже если настоящая доля вдруг меньше.
Сравнение с запасом: константа в исходнике считается в расширенной
точности, а значение хранится как Double, и точное равенство между
ними не выполняется никогда. }
V := AdvanceProgress(0.6, 0.2, 0.25, Step);
Check('назад не идёт', V >= 0.6 - 1E-9);
{ 2. Веха достигнута — прыгаем к ней сразу. }
V := AdvanceProgress(0.1, 0.5, 0.75, Step);
Check('до настоящей доли догоняет сразу', V >= 0.5);
{ 3. Между вехами ползёт, но следующей не достигает. }
V := 0;
for I := 1 to 200 do
V := AdvanceProgress(V, 0, 0.25, Step);
Check('между вехами движется', V > 0.2);
Check('следующую веху не переступает', V < 0.25);
{ 4. Замедление: вторая половина пути до вехи занимает столько же шагов,
сколько первая. Это и значит «чем дольше, тем медленнее». }
N1 := StepsFrom(0, 0, 1, 0.5);
N2 := StepsFrom(0.5, 0, 1, 0.75);
Check('вторая половина пути не быстрее первой', N2 >= N1);
WriteLn(Format(' шагов до половины: %d, до трёх четвертей: %d',
[N1, N1 + N2]));
{ 5. В конец не упирается, сколько ни жди. }
V := 0;
for I := 1 to 100000 do
V := AdvanceProgress(V, 0, 1, Step);
Check('полной не становится без настоящего конца', V <= 0.999);
{ 6. Ход монотонный. }
V := 0;
Prev := -1;
for I := 1 to 500 do
begin
V := AdvanceProgress(V, 0, 1, Step);
if V < Prev then
Break;
Prev := V;
end;
Check('ход монотонный', V >= Prev);
WriteLn;
if Failed = 0 then
WriteLn('ВЕРДИКТ: все проверки пройдены')
else
begin
WriteLn('ВЕРДИКТ: не пройдено проверок: ', Failed);
Halt(1);
end;
end.

75
tools/install-desktop.sh Executable file
View file

@ -0,0 +1,75 @@
#!/bin/bash
# Встраивание в рабочий стол: пункт меню, значки и тип файла проекта.
#
# Что делает:
# - кладёт значок программы и значок файла проекта в ~/.local/share/icons
# - описывает тип application/x-nlve-project, чтобы *.nlve перестали
# считаться просто текстом
# - кладёт пункт меню, который открывает такие файлы нашей программой
#
# Всё в домашнем каталоге: прав администратора не нужно, другим
# пользователям системы ничего не меняется.
#
# Запуск: tools/install-desktop.sh [путь к программе]
# По умолчанию берётся nlve рядом с этим репозиторием.
set -u
HERE=$(cd "$(dirname "$0")/.." && pwd)
BIN=${1:-$HERE/nlve}
PKG=$HERE/packaging
DATA=${XDG_DATA_HOME:-$HOME/.local/share}
APPS=$DATA/applications
MIME=$DATA/mime/packages
ICONS=$DATA/icons/hicolor
note() { echo "== $*"; }
fail() { echo "$*" >&2; exit 1; }
[ -x "$BIN" ] || fail "программы нет или она не запускается: $BIN"
[ -d "$PKG" ] || fail "нет каталога $PKG"
note "Значки"
for SIZE in 32 48 64 128; do
mkdir -p "$ICONS/${SIZE}x${SIZE}/apps" "$ICONS/${SIZE}x${SIZE}/mimetypes"
# convert есть не у всех; без него кладём один размер как есть.
if command -v convert >/dev/null 2>&1; then
convert "$PKG/nlve.png" -resize ${SIZE}x${SIZE} \
"$ICONS/${SIZE}x${SIZE}/apps/nlve.png"
convert "$PKG/application-x-nlve-project.png" -resize ${SIZE}x${SIZE} \
"$ICONS/${SIZE}x${SIZE}/mimetypes/application-x-nlve-project.png"
elif [ "$SIZE" = 128 ]; then
cp -f "$PKG/nlve.png" "$ICONS/128x128/apps/nlve.png"
cp -f "$PKG/application-x-nlve-project.png" \
"$ICONS/128x128/mimetypes/application-x-nlve-project.png"
fi
done
note "Тип файла проекта"
mkdir -p "$MIME"
cp -f "$PKG/application-x-nlve-project.xml" "$MIME/nlve.xml"
if command -v update-mime-database >/dev/null 2>&1; then
update-mime-database "$DATA/mime"
else
echo "update-mime-database не найдена: тип не зарегистрируется" >&2
fi
note "Пункт меню"
mkdir -p "$APPS"
sed -e "s|@BIN@|$BIN|g" -e "s|@DIR@|$(dirname "$BIN")|g" \
"$PKG/nlve.desktop.in" > "$APPS/nlve.desktop"
chmod 644 "$APPS/nlve.desktop"
command -v update-desktop-database >/dev/null 2>&1 &&
update-desktop-database "$APPS"
note "Программа по умолчанию для *.nlve"
command -v xdg-mime >/dev/null 2>&1 &&
xdg-mime default nlve.desktop application/x-nlve-project
command -v gtk-update-icon-cache >/dev/null 2>&1 &&
gtk-update-icon-cache -f -t "$ICONS" >/dev/null 2>&1
echo
echo "готово. Проверить: xdg-mime query filetype <файл>.nlve"
echo "Файловый менеджер может показать новый значок не сразу:"
echo "ему нужно перечитать базу (обычно достаточно перезапуска)."

62
tools/make_file_icon.py Normal file
View file

@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""
Делает значок файла проекта из значка программы.
Зачем отдельный. Значок программы и значок её файлов — разные вещи: в
списке файлов должно быть видно, что это документ, а не сама программа.
Поэтому берётся лист с загнутым углом, а знак программы кладётся на него.
Запуск: python3 tools/make_file_icon.py
Итог: packaging/application-x-nlve-project.png
"""
from pathlib import Path
from PIL import Image, ImageDraw
ROOT = Path(__file__).resolve().parent.parent
SRC = ROOT / 'packaging' / 'nlve.png'
DST = ROOT / 'packaging' / 'application-x-nlve-project.png'
SIZE = 128
# Лист занимает не всю клетку: значкам документов принято оставлять поля.
MARGIN_X = 16
MARGIN_Y = 6
FOLD = 30
PAPER = (245, 245, 245, 255)
FOLD_COLOR = (205, 205, 205, 255)
EDGE = (120, 120, 120, 255)
def main():
if not SRC.exists():
raise SystemExit(f'нет файла {SRC}')
img = Image.new('RGBA', (SIZE, SIZE), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
left, right = MARGIN_X, SIZE - MARGIN_X
top, bottom = MARGIN_Y, SIZE - MARGIN_Y
# Лист с срезанным правым верхним углом.
body = [
(left, top), (right - FOLD, top), (right, top + FOLD),
(right, bottom), (left, bottom),
]
d.polygon(body, fill=PAPER, outline=EDGE)
# Сам загнутый угол.
d.polygon([(right - FOLD, top), (right, top + FOLD),
(right - FOLD, top + FOLD)], fill=FOLD_COLOR, outline=EDGE)
# Знак программы поверх листа, в нижней его части: сверху угол.
mark = Image.open(SRC).convert('RGBA')
side = 66
mark = mark.resize((side, side), Image.LANCZOS)
img.alpha_composite(mark, ((SIZE - side) // 2, bottom - side - 8))
img.save(DST)
print('готов:', DST)
if __name__ == '__main__':
main()

137
tools/make_icons.py Normal file
View file

@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""
Готовит модуль с данными значков из файлов в icons_online.
Зачем модулем, а не файлами рядом с программой: программа не должна
зависеть от того, лежит ли что-то в её каталоге. Значки маленькие, все
вместе занимают единицы килобайт.
Размер выбран под кнопку: у неё двадцать восемь точек стороны, значку
достаётся клетка двадцать четыре на двадцать четыре. Уменьшение делает
convert с обычным сглаживанием, пропорции при этом сохраняются — все
исходники квадратные, растягивать нечего.
Запуск: python3 tools/make_icons.py
"""
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SRC = ROOT / 'icons_online'
OUT = ROOT / 'src' / 'uIconData.pas'
# Имя в программе -> файл исходника и сторона в точках.
# Для «схлопывания» берётся вариант на шестьдесят четыре точки: чем крупнее
# исходник, тем чище получается уменьшение.
ICONS = [
('Snap', 'icons8-магнит-30.png', 24),
('Ripple', 'icons8-merge-vertical-64.png', 24),
('Log', 'icons8-property-30.png', 24),
('Glue', 'icons8-клей-30.png', 24),
('Target', 'icons8-цель-30.png', 24),
]
# Значок самой программы: отдельно, потому что окну нужен формат ico —
# только его умеет читать TIcon в LCL.
APP_ICON = ('icons8-показ-видеокадров-30.png', 32)
def make_ico(path: Path, side: int) -> bytes:
out = subprocess.run(
['convert', str(path), '-background', 'none', '-alpha', 'on',
'-resize', '%dx%d' % (side, side), 'ico:-'],
check=True, capture_output=True)
return out.stdout
def scale(path: Path, side: int) -> bytes:
out = subprocess.run(
['convert', str(path), '-background', 'none', '-alpha', 'on',
'-resize', '%dx%d' % (side, side), 'png:-'],
check=True, capture_output=True)
return out.stdout
def pascal_bytes(data: bytes, indent: str = ' ') -> str:
parts = []
for i in range(0, len(data), 16):
chunk = ', '.join('$%02X' % b for b in data[i:i + 16])
parts.append(indent + chunk)
return ',\n'.join(parts)
def main() -> int:
if not SRC.is_dir():
print('нет каталога с исходниками:', SRC, file=sys.stderr)
return 1
blocks = []
names = []
for name, filename, side in ICONS:
path = SRC / filename
if not path.is_file():
print('нет файла:', path, file=sys.stderr)
return 1
data = scale(path, side)
names.append((name, len(data)))
blocks.append(
' { %s, %d на %d точек }\n'
' Icon%sData: array[0..%d] of Byte = (\n%s\n );'
% (filename, side, side, name, len(data) - 1, pascal_bytes(data)))
app_src, app_side = APP_ICON
app_path = SRC / app_src
if not app_path.is_file():
print('нет файла:', app_path, file=sys.stderr)
return 1
app_data = make_ico(app_path, app_side)
names.append(('App (ico)', len(app_data)))
blocks.append(
' { %s, значок окна, %d на %d точек, формат ico }\n'
' IconAppData: array[0..%d] of Byte = (\n%s\n );'
% (app_src, app_side, app_side, len(app_data) - 1,
pascal_bytes(app_data)))
text = '''{
uIconData — значки, вшитые в программу.
Собран из файлов в icons_online командой:
python3 tools/make_icons.py
Правится не он, а исходные картинки: этот файл переписывается целиком.
Значки взяты у Icons8 и требуют указания авторства — оно есть в окне
«О программе», вкладка «Библиотеки и авторство». Ссылки на каждый значок
лежат в icons_online/attribution.txt.
Картинки чёрные на прозрачном фоне. Цвет им задаётся при отрисовке:
берётся прозрачность точки, а цвет — тот, каким сейчас рисуется кнопка.
Поэтому один и тот же значок годится и для тёмной кнопки, и для
оранжевой.
}
unit uIconData;
{$mode objfpc}{$H+}
interface
const
%s
implementation
end.
''' % ('\n\n'.join(blocks))
OUT.write_text(text, encoding='utf-8')
print('записан', OUT)
for name, size in names:
print(' %-8s %d байт' % (name, size))
return 0
if __name__ == '__main__':
sys.exit(main())