Compare commits
10 commits
5a0f6318ab
...
74baa8dd5a
| Author | SHA1 | Date | |
|---|---|---|---|
| 74baa8dd5a | |||
| 638878bea2 | |||
| 03f3c84d8c | |||
| 0967678b61 | |||
| 3add5fcbf6 | |||
| ed889e5d03 | |||
| bdb16f3cea | |||
| d064a6e9d5 | |||
| 34767fd711 | |||
| ea51773bed |
24 changed files with 2830 additions and 173 deletions
49
BACKLOG.md
49
BACKLOG.md
|
|
@ -76,8 +76,57 @@ ffmpeg проблемой не является: он запускается о
|
|||
MP4/H.264 нельзя — контейнер и кодек альфу не хранят. Для этого нужен
|
||||
отдельный вывод в VP9 или ProRes 4444.
|
||||
|
||||
## Предпросмотр не показывает кадрирование обычного клипа
|
||||
|
||||
Кадрирование и поворот видны в окне только у клипов с ключом или
|
||||
прозрачностью: их плеер кладёт поверх основы сам и применяет к ним
|
||||
цепочку фильтров. Обычный клип идёт списком кусков, а список фильтровать
|
||||
нечем — он играется как есть, во весь кадр. В готовом ролике при этом всё
|
||||
верно: такой кусок помечен на пересборку и обкладывается полями.
|
||||
|
||||
Проверено опытом: у клипа выключен только ключ, кадрирование оставлено —
|
||||
окно показывает его во весь кадр.
|
||||
|
||||
Как это делается тем же механизмом, что и наложение: провести такой клип
|
||||
через ту же дорожку наложения, а под ним заставить основу быть чёрной —
|
||||
непрозрачный клип с полями в готовом ролике именно так и закрывает всё,
|
||||
что ниже. Значит, уплощению нужно понятие «здесь дыра, даже если ниже
|
||||
что-то есть».
|
||||
|
||||
Чего это стоит: ветка масштабирования в цепочке работает на каждом кадре,
|
||||
а не только там, где клип виден. Для накладки в 512 точек это пустяк, для
|
||||
куска 2688x1512 — уже нет. Поэтому сначала замер, потом решение.
|
||||
|
||||
## Окно настроек программы
|
||||
|
||||
Переключателей, которые стоят машинного времени, становится больше, а
|
||||
живут они кто где: прилипание и схлопывание — кнопками внизу, ключ в
|
||||
предпросмотре — в меню «Вид», выбор кодировщика вообще нигде (берётся
|
||||
NVENC, если он есть). Это разрозненно и не даёт увидеть общую цену.
|
||||
|
||||
Нужно одно окно, где собрано всё, что можно выключить ради скорости:
|
||||
|
||||
- ключ и прозрачность в предпросмотре (сейчас в меню «Вид»);
|
||||
- наложение в предпросмотре вообще — на слабой машине может не хватать и
|
||||
его, тогда честнее показывать одну основу;
|
||||
- кодировщик для пересборки: аппаратный или процессорный, и какой
|
||||
именно;
|
||||
- где держать кэш и когда его убирать;
|
||||
- прилипание клипов и курсора (сейчас кнопками, но им место и здесь).
|
||||
|
||||
Почему это важно: разработка идёт на машине с быстрой видеокартой, и
|
||||
всё, что здесь считается «недорогим», на другой может оказаться
|
||||
неподъёмным. Пока цифры замеряны только на одной машине, у человека
|
||||
должна быть возможность выключить любую из этих трат.
|
||||
|
||||
## Мелочи, до которых не дошли руки
|
||||
|
||||
**Вид дорожки не виден в её заголовке.** Дорожки картинки, звука и текста
|
||||
подписаны V1, A1, Т1, и по букве вид угадывается, но не более того.
|
||||
Клип, попавший на дорожку чужого вида, теперь не принимается и не
|
||||
теряется, но само различие стоило бы показать явно — значком или цветом
|
||||
полосы заголовка.
|
||||
|
||||
**Значок файла программы под Windows.** Значок окна задаётся из
|
||||
`uIconData`, а значок самого `.exe` в проводнике берётся из ресурса при
|
||||
сборке — там он до сих пор стандартный.
|
||||
|
|
|
|||
94
README.md
94
README.md
|
|
@ -141,6 +141,46 @@ xdg-mime query default application/x-nlve-project
|
|||
липнут при перетаскивании, курсор — при щелчке, и одно бывает нужно без
|
||||
другого.
|
||||
|
||||
**Ключ и прозрачность в предпросмотре** (меню «Вид»). Включено — окно
|
||||
показывает то же, что даст сборка: выбитый цвет и приглушение считаются
|
||||
на лету. Выключено — оверлей виден целиком, со своим фоном: так понятно,
|
||||
куда он встал и где его края.
|
||||
|
||||
Считается это по площади самого оверлея, а не всего кадра. Замер на
|
||||
2688x1512 (600 кадров, десять секунд материала): основа 2.07 с, с
|
||||
наложением 2.43 с, с ключом и прозрачностью 2.66 с. То есть наложение
|
||||
стоит 0.36 с, ключ добавляет ещё 0.23 с — вчетверо быстрее реального
|
||||
времени. На слабой машине это может быть заметнее, потому выключатель и
|
||||
есть.
|
||||
|
||||
**Отмеченный участок.** У линейки два этажа. Верхний, в двенадцать точек,
|
||||
— про отметки: протяжка по нему отмечает участок, даже если отметок не
|
||||
было, а щелчок без движения их снимает. По краям участка стоят жёлтые
|
||||
уголки, за них тянут. Нижний этаж — про курсор, как и раньше.
|
||||
|
||||
Клавиши тоже остались: `I` ставит начало по курсору, `O` — конец; оба
|
||||
пункта есть в меню «Правка», там же снятие отметок. Пока он отмечен, сборка
|
||||
собирает только его — это единственный способ проверить правку на длинном
|
||||
монтаже, не тратя на каждую проверку столько же времени, сколько идёт сам
|
||||
ролик. Отметки живут в файле проекта: участок относится к монтажу, а не к
|
||||
привычкам человека.
|
||||
|
||||
**Дорожки.** Слева в заголовке две стрелки: поднять и опустить. У верхней
|
||||
дорожки некуда вверх, у нижней вниз — такая стрелка нарисована тускло и
|
||||
не нажимается. Заголовок можно и просто потащить мышью. Двойной щелчок по
|
||||
названию переименовывает дорожку: на монтаже из десятка дорожек «карта»,
|
||||
«звук мотора» и «титры» читаются несравнимо лучше, чем V1, A2 и Т1.
|
||||
|
||||
Правый щелчок по заголовку открывает меню: поднять, опустить,
|
||||
переименовать, удалить. Удаление дорожки с клипами спрашивает
|
||||
подтверждение и называет число клипов.
|
||||
|
||||
**Копирование свойств клипа.** Ctrl+Shift+C берёт с выделенного клипа всё
|
||||
оформление — кадрирование, поворот, увеличение, смещение, прозрачность,
|
||||
цветовой ключ, затухания, громкость, — Ctrl+Shift+V применяет их ко всем
|
||||
выделенным. Вид клипа и его текст при этом не переносятся: свойства
|
||||
копируют между разными клипами, и превращать картинку в надпись незачем.
|
||||
|
||||
**Схлопывание.** Кнопка внизу показывает и режим, и состояние: утоплена —
|
||||
автосхлопывание включено, и тогда при перетаскивании клипа всё, что
|
||||
начинается не раньше него, едет следом. Стрелка рядом открывает выбор
|
||||
|
|
@ -358,6 +398,60 @@ Linux потом искала по нему `.dll`.
|
|||
каждого слоя считается на каждый отсчёт, и длина результата получается
|
||||
ровно такой, какая нужна.
|
||||
|
||||
## Качество пересобираемых кусков
|
||||
|
||||
Куски, которые нельзя скопировать, целятся в наибольший битрейт среди
|
||||
файлов проекта. Без этого кодировщик берёт своё значение по умолчанию, и
|
||||
оно скромное: на съёмке 2688x1512 с 56 Мбит/с NVENC выдавал 5.9 Мбит/с —
|
||||
пересобранный кусок бросался в глаза среди скопированных соседей.
|
||||
|
||||
Кроме среднего задаётся потолок в полтора раза выше и буфер вдвое: без
|
||||
них кодировщик разгоняется на сложных кадрах ровно настолько, насколько
|
||||
позволит средний, и мелкие детали всё равно теряются.
|
||||
|
||||
Берётся именно наибольший, а не средний: пересобранный кусок стоит рядом
|
||||
со скопированными, и если он окажется хуже их, разница видна именно на
|
||||
нём. Лишнее место дешевле.
|
||||
|
||||
## Место на диске
|
||||
|
||||
Куски весят примерно столько же, сколько готовый ролик, а на длинном
|
||||
материале и больше: на часовой съёмке набралось 110 ГБ. Поэтому:
|
||||
|
||||
- **картинка не собирается в промежуточный файл.** Звук собирается
|
||||
раньше, и склейка сразу кладёт всё вместе в конечный файл. Раньше
|
||||
получалось два полных ролика на диске одновременно — 91 ГБ в кэше и 91
|
||||
ГБ рядом — и лишний проход копирования ради одной звуковой дорожки;
|
||||
- **после удачной сборки куски удаляются,** и в журнале сказано, сколько
|
||||
места освободилось. После неудачной — остаются: с них продолжают.
|
||||
|
||||
Оставить их можно настройкой `keep_parts` в файле настроек: пригодится,
|
||||
когда разбираешься, из чего собрался ролик.
|
||||
|
||||
## Прерванная сборка
|
||||
|
||||
Каждый доведённый до конца кусок помечается в списке рядом с самими
|
||||
кусками (`готово.txt` в кэше). Если сборка не дошла до конца — упала,
|
||||
была отменена или программу закрыли, — при следующей сборке в тот же файл
|
||||
программа предложит продолжить с готовых кусков.
|
||||
|
||||
Проверяется не только наличие файла: сборку могли прервать посреди
|
||||
записи, и файл оказался бы обрезанным. Поэтому куском считается только
|
||||
тот, который успел попасть в список.
|
||||
|
||||
Первая строка списка — отпечаток плана: пути, места и длины всех кусков.
|
||||
Не совпал — значит монтаж с тех пор менялся, и готовые куски не подойдут;
|
||||
программа скажет об этом и соберёт заново.
|
||||
|
||||
## Сколько осталось
|
||||
|
||||
Окно сборки показывает, сколько прошло и сколько примерно осталось.
|
||||
Оценка простая: если четверть пути заняла минуту, весь путь займёт
|
||||
четыре. На деле скорость меняется — копируемые куски идут мгновенно,
|
||||
пересобираемые медленно, — поэтому число называется «примерно» и
|
||||
пересчитывается на каждом шаге. Даже такая оценка отвечает на главный
|
||||
вопрос: успею ли я сходить за чаем.
|
||||
|
||||
## Как собирается ролик
|
||||
|
||||
1. Монтаж уплощается в последовательность непересекающихся кусков.
|
||||
|
|
|
|||
|
|
@ -61,8 +61,72 @@ mpv 0.37, видео 2688x1512 HEVC 87 Мбит/с, 600 кадров (10 с ма
|
|||
и картинку, и звук.
|
||||
- `./build.sh tests` — все проверки пройдены.
|
||||
|
||||
## Что осталось
|
||||
## Продолжение: ключ прямо в предпросмотре
|
||||
|
||||
Ключ в предпросмотре не применяется — так решено сознательно. Стоит он
|
||||
недорого (фильтр работает по небольшой площади), и при желании это
|
||||
делается переключателем.
|
||||
По просьбе сделано и это. Ключ и прозрачность считаются в той же цепочке
|
||||
плеера, отдельным механизмом не обзаводились: строки фильтров теперь
|
||||
общие со сборкой (`ColorKeyFilter`, `OpacityFilter` в `uTransform`), так
|
||||
что окно и готовый ролик не могут разойтись по формату записи.
|
||||
|
||||
Переключатель — в меню «Вид», состояние переживает перезапуск. Замер на
|
||||
тяжёлом материале 2688x1512, 600 кадров:
|
||||
|
||||
| Что | Время |
|
||||
|---|---|
|
||||
| только основа | 2.07 с |
|
||||
| плюс наложение | 2.43 с |
|
||||
| плюс ключ и прозрачность | 2.66 с |
|
||||
|
||||
Проверено снимками окна: с ключом сквозь пурпур видно нижнее видео,
|
||||
без ключа оверлей виден плашкой целиком.
|
||||
|
||||
В бэклог записано окно настроек программы: переключателей, которые стоят
|
||||
машинного времени, становится много, и живут они кто где.
|
||||
|
||||
## Западня с настройками
|
||||
|
||||
Тестовые запуски шли с подменённым HOME, и это не изолировало настройки:
|
||||
в сессии задан `XDG_CONFIG_HOME`, и программа читала настоящий файл
|
||||
пользователя (оттуда и бралась высота панели предпросмотра в 883 точки,
|
||||
из-за которой монтаж не помещался в окно). Файл не пострадал — проверено
|
||||
по содержимому, — но теперь тестовые скрипты задают `XDG_CONFIG_HOME`,
|
||||
`XDG_CACHE_HOME` и `XDG_DATA_HOME` своими.
|
||||
|
||||
## Рез терял цветовой ключ
|
||||
|
||||
Показано пользователем на снимках: до реза оверлей лежал компасом в углу,
|
||||
после реза (S) в окне остался только он сам, во весь кадр и с пурпуром.
|
||||
|
||||
Причина: разрез создаёт хвост и переносит на него настройки по одной —
|
||||
громкость, прозрачность, кадрирование, затухания. Цветового ключа в этом
|
||||
списке не было, потому что он появился позже. Хвост без ключа перестаёт
|
||||
быть наложением, становится обычным победителем и закрывает собой всё,
|
||||
что ниже.
|
||||
|
||||
Чинить перечислением ещё одного поля значило бы оставить ту же ловушку
|
||||
следующему свойству. Поэтому у клипа появился `CopySettingsFrom`: одно
|
||||
место, где сказано, что переносится при копировании, а что делает клип
|
||||
этим клипом (имя, место, кусок исходника, связка, выделение).
|
||||
|
||||
Проверка добавлена в `lfmcheck`: рез клипа с ключом, прозрачностью,
|
||||
громкостью и кадрированием — всё это должно оказаться у обеих половин, а
|
||||
связки у них должны быть разные и непустые.
|
||||
|
||||
Важное для уже сделанных монтажей: правка не чинит проекты задним
|
||||
числом. Если оверлей был разрезан старой сборкой, у хвоста ключ надо
|
||||
включить заново.
|
||||
|
||||
## Почему казалось, что рез потерял и кадрирование
|
||||
|
||||
Не потерял: `Tail.Transform := Clip.Transform` было в разрезе и раньше.
|
||||
Дело в другом — кадрирование видно в окне только у клипов, которые плеер
|
||||
кладёт поверх основы сам. Обычный клип идёт списком кусков, а список
|
||||
фильтровать нечем.
|
||||
|
||||
Проверено опытом на том же проекте: у клипа выключен только ключ,
|
||||
кадрирование оставлено нетронутым — окно показало клип во весь кадр.
|
||||
То есть оба признака (пурпур и потерянный уголок) шли от одной причины,
|
||||
и с возвратом ключа возвращается и уголок.
|
||||
|
||||
Сам разрыв записан в `BACKLOG.md`: предпросмотр не показывает
|
||||
кадрирование обычного клипа, и как это чинить тем же механизмом.
|
||||
|
|
|
|||
118
docs/session-logs/2026-08-31_участок-качество-пропажи.md
Normal file
118
docs/session-logs/2026-08-31_участок-качество-пропажи.md
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
# 31.08.2026 — отмеченный участок, качество пересборки, пропавшие куски
|
||||
|
||||
## Что сообщил пользователь
|
||||
|
||||
1. В собранном ролике на одном месте нет ни надписи, ни фотографии, хотя
|
||||
на монтаже они есть.
|
||||
2. Качество пересобранных кусков отвратительное: битрейт 5931 кбит/с при
|
||||
исходных 56655. Предложено: если битрейт не задан, брать наибольший
|
||||
среди использованных файлов.
|
||||
3. Чтобы проверять такое, нужен отмеченный участок сборки — как в Vegas.
|
||||
|
||||
Порядок работ по его просьбе: сначала участок, потом пропажи, потом
|
||||
качество. Плана не было, задачи пришли списком.
|
||||
|
||||
## Участок
|
||||
|
||||
Отметки живут в проекте (`region_start`, `region_end`), ставятся клавишами
|
||||
`I` и `O` по курсору, показаны полосой под линейкой и стойками по краям.
|
||||
При сборке список кусков подрезается по участку — картинка и звук по
|
||||
одному и тому же времени, — а разметка текста берётся с его начала:
|
||||
иначе надписи легли бы со сдвигом.
|
||||
|
||||
Проверка 9 в `nlvetool flattest`: обрезка по участку, попадающему на
|
||||
середины крайних кусков, участок внутри одного куска и участок за концом
|
||||
монтажа. Снимок окна: полоса ровно между отметками.
|
||||
|
||||
## Пропажи: причин оказалось две
|
||||
|
||||
**Фотография.** Кусок с наложением собирается отдельной веткой, и в неё
|
||||
слои подаются как есть: `-ss ... -t ... -i фото.png`. Одиночная картинка
|
||||
даёт ffmpeg ровно один кадр — `-t` тут не помогает. Кусок длиной три
|
||||
секунды получался длиной 0.016667 с, то есть фотография из ролика просто
|
||||
исчезала. Теперь у слоя есть признак «это картинка», и она повторяется
|
||||
ключом `-loop 1` с частотой кадров проекта.
|
||||
|
||||
Воспроизведено на своём материале до правки (кусок 0.016667 с) и проверено
|
||||
после (3.000000 с, в кадре снова фотография).
|
||||
|
||||
**Текст.** Вжигание помечает кусок разметкой, но ветка наложения про
|
||||
разметку не знала и молча её теряла. Пока оверлей лежит на всём ролике —
|
||||
а это обычный случай, карта или компас поверх съёмки, — этой веткой идут
|
||||
все куски, и текст не появлялся нигде.
|
||||
|
||||
Теперь разметка вжигается последним звеном цепочки, поверх сведённой
|
||||
картинки. Проверено сборкой: в нижней полосе кадра 1833 белых точки там,
|
||||
где надпись есть, и ноль там, где её нет.
|
||||
|
||||
## Качество
|
||||
|
||||
Битрейт не задавался вовсе — кодировщик брал своё значение по умолчанию.
|
||||
Теперь пересборка целится в наибольший битрейт среди файлов проекта
|
||||
(`TProject.MaxVideoBitrate`), с потолком в полтора раза выше и буфером
|
||||
вдвое. Тем же битрейтом делаются вставки-картинки: фотография попадает в
|
||||
ролик наравне со съёмкой.
|
||||
|
||||
Наибольший, а не средний: пересобранный кусок стоит рядом со
|
||||
скопированными, и если он окажется хуже, разница видна именно на нём.
|
||||
|
||||
## Ловушка в проверочном инструменте
|
||||
|
||||
`nlvetool render` уплощает монтаж заново после создания вставок, и первая
|
||||
попытка вжечь текст до этого места ничего не дала: повторное уплощение
|
||||
стёрло отметки о разметке. Вжигание перенесено на окончательный список —
|
||||
так же, как в окне сборки.
|
||||
|
||||
## Третий заход: текст не вжигался в фотографию
|
||||
|
||||
Пользователь согласился перенести клипы на правильные дорожки, поставил
|
||||
галку вжигания — и текста в ролике снова не оказалось. Журнал сборки,
|
||||
заведённый как раз для таких случаев, показал причину за минуту:
|
||||
|
||||
кусок 2: img_4ec7c38d3285_15000.mkv, из 0.000000, длина 5.000000,
|
||||
с текстом, пересобирается
|
||||
> ffmpeg ... -i img_4ec7c38d3285_15000.mkv -map 0:v:0 -c copy ...
|
||||
|
||||
Кусок помечен «с текстом» и «пересобирается», а команда — копирование.
|
||||
Причина: сгенерированные вставки (картинки, чёрное) отбирались первой же
|
||||
веткой сборки, до проверки на пересборку, и копировались целиком. Текст
|
||||
до них просто не доходил.
|
||||
|
||||
Теперь вставка с разметкой идёт общей веткой пересборки, и время у неё
|
||||
берётся своё — она начинается с нуля, а не с середины монтажа. Проверено
|
||||
сборкой: на кадре с фотографией 3748 белых точек в нижней полосе, сама
|
||||
фотография на месте.
|
||||
|
||||
## Что ещё сделано в этом заходе
|
||||
|
||||
- Верхний этаж линейки: протяжка отмечает участок, даже если отметок не
|
||||
было; щелчок без движения их снимает.
|
||||
- Заголовок дорожки: правый щелчок открывает меню (поднять, опустить,
|
||||
удалить), перетаскивание меняет порядок дорожек.
|
||||
|
||||
## Четвёртый заход: оверлей, текст в окне и одинаковые имена
|
||||
|
||||
**Миникарта была в окне, но не в ролике.** Предпросмотр рисовал оверлей
|
||||
поверх всего, не глядя на порядок дорожек, а сборка живёт по правилу
|
||||
«верхний клип побеждает целиком». Фотография лежала выше оверлея — в
|
||||
ролике она его закрывала, в окне нет.
|
||||
|
||||
Теперь окно считает так же, как сборка: оверлей показывается только там,
|
||||
где его не закрывает сплошной клип с дорожки выше. Закрытый посередине
|
||||
оверлей разбивается на несколько окон показа. Проверка 11 в
|
||||
`nlvetool flattest`.
|
||||
|
||||
**Текст не появлялся в окне.** Разметка лежит в файле с одним и тем же
|
||||
именем на весь проект, а плеер сравнивал имена: имя прежнее — значит
|
||||
перечитывать нечего. Правка текста доходила до окна только вместе с
|
||||
перезагрузкой всего списка кусков.
|
||||
|
||||
Заодно проверено, что цепочка `lavfi-complex` субтитрам не мешает: та же
|
||||
цепочка, прогнанная через mpv в файл, даёт текст на кадре (28828 белых
|
||||
точек против 28758 без цепочки). Сравнивается теперь сама разметка, а не
|
||||
имя файла, и при изменении она перечитывается принудительно.
|
||||
|
||||
**Две дорожки Т2.** Имя новой дорожке давалось по счёту дорожек своего
|
||||
вида плюс единица. После удаления средней счёт совпадал с уже занятым
|
||||
именем. Теперь имя подбирается свободное, а одинаковые имена из старых
|
||||
проектов разводятся при чтении. Проверка 12.
|
||||
|
|
@ -58,6 +58,12 @@ type
|
|||
Y: Integer;
|
||||
{ Постоянная непрозрачность слоя. }
|
||||
Opacity: Double;
|
||||
{ Слой — неподвижная картинка, а не видео.
|
||||
|
||||
Её надо повторять: одиночный файл даёт ffmpeg ровно один кадр, и
|
||||
кусок с наложением схлопывался в одну шестидесятую секунды —
|
||||
фотография из ролика просто пропадала. }
|
||||
IsImage: Boolean;
|
||||
{ Цветовой ключ слоя: этот цвет становится прозрачным. Ключ выключен,
|
||||
если KeyEnabled равно False. }
|
||||
KeyEnabled: Boolean;
|
||||
|
|
|
|||
328
src/uExport.pas
328
src/uExport.pas
|
|
@ -115,6 +115,13 @@ type
|
|||
FStageText: string;
|
||||
FFraction: Double;
|
||||
FPendingLog: string;
|
||||
FLogPath: string;
|
||||
FResume: Boolean;
|
||||
FReusedParts: Integer;
|
||||
{ Имена кусков, доведённых до конца в этой или прошлой сборке. }
|
||||
FDone: TStringList;
|
||||
{ Признак того, что готовые куски относятся к этому же монтажу. }
|
||||
FPlanKey: string;
|
||||
FReport: TExportReport;
|
||||
|
||||
FOnProgress: TNotifyEvent;
|
||||
|
|
@ -142,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;
|
||||
|
||||
|
|
@ -169,6 +186,7 @@ type
|
|||
{ AMaster нужен, чтобы понять, влезут ли кодеки проекта в MPEG-TS.
|
||||
Можно передать пустую запись — тогда способ склейки выберется
|
||||
по одному лишь числу источников. }
|
||||
destructor Destroy; override;
|
||||
constructor Create(const ASegments, AAudio: TFlatSegmentArray;
|
||||
const AOutputPath: string; AKeepIntermediate: Boolean;
|
||||
const AMaster: TMediaInfo; const AProfile: TEncodeProfile);
|
||||
|
|
@ -187,6 +205,23 @@ type
|
|||
дорожкой. Пусто — либо текста нет, либо он вжигается в картинку.
|
||||
Годится только для 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;
|
||||
|
||||
{ Каталог для временных кусков рядом с готовым файлом. }
|
||||
|
|
@ -195,7 +230,33 @@ function CacheDirFor(const AOutputPath: string): string;
|
|||
implementation
|
||||
|
||||
uses
|
||||
Math, FileUtil, 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
|
||||
|
|
@ -204,6 +265,12 @@ begin
|
|||
PathDelim;
|
||||
end;
|
||||
|
||||
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 AProfile: TEncodeProfile);
|
||||
|
|
@ -272,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;
|
||||
|
|
@ -487,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;
|
||||
|
|
@ -512,6 +729,8 @@ begin
|
|||
Exit;
|
||||
|
||||
Rep.TempPath := Dst;
|
||||
|
||||
MarkDone(ExtractFileName(Dst));
|
||||
AddPart(Rep);
|
||||
Continue;
|
||||
end;
|
||||
|
|
@ -537,19 +756,29 @@ begin
|
|||
FSegments[I].Duration, FMaster.Width, FMaster.Height,
|
||||
FMaster.FpsNum, FMaster.FpsDen, FMaster.PixFmt,
|
||||
PreferredEncoder(FMaster.VideoCodec),
|
||||
FIntermediateFormat = 'mpegts'),
|
||||
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 then
|
||||
{ Кусок от файла, не совпадающего с параметрами проекта, или с
|
||||
вжигаемым текстом. Скопировать его нельзя — пересобираем. Но только
|
||||
его: соседние куски, снятые тем же оборудованием, что и весь
|
||||
проект, по-прежнему копируются.
|
||||
|
||||
Сгенерированные вставки — картинки и чёрное — сюда попадают только
|
||||
с текстом: без него их незачем перекодировать, они и так в
|
||||
параметрах проекта. Раньше они уходили в ветку выше и копировались
|
||||
вместе с текстом мимо: галка вжигания стояла, а надписи на
|
||||
фотографии не появлялось. }
|
||||
if FSegments[I].Transcode or (FSegments[I].SubFile <> '') then
|
||||
begin
|
||||
SetStage(Format('Пересобираю кусок %d из %d (файл не совпадает с проектом)',
|
||||
[I + 1, Length(FSegments)]));
|
||||
|
|
@ -563,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, FSegments[I].SubFile),
|
||||
FIntermediateFormat, FSegments[I].Filter, FSegments[I].SubFile,
|
||||
FMaster.VideoBitrate),
|
||||
'Пересборка куска ' + IntToStr(I + 1)) then
|
||||
Exit;
|
||||
|
||||
Rep.TempPath := Dst;
|
||||
|
||||
MarkDone(ExtractFileName(Dst));
|
||||
AddPart(Rep);
|
||||
Continue;
|
||||
end;
|
||||
|
|
@ -620,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
|
||||
|
|
@ -636,6 +877,7 @@ begin
|
|||
Rep.Duration := SegEnd - EncodeUntil;
|
||||
Rep.TempPath := Dst;
|
||||
Rep.Encoded := False;
|
||||
MarkDone(ExtractFileName(Dst));
|
||||
AddPart(Rep);
|
||||
end;
|
||||
|
||||
|
|
@ -667,6 +909,7 @@ begin
|
|||
Rep.Duration := FSegments[I].Duration;
|
||||
Rep.TempPath := Dst;
|
||||
Rep.Encoded := False;
|
||||
MarkDone(ExtractFileName(Dst));
|
||||
AddPart(Rep);
|
||||
end;
|
||||
|
||||
|
|
@ -713,9 +956,9 @@ begin
|
|||
куски по отдельности здесь нельзя, на стыках была бы видна смена
|
||||
качества. }
|
||||
SetStage('Склеиваю и перекодирую');
|
||||
if RunFfmpeg(ArgsConcatEncode(AListPath, FVideoPath,
|
||||
if RunFfmpeg(ArgsConcatEncode(AListPath, FOutputPath,
|
||||
FProfile.Width, FProfile.Height, FMaster.FpsNum, FMaster.FpsDen,
|
||||
FProfile.BitrateKbps, FProfile.Codec, FMaster.PixFmt, True),
|
||||
FProfile.BitrateKbps, FProfile.Codec, FMaster.PixFmt, True, FAudioPath),
|
||||
'Склейка с перекодированием') then
|
||||
Exit(True);
|
||||
|
||||
|
|
@ -728,14 +971,15 @@ begin
|
|||
видеокарте кодек исходника. Отказываться из-за неё от сборки
|
||||
нельзя: медленно лучше, чем никак. }
|
||||
Log('Аппаратная распаковка не пошла, повторяю на процессоре.');
|
||||
Exit(RunFfmpeg(ArgsConcatEncode(AListPath, FVideoPath,
|
||||
Exit(RunFfmpeg(ArgsConcatEncode(AListPath, FOutputPath,
|
||||
FProfile.Width, FProfile.Height, FMaster.FpsNum, FMaster.FpsDen,
|
||||
FProfile.BitrateKbps, FProfile.Codec, FMaster.PixFmt, False),
|
||||
FProfile.BitrateKbps, FProfile.Codec, FMaster.PixFmt, False, FAudioPath),
|
||||
'Склейка с перекодированием без аппаратной распаковки'));
|
||||
end;
|
||||
|
||||
SetStage('Склеиваю');
|
||||
Result := RunFfmpeg(ArgsConcat(AListPath, FVideoPath), 'Склейка');
|
||||
Result := RunFfmpeg(ArgsConcatMux(AListPath, FAudioPath, FSubtitlePath,
|
||||
FOutputPath), 'Склейка');
|
||||
end;
|
||||
|
||||
procedure TExportThread.Verify;
|
||||
|
|
@ -987,6 +1231,7 @@ procedure TExportThread.Execute;
|
|||
var
|
||||
ListPath: string;
|
||||
I: Integer;
|
||||
Freed: Int64;
|
||||
ToolError: string;
|
||||
begin
|
||||
try
|
||||
|
|
@ -1013,6 +1258,12 @@ begin
|
|||
Log('Временные куски: ' + FCacheDir);
|
||||
|
||||
ChooseIntermediateFormat;
|
||||
{ Список готовых кусков — после выбора промежуточного формата: от него
|
||||
зависят их имена. }
|
||||
LoadDoneList;
|
||||
if FResume and (FDone.Count > 1) then
|
||||
Log(Format('Продолжаю прерванную сборку: готовых кусков %d.',
|
||||
[FDone.Count - 1]));
|
||||
|
||||
{ Единица учёта работы — секунда материала. Каждая секунда проходит
|
||||
через вырезание, склейку и сборку звука, отсюда тройной вес. }
|
||||
|
|
@ -1024,23 +1275,24 @@ begin
|
|||
if not WriteConcatList(ListPath) then
|
||||
Exit;
|
||||
|
||||
{ Картинка собирается в отдельный файл, а не сразу в конечный: звук у
|
||||
неё свой, из исходников, и он почти наверняка не тот, что нужен.
|
||||
Настоящий звук собирается отдельно и подставляется при сшивании. }
|
||||
FVideoPath := FCacheDir + 'video' + ExtractFileExt(FOutputPath);
|
||||
if not ConcatAll(ListPath) then
|
||||
Exit;
|
||||
{ Звук собирается раньше картинки, и склейка сразу кладёт всё вместе в
|
||||
конечный файл.
|
||||
|
||||
Так было не всегда: сперва картинка собиралась в отдельный файл, а
|
||||
потом сшивалась со звуком. На часовом ролике это 91 ГБ
|
||||
промежуточного файла и лишний полный проход копирования — места
|
||||
требовалось вдвое больше готового ролика. }
|
||||
FVideoPath := '';
|
||||
if not BuildAudio then
|
||||
begin
|
||||
if FReport.Cancelled then
|
||||
Exit;
|
||||
{ Без звука ролик всё равно лучше, чем ничего: картинка уже собрана. }
|
||||
Log('Звук собрать не удалось, оставляю картинку как есть.');
|
||||
if not RenameFile(FVideoPath, FOutputPath) then
|
||||
FileUtil.CopyFile(FVideoPath, FOutputPath);
|
||||
end
|
||||
else if not MuxFinal then
|
||||
{ Без звука ролик всё равно лучше, чем ничего. }
|
||||
Log('Звук собрать не удалось, собираю картинку без него.');
|
||||
FAudioPath := '';
|
||||
end;
|
||||
|
||||
if not ConcatAll(ListPath) then
|
||||
Exit;
|
||||
|
||||
Verify;
|
||||
|
|
@ -1054,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
|
||||
|
|
|
|||
|
|
@ -31,15 +31,23 @@ type
|
|||
Path: string;
|
||||
{ Вжигать ли текст в картинку. При MP4 всегда True. }
|
||||
BurnText: Boolean;
|
||||
{ Собирать только отмеченный участок. Имеет смысл, только когда
|
||||
участок отмечен. }
|
||||
RegionOnly: Boolean;
|
||||
end;
|
||||
|
||||
{ Показывает окно.
|
||||
|
||||
AHasText — есть ли в проекте надписи: без них про текст и спрашивать
|
||||
незачем. APreferMkv — в монтаже есть точные резы, и MKV надёжнее.
|
||||
ABurnDefault — ответ, данный в прошлый раз. }
|
||||
ABurnDefault — ответ, данный в прошлый раз.
|
||||
|
||||
ARegion — описание отмеченного участка. Не пусто — в окне появляется
|
||||
галка «только отмеченный участок». Галка, а не надпись: надпись легко
|
||||
проглядеть, а тут вопрос из тех, где ошибка стоит часа работы — соберёшь
|
||||
кусок вместо ролика или наоборот. }
|
||||
function AskExport(var AChoice: TExportChoice; AHasText, APreferMkv: Boolean;
|
||||
const ADefaultDir: string): Boolean;
|
||||
const ADefaultDir: string; const ARegion: string = ''): Boolean;
|
||||
|
||||
implementation
|
||||
|
||||
|
|
@ -51,6 +59,7 @@ type
|
|||
BtnMp4: TThemedButton;
|
||||
BtnMkv: TThemedButton;
|
||||
cbBurn: TCheckBox;
|
||||
cbRegion: TCheckBox;
|
||||
lblNote: TLabel;
|
||||
HasText: Boolean;
|
||||
{ Ответ человека про вжигание, сохранённый до того, как MP4 навязал
|
||||
|
|
@ -65,6 +74,7 @@ type
|
|||
procedure FormatClick(Sender: TObject);
|
||||
procedure BrowseClick(Sender: TObject);
|
||||
procedure BurnClick(Sender: TObject);
|
||||
procedure RegionClick(Sender: TObject);
|
||||
function IsMp4: Boolean;
|
||||
procedure ApplyFormat;
|
||||
end;
|
||||
|
|
@ -139,6 +149,12 @@ begin
|
|||
ApplyFormat;
|
||||
end;
|
||||
|
||||
{ Галка участка ничего не пересчитывает: она только запоминает ответ.
|
||||
Всё, что от неё зависит, решается уже после закрытия окна. }
|
||||
procedure TExportDialog.RegionClick(Sender: TObject);
|
||||
begin
|
||||
end;
|
||||
|
||||
procedure TExportDialog.BrowseClick(Sender: TObject);
|
||||
var
|
||||
Dlg: TSaveDialog;
|
||||
|
|
@ -166,7 +182,7 @@ begin
|
|||
end;
|
||||
|
||||
function AskExport(var AChoice: TExportChoice; AHasText, APreferMkv: Boolean;
|
||||
const ADefaultDir: string): Boolean;
|
||||
const ADefaultDir: string; const ARegion: string): Boolean;
|
||||
var
|
||||
Dlg: TExportDialog;
|
||||
Lbl: TLabel;
|
||||
|
|
@ -183,7 +199,7 @@ begin
|
|||
Dlg.Form.Caption := 'Сборка ролика';
|
||||
Dlg.Form.Position := poScreenCenter;
|
||||
Dlg.Form.ClientWidth := 620;
|
||||
Dlg.Form.ClientHeight := 250;
|
||||
Dlg.Form.ClientHeight := 272;
|
||||
|
||||
Lbl := TLabel.Create(Dlg.Form);
|
||||
Lbl.Parent := Dlg.Form;
|
||||
|
|
@ -221,23 +237,42 @@ begin
|
|||
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, 116, 300, 22);
|
||||
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, 144, 596, 52);
|
||||
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, 208, 100, 30);
|
||||
BtnOk.SetBounds(400, 230, 100, 30);
|
||||
BtnOk.Anchors := [akRight, akBottom];
|
||||
BtnOk.Caption := 'Собрать';
|
||||
BtnOk.ModalResult := mrOk;
|
||||
|
|
@ -245,7 +280,7 @@ begin
|
|||
|
||||
BtnCancel := TThemedButton.Create(Dlg.Form);
|
||||
BtnCancel.Parent := Dlg.Form;
|
||||
BtnCancel.SetBounds(508, 208, 100, 30);
|
||||
BtnCancel.SetBounds(508, 230, 100, 30);
|
||||
BtnCancel.Anchors := [akRight, akBottom];
|
||||
BtnCancel.Caption := 'Отмена';
|
||||
BtnCancel.ModalResult := mrCancel;
|
||||
|
|
@ -276,6 +311,7 @@ begin
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ function ArgsTranscodeSegment(const ASrc, ADst: string; AStart, AEnd: Double;
|
|||
const AVideoCodec, APixFmt, AAudioCodec: string;
|
||||
ASampleRate, AChannels: Integer; AVolume: Double;
|
||||
const AFormat: string; const AFilter: string = '';
|
||||
const ASubFile: string = ''): TArgs;
|
||||
const ASubFile: string = ''; ABitrate: Int64 = 0): TArgs;
|
||||
|
||||
{ Путь внутри строки фильтра. Двоеточие там разделяет настройки, обратная
|
||||
косая и кавычка имеют своё значение — всё это надо обезвредить, иначе
|
||||
|
|
@ -115,6 +115,18 @@ 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 с
|
||||
|
|
@ -127,7 +139,8 @@ function ArgsConcat(const AListFile, ADst: string): TArgs;
|
|||
процессоре идёт медленнее реального времени, и смысл теряется. }
|
||||
function ArgsConcatEncode(const AListFile, ADst: string;
|
||||
AWidth, AHeight, AFpsNum, AFpsDen, ABitrateKbps: Integer;
|
||||
const AVideoCodec, APixFmt: string; AHwDecode: Boolean = True): TArgs;
|
||||
const AVideoCodec, APixFmt: string; AHwDecode: Boolean = True;
|
||||
const AAudio: string = ''): TArgs;
|
||||
|
||||
{ Перепаковать файл в другой контейнер без перекодирования. }
|
||||
{ Звук куска в сыром виде: 16 бит со знаком, частота и число каналов
|
||||
|
|
@ -146,11 +159,16 @@ function ArgsEncodeAudio(const ASource, ATarget: string;
|
|||
|
||||
Постоянная непрозрачность делается наложением с приглушённым альфа-
|
||||
каналом, переход — фильтром 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): TArgs;
|
||||
const APixFmt, ACodec: string; AIsTs: Boolean;
|
||||
ABitrate: Int64 = 0; const ASubFile: string = ''): TArgs;
|
||||
|
||||
{ Сшивание готовой картинки с готовым звуком. Оба потока копируются.
|
||||
|
||||
|
|
@ -197,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;
|
||||
|
||||
{ Сделать чёрный клип с тишиной заданной длины.
|
||||
|
||||
|
|
@ -235,7 +253,11 @@ function ParseProgressLine(const ALine: string; var ASeconds: Double;
|
|||
implementation
|
||||
|
||||
uses
|
||||
StrUtils;
|
||||
StrUtils, uTransform;
|
||||
|
||||
{ Ключи качества. Объявлено в самом начале: ими пользуется отрисовка
|
||||
слоёв, а она по тексту раньше самой функции. }
|
||||
function BitrateArgs(ABitrate: Int64): TArgs; forward;
|
||||
|
||||
{ Общие параметры вывода в формате проекта. Объявлено заранее, потому что
|
||||
используется выше по тексту, чем определено. }
|
||||
|
|
@ -279,20 +301,33 @@ 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): TArgs;
|
||||
const APixFmt, ACodec: string; AIsTs: Boolean;
|
||||
ABitrate: Int64; const ASubFile: string): TArgs;
|
||||
var
|
||||
I: Integer;
|
||||
Graph, Fit, Src: string;
|
||||
Graph, Fit, Src, OutLabel: string;
|
||||
Crossfade: Boolean;
|
||||
begin
|
||||
Result := ['-hide_banner', '-nostdin', '-loglevel', 'warning', '-y'];
|
||||
|
||||
{ Каждый слой — отдельный вход, вырезанный ровно по длине участка. }
|
||||
{ Каждый слой — отдельный вход, вырезанный ровно по длине участка.
|
||||
|
||||
Неподвижная картинка — случай особый: её надо повторять ключом -loop,
|
||||
иначе ffmpeg отдаёт один-единственный кадр, и весь кусок с наложением
|
||||
схлопывается в одну шестидесятую секунды. Перематывать её тоже
|
||||
незачем: у картинки нет времени. }
|
||||
for I := 0 to High(ALayers) do
|
||||
Result := Concat(Result,
|
||||
['-ss', SecondsToStr(ALayers[I].SrcStart),
|
||||
'-t', SecondsToStr(ADuration),
|
||||
'-i', ALayers[I].FilePath]);
|
||||
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
|
||||
|
|
@ -318,12 +353,8 @@ begin
|
|||
масштабом. Цвет записывается как 0xRRGGBB — порядок обратный тому,
|
||||
в каком он хранится у нас. }
|
||||
if ALayers[I].KeyEnabled then
|
||||
Fit := Fit + Format(',colorkey=0x%.2x%.2x%.2x:%s:%s',
|
||||
[ALayers[I].KeyColor and $FF,
|
||||
(ALayers[I].KeyColor shr 8) and $FF,
|
||||
(ALayers[I].KeyColor shr 16) and $FF,
|
||||
SecondsToStr(ALayers[I].KeySimilarity / 100),
|
||||
SecondsToStr(ALayers[I].KeyBlend / 100)]);
|
||||
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;
|
||||
|
||||
|
|
@ -364,15 +395,27 @@ begin
|
|||
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', '[out]',
|
||||
['-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']);
|
||||
|
|
@ -407,7 +450,7 @@ 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;
|
||||
|
|
@ -621,7 +664,7 @@ function ArgsTranscodeSegment(const ASrc, ADst: string; AStart, AEnd: Double;
|
|||
const AVideoCodec, APixFmt, AAudioCodec: string;
|
||||
ASampleRate, AChannels: Integer; AVolume: Double;
|
||||
const AFormat: string; const AFilter: string;
|
||||
const ASubFile: string): TArgs;
|
||||
const ASubFile: string; ABitrate: Int64): TArgs;
|
||||
var
|
||||
Chain: string;
|
||||
begin
|
||||
|
|
@ -655,7 +698,7 @@ begin
|
|||
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);
|
||||
|
|
@ -679,7 +722,8 @@ end;
|
|||
|
||||
function ArgsConcatEncode(const AListFile, ADst: string;
|
||||
AWidth, AHeight, AFpsNum, AFpsDen, ABitrateKbps: Integer;
|
||||
const AVideoCodec, APixFmt: string; AHwDecode: Boolean): TArgs;
|
||||
const AVideoCodec, APixFmt: string; AHwDecode: Boolean;
|
||||
const AAudio: string): TArgs;
|
||||
var
|
||||
Fps, Enc: string;
|
||||
begin
|
||||
|
|
@ -700,13 +744,13 @@ begin
|
|||
if AHwDecode and (Pos('nvenc', Enc) > 0) then
|
||||
Result := Concat(Result, ['-hwaccel', 'cuda']);
|
||||
|
||||
Result := Concat(Result, [
|
||||
'-f', 'concat',
|
||||
'-safe', '0',
|
||||
'-i', AListFile,
|
||||
'-map', '0:v:0',
|
||||
'-map', '0:a:0?'
|
||||
]);
|
||||
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?']);
|
||||
|
||||
{ Вписываем целиком и добиваем полями: соотношение сторон менять нельзя,
|
||||
иначе кадр растянется. }
|
||||
|
|
@ -759,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, [
|
||||
|
|
@ -929,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
|
||||
|
|
@ -947,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
|
||||
|
|
@ -979,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 задаёт длину.
|
||||
Картинка вписывается в кадр целиком с чёрными полями по краям:
|
||||
|
|
@ -1002,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);
|
||||
|
|
|
|||
195
src/uFlatten.pas
195
src/uFlatten.pas
|
|
@ -38,6 +38,9 @@ uses
|
|||
Classes, SysUtils, uAppTypes, uProject, uMediaProbe, uTransform;
|
||||
|
||||
type
|
||||
{ Набор отрезков времени: им описывают, где оверлей виден. }
|
||||
TDoubleArray = array of Double;
|
||||
|
||||
{ Клип, который в предпросмотре кладётся поверх основы.
|
||||
|
||||
Место в кадре — то же самое, по чему сборка обкладывает картинку
|
||||
|
|
@ -140,11 +143,31 @@ function FlattenProject(AProject: TProject;
|
|||
под ними видно то, что лежит ниже, — а выдаются отдельным списком со
|
||||
временем и местом в кадре.
|
||||
|
||||
AWithKey — применять ли к оверлею цветовой ключ и прозрачность прямо в
|
||||
плеере. Тогда окно показывает то же, что даст сборка. Стоит это
|
||||
недорого: фильтр работает по площади самого оверлея, а не всего кадра,
|
||||
но на слабой машине лишним не будет, поэтому решает вызывающий.
|
||||
|
||||
Для сборки ролика это не применяется: там всё сводится по-настоящему. }
|
||||
function FlattenPreview(AProject: TProject;
|
||||
out AVideo, AAudio: TFlatSegmentArray;
|
||||
out AOverlays: TPreviewOverlays;
|
||||
out AMissing: TGenRequests): Boolean;
|
||||
out AMissing: TGenRequests;
|
||||
AWithKey: Boolean = False): Boolean;
|
||||
|
||||
{ Оставляет от списка кусков только то, что попадает в участок.
|
||||
|
||||
Куски, задетые краем, подрезаются: у начального сдвигается точка входа в
|
||||
исходник, у конечного укорачивается длина. Слои внутри куска сдвигаются
|
||||
вместе с ним — иначе наложение поехало бы относительно картинки.
|
||||
|
||||
Зачем: собрать кусок монтажа, не собирая весь ролик. На длинном
|
||||
материале это разница между минутой и часом, и без этого проверить
|
||||
правку в готовом файле почти невозможно.
|
||||
|
||||
Возвращает, сколько секунд осталось. }
|
||||
function ClipSegmentsToRange(var ASegments: TFlatSegmentArray;
|
||||
const AFrom, ATo: Double): Double;
|
||||
|
||||
{ Путь к сгенерированному клипу. Имя выводится из содержимого запроса,
|
||||
поэтому одинаковые запросы дают один и тот же файл, и он делается
|
||||
|
|
@ -271,6 +294,48 @@ begin
|
|||
ALayers[I].KeySimilarity, ALayers[I].KeyBlend]);
|
||||
end;
|
||||
|
||||
function ClipSegmentsToRange(var ASegments: TFlatSegmentArray;
|
||||
const AFrom, ATo: Double): Double;
|
||||
var
|
||||
I, J, N: Integer;
|
||||
At, SegStart, SegEnd, NewStart, NewEnd, Shift: Double;
|
||||
Kept: TFlatSegmentArray;
|
||||
Seg: TFlatSegment;
|
||||
begin
|
||||
Result := 0;
|
||||
if ATo <= AFrom then
|
||||
Exit;
|
||||
|
||||
N := 0;
|
||||
At := 0;
|
||||
SetLength(Kept, Length(ASegments));
|
||||
for I := 0 to High(ASegments) do
|
||||
begin
|
||||
SegStart := At;
|
||||
SegEnd := At + ASegments[I].Duration;
|
||||
At := SegEnd;
|
||||
|
||||
NewStart := Max(SegStart, AFrom);
|
||||
NewEnd := Min(SegEnd, ATo);
|
||||
if NewEnd <= NewStart + 0.0005 then
|
||||
Continue;
|
||||
|
||||
Seg := ASegments[I];
|
||||
Shift := NewStart - SegStart;
|
||||
Seg.SrcStart := Seg.SrcStart + Shift;
|
||||
Seg.Duration := NewEnd - NewStart;
|
||||
for J := 0 to High(Seg.Layers) do
|
||||
Seg.Layers[J].SrcStart := Seg.Layers[J].SrcStart + Shift;
|
||||
|
||||
Kept[N] := Seg;
|
||||
Inc(N);
|
||||
Result := Result + Seg.Duration;
|
||||
end;
|
||||
|
||||
SetLength(Kept, N);
|
||||
ASegments := Kept;
|
||||
end;
|
||||
|
||||
procedure ResolveLayers(AProject: TProject; var ASegments: TFlatSegmentArray;
|
||||
var AMissing: TGenRequests);
|
||||
var
|
||||
|
|
@ -431,6 +496,7 @@ var
|
|||
if It = nil then
|
||||
Exit;
|
||||
Result.FilePath := It.EffectivePath;
|
||||
Result.IsImage := (AClip.Kind = ckImage) or (It.Info.Kind = mkImage);
|
||||
Result.SrcStart := AClip.SrcIn + (AAt - AClip.TlStart);
|
||||
Result.KeyEnabled := AClip.ColorKey.Enabled;
|
||||
Result.KeyColor := AClip.ColorKey.Color;
|
||||
|
|
@ -1023,6 +1089,52 @@ begin
|
|||
end;
|
||||
end;
|
||||
|
||||
{ Вырезает из набора отрезков указанный. Отрезок посередине делит тот, в
|
||||
который попал, надвое: оверлей, закрытый в середине, показывается двумя
|
||||
окнами. }
|
||||
procedure CutRange(var AFrom, ATo: TDoubleArray; var ACount: Integer;
|
||||
const ACutFrom, ACutTo: Double);
|
||||
var
|
||||
I, N: Integer;
|
||||
NewFrom, NewTo: array of Double;
|
||||
|
||||
procedure Push(const A, B: Double);
|
||||
begin
|
||||
if B <= A + 0.001 then
|
||||
Exit;
|
||||
NewFrom[N] := A;
|
||||
NewTo[N] := B;
|
||||
Inc(N);
|
||||
end;
|
||||
|
||||
begin
|
||||
if ACutTo <= ACutFrom then
|
||||
Exit;
|
||||
SetLength(NewFrom, ACount * 2 + 2);
|
||||
SetLength(NewTo, ACount * 2 + 2);
|
||||
N := 0;
|
||||
for I := 0 to ACount - 1 do
|
||||
begin
|
||||
{ Не пересекаются — отрезок остаётся как был. }
|
||||
if (ACutTo <= AFrom[I] + 0.001) or (ACutFrom >= ATo[I] - 0.001) then
|
||||
Push(AFrom[I], ATo[I])
|
||||
else
|
||||
begin
|
||||
Push(AFrom[I], ACutFrom);
|
||||
Push(ACutTo, ATo[I]);
|
||||
end;
|
||||
end;
|
||||
|
||||
ACount := N;
|
||||
SetLength(AFrom, N);
|
||||
SetLength(ATo, N);
|
||||
for I := 0 to N - 1 do
|
||||
begin
|
||||
AFrom[I] := NewFrom[I];
|
||||
ATo[I] := NewTo[I];
|
||||
end;
|
||||
end;
|
||||
|
||||
{ Клип кладётся поверх, а не побеждает целиком, если он не сплошной:
|
||||
с выбитым цветом или полупрозрачный. }
|
||||
function IsOverlayClip(AClip: TClip): Boolean;
|
||||
|
|
@ -1034,15 +1146,18 @@ end;
|
|||
function FlattenPreview(AProject: TProject;
|
||||
out AVideo, AAudio: TFlatSegmentArray;
|
||||
out AOverlays: TPreviewOverlays;
|
||||
out AMissing: TGenRequests): Boolean;
|
||||
out AMissing: TGenRequests;
|
||||
AWithKey: Boolean): Boolean;
|
||||
var
|
||||
Hidden: TFPList;
|
||||
MissA: TGenRequests;
|
||||
T, C, I, N: Integer;
|
||||
Track: TTrack;
|
||||
T, C, I, J, K, N: Integer;
|
||||
Track, Above: TTrack;
|
||||
Clip: TClip;
|
||||
Item: TMediaItem;
|
||||
Total: Double;
|
||||
RangeA, RangeB: TDoubleArray;
|
||||
RangeCount: Integer;
|
||||
Ov: TPreviewOverlay;
|
||||
|
||||
{ Занято ли это время другим оверлеем. }
|
||||
|
|
@ -1099,18 +1214,66 @@ begin
|
|||
|
||||
Hidden.Add(Clip);
|
||||
|
||||
Ov := Default(TPreviewOverlay);
|
||||
Ov.TlStart := Clip.TlStart;
|
||||
Ov.TlEnd := Clip.TlEnd;
|
||||
Ov.FilePath := Item.EffectivePath;
|
||||
Ov.SrcStart := Clip.SrcIn;
|
||||
Ov.Filter := OverlayFilter(Clip.Transform,
|
||||
Item.Info.Width, Item.Info.Height,
|
||||
AProject.Master.Width, AProject.Master.Height,
|
||||
Ov.X, Ov.Y, Ov.Width, Ov.Height);
|
||||
SetLength(AOverlays, N + 1);
|
||||
AOverlays[N] := Ov;
|
||||
Inc(N);
|
||||
{ Оверлей виден не всюду, где лежит: сверху его может закрывать
|
||||
сплошной клип, и в готовом ролике его там не будет — побеждает
|
||||
верхняя дорожка целиком. Показывать в окне то, чего не будет в
|
||||
ролике, нельзя: именно из-за такого расхождения человек собирает
|
||||
ролик и не находит в нём миникарты.
|
||||
|
||||
Закрывать могут и посередине — тогда от оверлея остаётся
|
||||
несколько отрезков, и каждый становится своим окном показа. }
|
||||
RangeCount := 1;
|
||||
SetLength(RangeA, 1);
|
||||
SetLength(RangeB, 1);
|
||||
RangeA[0] := Clip.TlStart;
|
||||
RangeB[0] := Clip.TlEnd;
|
||||
|
||||
for K := 0 to T - 1 do
|
||||
begin
|
||||
Above := AProject.Tracks[K];
|
||||
if Above.Kind <> tkVideo then
|
||||
Continue;
|
||||
if not AProject.TrackAudible(Above) then
|
||||
Continue;
|
||||
for J := 0 to Above.Count - 1 do
|
||||
{ Клип с ключом или прозрачностью сам ничего не закрывает. }
|
||||
if not IsOverlayClip(Above[J]) then
|
||||
CutRange(RangeA, RangeB, RangeCount,
|
||||
Above[J].TlStart, Above[J].TlEnd);
|
||||
end;
|
||||
|
||||
for J := 0 to RangeCount - 1 do
|
||||
begin
|
||||
if RangeB[J] <= RangeA[J] + 0.001 then
|
||||
Continue;
|
||||
if OverlapsTaken(RangeA[J], RangeB[J]) then
|
||||
Continue;
|
||||
|
||||
Ov := Default(TPreviewOverlay);
|
||||
Ov.TlStart := RangeA[J];
|
||||
Ov.TlEnd := RangeB[J];
|
||||
Ov.FilePath := Item.EffectivePath;
|
||||
Ov.SrcStart := Clip.SrcIn + (RangeA[J] - Clip.TlStart);
|
||||
Ov.Filter := OverlayFilter(Clip.Transform,
|
||||
Item.Info.Width, Item.Info.Height,
|
||||
AProject.Master.Width, AProject.Master.Height,
|
||||
Ov.X, Ov.Y, Ov.Width, Ov.Height);
|
||||
{ Порядок тот же, что при сборке: сперва вписали в кадр, потом
|
||||
выбили цвет, потом приглушили. Иначе смягчение края разошлось
|
||||
бы с тем, что даст готовый ролик. }
|
||||
if AWithKey then
|
||||
begin
|
||||
if Clip.ColorKey.Enabled then
|
||||
Ov.Filter := Ov.Filter + ',' + ColorKeyFilter(Clip.ColorKey.Color,
|
||||
Clip.ColorKey.Similarity, Clip.ColorKey.Blend);
|
||||
if OpacityFilter(Clip.Opacity) <> '' then
|
||||
Ov.Filter := Ov.Filter + ',' + OpacityFilter(Clip.Opacity);
|
||||
end;
|
||||
|
||||
SetLength(AOverlays, N + 1);
|
||||
AOverlays[N] := Ov;
|
||||
Inc(N);
|
||||
end;
|
||||
end;
|
||||
end;
|
||||
|
||||
|
|
|
|||
|
|
@ -226,11 +226,13 @@ 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.SampleRate, FMaster.Channels, FMaster.VideoBitrate);
|
||||
gkSilence:
|
||||
Args := ArgsSilenceClip(FRequests[I].TargetPath,
|
||||
FRequests[I].Duration, AudioCodec,
|
||||
|
|
@ -243,7 +245,7 @@ begin
|
|||
Args := ArgsRenderLayers(FRequests[I].Layers,
|
||||
FRequests[I].TargetPath, FRequests[I].Duration,
|
||||
FMaster.Width, FMaster.Height, FMaster.FpsNum, FMaster.FpsDen,
|
||||
FMaster.PixFmt, FMaster.VideoCodec, False);
|
||||
FMaster.PixFmt, FMaster.VideoCodec, False, FMaster.VideoBitrate);
|
||||
else
|
||||
Continue;
|
||||
end;
|
||||
|
|
|
|||
|
|
@ -32,8 +32,8 @@ const
|
|||
$60, $00, $00, $3A, $98, $00, $00, $17, $70, $9C, $BA, $51, $3C, $00, $00, $00,
|
||||
$02, $62, $4B, $47, $44, $00, $00, $AA, $8D, $23, $32, $00, $00, $00, $09, $70,
|
||||
$48, $59, $73, $00, $00, $0B, $13, $00, $00, $0B, $13, $01, $00, $9A, $9C, $18,
|
||||
$00, $00, $00, $07, $74, $49, $4D, $45, $07, $EA, $08, $1E, $15, $14, $31, $9E,
|
||||
$20, $4F, $DD, $00, $00, $01, $3E, $49, $44, $41, $54, $38, $CB, $BD, $92, $31,
|
||||
$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,
|
||||
|
|
@ -66,8 +66,8 @@ const
|
|||
$60, $00, $00, $3A, $98, $00, $00, $17, $70, $9C, $BA, $51, $3C, $00, $00, $00,
|
||||
$02, $62, $4B, $47, $44, $00, $00, $AA, $8D, $23, $32, $00, $00, $00, $09, $70,
|
||||
$48, $59, $73, $00, $00, $0B, $13, $00, $00, $0B, $13, $01, $00, $9A, $9C, $18,
|
||||
$00, $00, $00, $07, $74, $49, $4D, $45, $07, $EA, $08, $1E, $15, $14, $31, $9E,
|
||||
$20, $4F, $DD, $00, $00, $01, $2F, $49, $44, $41, $54, $38, $CB, $CD, $D2, $3D,
|
||||
$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,
|
||||
|
|
@ -99,8 +99,8 @@ const
|
|||
$60, $00, $00, $3A, $98, $00, $00, $17, $70, $9C, $BA, $51, $3C, $00, $00, $00,
|
||||
$02, $62, $4B, $47, $44, $00, $00, $AA, $8D, $23, $32, $00, $00, $00, $09, $70,
|
||||
$48, $59, $73, $00, $00, $0B, $13, $00, $00, $0B, $13, $01, $00, $9A, $9C, $18,
|
||||
$00, $00, $00, $07, $74, $49, $4D, $45, $07, $EA, $08, $1E, $15, $14, $31, $9E,
|
||||
$20, $4F, $DD, $00, $00, $01, $0F, $49, $44, $41, $54, $38, $CB, $B5, $D3, $BF,
|
||||
$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,
|
||||
|
|
@ -130,8 +130,8 @@ const
|
|||
$60, $00, $00, $3A, $98, $00, $00, $17, $70, $9C, $BA, $51, $3C, $00, $00, $00,
|
||||
$02, $62, $4B, $47, $44, $00, $00, $AA, $8D, $23, $32, $00, $00, $00, $09, $70,
|
||||
$48, $59, $73, $00, $00, $0B, $13, $00, $00, $0B, $13, $01, $00, $9A, $9C, $18,
|
||||
$00, $00, $00, $07, $74, $49, $4D, $45, $07, $EA, $08, $1E, $15, $14, $31, $9E,
|
||||
$20, $4F, $DD, $00, $00, $01, $67, $49, $44, $41, $54, $38, $CB, $B5, $D3, $3D,
|
||||
$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,
|
||||
|
|
@ -158,6 +158,59 @@ const
|
|||
$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,
|
||||
|
|
|
|||
|
|
@ -67,8 +67,19 @@ object MainForm: TMainForm
|
|||
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 = 356
|
||||
Left = 386
|
||||
Height = 28
|
||||
Top = 6
|
||||
Width = 28
|
||||
|
|
@ -78,7 +89,7 @@ object MainForm: TMainForm
|
|||
TabStop = False
|
||||
end
|
||||
object btnRippleMenu: TThemedButton
|
||||
Left = 386
|
||||
Left = 416
|
||||
Height = 28
|
||||
Top = 6
|
||||
Width = 22
|
||||
|
|
@ -87,10 +98,10 @@ object MainForm: TMainForm
|
|||
TabStop = False
|
||||
end
|
||||
object lblStatus: TLabel
|
||||
Left = 458
|
||||
Left = 488
|
||||
Height = 20
|
||||
Top = 10
|
||||
Width = 810
|
||||
Width = 780
|
||||
Anchors = [akTop, akLeft, akRight]
|
||||
AutoSize = False
|
||||
Caption = 'Готов.'
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -218,7 +218,10 @@ type
|
|||
|
||||
Держится отдельно от списка кусков: текст правится чаще, чем
|
||||
монтаж, и перезагружать из-за него картинку незачем. }
|
||||
procedure SetSubtitleFile(const APath: string);
|
||||
{ AForce — перечитать файл, даже если имя прежнее. Нужно всегда, когда
|
||||
разметка переписана: имя у неё одно на весь проект, и по имени
|
||||
изменение не видно. }
|
||||
procedure SetSubtitleFile(const APath: string; AForce: Boolean = False);
|
||||
end;
|
||||
|
||||
implementation
|
||||
|
|
@ -1096,9 +1099,9 @@ end;
|
|||
подключаем новые, не трогая воспроизведение. При загрузке нового списка
|
||||
кусков mpv забывает все внешние дорожки, поэтому там подключение
|
||||
повторяется — см. SelectExternalAudio. }
|
||||
procedure TMpvPlayer.SetSubtitleFile(const APath: string);
|
||||
procedure TMpvPlayer.SetSubtitleFile(const APath: string; AForce: Boolean);
|
||||
begin
|
||||
if FSubtitlePath = APath then
|
||||
if (FSubtitlePath = APath) and (not AForce) then
|
||||
Exit;
|
||||
FSubtitlePath := APath;
|
||||
if FHandle = nil then
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ type
|
|||
TProgressForm = class(TForm)
|
||||
btnAction: TThemedButton;
|
||||
lblStage: TLabel;
|
||||
lblEta: TLabel;
|
||||
memoLog: TLogView;
|
||||
pbBar: TProgressBar;
|
||||
pnlTop: TPanel;
|
||||
|
|
@ -36,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);
|
||||
|
|
@ -70,6 +76,9 @@ begin
|
|||
pbBar.Position := 0;
|
||||
memoLog.Clear;
|
||||
btnAction.Caption := 'Отмена';
|
||||
FStartedAt := Now;
|
||||
FLastFraction := 0;
|
||||
lblEta.Caption := '';
|
||||
end;
|
||||
|
||||
procedure TProgressForm.SetStage(const AText: string);
|
||||
|
|
@ -77,13 +86,55 @@ 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);
|
||||
|
|
@ -98,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
|
||||
|
|
|
|||
257
src/uProject.pas
257
src/uProject.pas
|
|
@ -143,6 +143,27 @@ type
|
|||
FColorKey: TColorKey;
|
||||
public
|
||||
constructor Create;
|
||||
{ Переносит на этот клип всё, что настроено у другого: громкость,
|
||||
кадрирование, прозрачность, ключ, затухания, текст.
|
||||
|
||||
Не переносится то, что делает клип этим клипом: имя, место на
|
||||
монтаже, кусок исходника, связка и выделение.
|
||||
|
||||
Зачем метод, а не переписывание полей на месте: копия клипа нужна в
|
||||
нескольких местах (рез — самое частое), и каждое новое свойство
|
||||
иначе приходится вспоминать в каждом из них. Один раз не вспомнили —
|
||||
и после реза половина клипа теряла цветовой ключ, а вместе с ним и
|
||||
прозрачность: она переставала быть наложением и закрывала собой всё,
|
||||
что ниже. }
|
||||
procedure CopySettingsFrom(ASource: TClip);
|
||||
{ Переносит только оформление: кадрирование, поворот, увеличение,
|
||||
смещение, прозрачность, ключ, затухания, громкость.
|
||||
|
||||
От CopySettingsFrom отличается тем, что не трогает вид клипа и его
|
||||
текст: свойства переносят между разными клипами, и превращать
|
||||
картинку в надпись при этом незачем. Оформление надписи переносится
|
||||
только между надписями. }
|
||||
procedure CopyLookFrom(ASource: TClip);
|
||||
function TlEnd: Double;
|
||||
function SrcOut: Double;
|
||||
{ Попадает ли момент внутрь клипа. Правый край не включается: там уже
|
||||
|
|
@ -236,6 +257,9 @@ type
|
|||
FTracks: TObjectList;
|
||||
FMaster: TMediaInfo;
|
||||
FRippleMode: TRippleMode;
|
||||
{ Отмеченный участок монтажа: от и до. Пока не отмечен, оба нуля. }
|
||||
FRegionStart: Double;
|
||||
FRegionEnd: Double;
|
||||
FRippleEnabled: Boolean;
|
||||
FFileName: string;
|
||||
FModified: Boolean;
|
||||
|
|
@ -266,6 +290,14 @@ type
|
|||
{ Номер новой связки. Отдельным именем, чтобы не пришлось помнить
|
||||
префикс в каждом месте, где связка заводится. }
|
||||
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;
|
||||
|
|
@ -348,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;
|
||||
|
|
@ -402,6 +466,35 @@ 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;
|
||||
|
|
@ -548,6 +641,8 @@ begin
|
|||
FMaster := Default(TMediaInfo);
|
||||
FRippleMode := rmCurrentTrack;
|
||||
FRippleEnabled := False;
|
||||
FRegionStart := 0;
|
||||
FRegionEnd := 0;
|
||||
FFileName := '';
|
||||
FModified := False;
|
||||
FNextId := 1;
|
||||
|
|
@ -652,6 +747,45 @@ 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);
|
||||
|
|
@ -934,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;
|
||||
|
|
@ -1434,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;
|
||||
|
|
@ -1551,12 +1803,15 @@ begin
|
|||
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -37,6 +37,9 @@ type
|
|||
FVideoPanelHeight: Integer;
|
||||
FLogVisible: Boolean;
|
||||
FPlayheadSnap: Boolean;
|
||||
FPreviewKey: Boolean;
|
||||
FCenterPlayhead: Boolean;
|
||||
FKeepParts: Boolean;
|
||||
FDepsReminder: Boolean;
|
||||
FRecentProjects: TStringList;
|
||||
function FileName: string;
|
||||
|
|
@ -70,6 +73,14 @@ type
|
|||
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;
|
||||
|
|
@ -103,6 +114,9 @@ begin
|
|||
FVideoPanelHeight := DefaultVideoHeight;
|
||||
FLogVisible := True;
|
||||
FPlayheadSnap := True;
|
||||
FPreviewKey := True;
|
||||
FCenterPlayhead := True;
|
||||
FKeepParts := False;
|
||||
FDepsReminder := True;
|
||||
end;
|
||||
|
||||
|
|
@ -184,6 +198,9 @@ begin
|
|||
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;
|
||||
|
|
@ -236,6 +253,9 @@ begin
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -79,9 +79,14 @@ function WriteAssForRange(AProject: TProject; const AFrom, ATo: Double;
|
|||
пересборки и подкладывает свою разметку.
|
||||
|
||||
Возвращает, сколько секунд придётся пересобрать сверх того, что и так
|
||||
пересобиралось: человеку это говорят до начала. }
|
||||
пересобиралось: человеку это говорят до начала.
|
||||
|
||||
AStartTime — время монтажа, которому соответствует начало списка.
|
||||
Нужен, когда собирают не весь ролик, а отмеченный участок: иначе текст
|
||||
брался бы с начала монтажа и лёг бы не на те куски. }
|
||||
function BurnTextIntoSegments(AProject: TProject;
|
||||
var ASegments: TFlatSegmentArray; const ACacheDir: string): Double;
|
||||
var ASegments: TFlatSegmentArray; const ACacheDir: string;
|
||||
const AStartTime: Double = 0): Double;
|
||||
|
||||
implementation
|
||||
|
||||
|
|
@ -479,7 +484,8 @@ begin
|
|||
end;
|
||||
|
||||
function BurnTextIntoSegments(AProject: TProject;
|
||||
var ASegments: TFlatSegmentArray; const ACacheDir: string): Double;
|
||||
var ASegments: TFlatSegmentArray; const ACacheDir: string;
|
||||
const AStartTime: Double): Double;
|
||||
var
|
||||
I: Integer;
|
||||
At, SegEnd: Double;
|
||||
|
|
@ -489,7 +495,7 @@ begin
|
|||
if (AProject = nil) or (not HasText(AProject)) then
|
||||
Exit;
|
||||
|
||||
At := 0;
|
||||
At := AStartTime;
|
||||
for I := 0 to High(ASegments) do
|
||||
begin
|
||||
SegEnd := At + ASegments[I].Duration;
|
||||
|
|
|
|||
|
|
@ -69,7 +69,8 @@ type
|
|||
tiSnap, // прилипание: края притягиваются к общей линии
|
||||
tiRipple, // схлопывание: дыра между кусками закрывается
|
||||
tiLog, // журнал: полоса с сообщениями
|
||||
tiGlue // курсор липнет к границам клипов
|
||||
tiGlue, // курсор липнет к границам клипов
|
||||
tiTarget // курсор держится середины монтажа
|
||||
);
|
||||
|
||||
{ Полоска выполнения, которая рисует себя сама.
|
||||
|
|
@ -233,6 +234,11 @@ function ThemedMessageDlg(const ACaption, AText: string;
|
|||
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}
|
||||
|
|
@ -453,6 +459,7 @@ begin
|
|||
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;
|
||||
|
|
@ -1370,6 +1377,58 @@ begin
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -38,11 +38,22 @@ type
|
|||
taPan, // прокрутка средней кнопкой
|
||||
taVolume, // тянем ручку громкости клипа
|
||||
taFadeIn, // тянем появление за верхний левый угол
|
||||
taFadeOut // тянем уход за верхний правый угол
|
||||
taFadeOut, // тянем уход за верхний правый угол
|
||||
taRegionIn, // тянем начало отмеченного участка
|
||||
taRegionOut, // тянем конец отмеченного участка
|
||||
taDragTrack // тянем дорожку за заголовок, меняя порядок
|
||||
);
|
||||
|
||||
{ Что находится под указателем в полосе заголовков дорожек. }
|
||||
THeaderPart = (hpNone, hpBody, hpMute, hpSolo);
|
||||
THeaderPart = (hpNone, hpBody, hpMute, hpSolo, hpUp, hpDown, hpName);
|
||||
|
||||
{ Правый щелчок по заголовку дорожки. Само меню строит окно: компонент
|
||||
не знает, что с дорожкой можно делать. }
|
||||
TTrackMenuEvent = procedure(Sender: TObject; ATrackIndex: Integer;
|
||||
const AScreenPos: TPoint) of object;
|
||||
|
||||
{ Дорожку просят переименовать: окно спросит новое имя. }
|
||||
TTrackRenameEvent = procedure(Sender: TObject; ATrackIndex: Integer) of object;
|
||||
|
||||
{ Файл из списка проекта бросили на монтаж.
|
||||
|
||||
|
|
@ -121,6 +132,14 @@ type
|
|||
FOnPlayheadChange: TNotifyEvent;
|
||||
FOnStructureChange: TNotifyEvent;
|
||||
FOnSelectionChange: TNotifyEvent;
|
||||
{ Отмеченный участок подвинули мышью. }
|
||||
FOnRegionChange: TNotifyEvent;
|
||||
{ Правый щелчок по заголовку дорожки: окно покажет своё меню. }
|
||||
FOnTrackMenu: TTrackMenuEvent;
|
||||
FOnTrackRename: TTrackRenameEvent;
|
||||
{ Дорожка, которую тащат за заголовок. }
|
||||
FDragTrackIndex: Integer;
|
||||
FDragTrackFrom: Integer;
|
||||
FOnMediaDrop: TMediaDropEvent;
|
||||
FOnClipTransform: TClipEvent;
|
||||
FOnTrackAdded: TNotifyEvent;
|
||||
|
|
@ -143,6 +162,13 @@ type
|
|||
function YToVolume(const AY: Integer; ATop: Integer): Double;
|
||||
|
||||
procedure DrawRuler(ACanvas: TCanvas);
|
||||
procedure DrawRegion(ACanvas: TCanvas);
|
||||
procedure DrawRegionHandle(ACanvas: TCanvas; AX: Integer;
|
||||
AIsStart: Boolean);
|
||||
procedure DrawTrackArrow(ACanvas: TCanvas; ALeft, ATop: Integer;
|
||||
AUp, AEnabled: Boolean);
|
||||
function RegionHandleAt(AX, AY: Integer): Integer;
|
||||
procedure DragRegionEdge(AX: Integer; AIsStart: Boolean);
|
||||
procedure DrawTracks(ACanvas: TCanvas);
|
||||
procedure DrawClip(ACanvas: TCanvas; AClip: TClip; ATop: Integer);
|
||||
procedure DrawPlayhead(ACanvas: TCanvas);
|
||||
|
|
@ -283,6 +309,12 @@ type
|
|||
write FOnStructureChange;
|
||||
property OnSelectionChange: TNotifyEvent read FOnSelectionChange
|
||||
write FOnSelectionChange;
|
||||
property OnRegionChange: TNotifyEvent read FOnRegionChange
|
||||
write FOnRegionChange;
|
||||
property OnTrackMenu: TTrackMenuEvent read FOnTrackMenu
|
||||
write FOnTrackMenu;
|
||||
property OnTrackRename: TTrackRenameEvent read FOnTrackRename
|
||||
write FOnTrackRename;
|
||||
property OnMediaDrop: TMediaDropEvent read FOnMediaDrop write FOnMediaDrop;
|
||||
{ Нажата кнопка кадрирования в углу клипа. }
|
||||
property OnClipTransform: TClipEvent read FOnClipTransform
|
||||
|
|
@ -301,6 +333,16 @@ const
|
|||
|
||||
{ На сколько пикселей примагничиваются края при перетаскивании. }
|
||||
SnapPixels = 8;
|
||||
{ Уголок отметки участка: сторона треугольника в точках. }
|
||||
RegionHandleWidth = 9;
|
||||
{ Стрелки порядка дорожек в заголовке. }
|
||||
ArrowSize = 14;
|
||||
ArrowLeft = 5;
|
||||
{ Полоса участка вверху линейки: в ней живут уголки, и протяжка по ней
|
||||
отмечает участок, а не двигает курсор. Так же устроено в монтажных
|
||||
программах: у линейки два этажа, верхний про отметки, нижний про
|
||||
курсор. }
|
||||
RegionBarHeight = 12;
|
||||
|
||||
{ Сдвиг мыши, до которого нажатие считается щелчком, а не перетаскиванием.
|
||||
Совсем без запаса нельзя: рука дрожит, и попасть кнопкой в один и тот же
|
||||
|
|
@ -351,6 +393,9 @@ const
|
|||
ColorClipBorder = TColor($5080AA); // светлая рамка клипа
|
||||
ColorClipText = TColor($F0F0F0);
|
||||
ColorPlayhead = TColor($4040FF); // красный: привычный цвет курсора
|
||||
ColorRegion = TColor($30A0D0); // отмеченный участок: янтарная полоса
|
||||
ColorRegionEdge = TColor($1878B0);
|
||||
ColorRegionHandle = TColor($40E0F0); // уголки отметок: жёлтые, как в Vegas
|
||||
ColorEdgeSnap = TColor($909090); // серый: рез притянут к опорному кадру
|
||||
ColorEdgePrecise = TColor($FFC030); // голубой: рез потребует перекодирования
|
||||
ColorWave = TColor($96C8D2); // пастельно-жёлтый: форма звука
|
||||
|
|
@ -389,6 +434,8 @@ begin
|
|||
FActiveTrack := 0;
|
||||
FSnapEnabled := True;
|
||||
FPlayheadSnap := True;
|
||||
FDragTrackIndex := -1;
|
||||
FDragTrackFrom := -1;
|
||||
end;
|
||||
|
||||
destructor TTimelineControl.Destroy;
|
||||
|
|
@ -513,6 +560,22 @@ begin
|
|||
|
||||
Result := hpBody;
|
||||
RowTop := TrackTop(ATrackIndex);
|
||||
|
||||
{ Стрелки порядка — слева, одна под другой: дорожку двигают вверх и
|
||||
вниз чаще, чем делают с ней что-либо ещё, и лезть за этим в меню
|
||||
неудобно. }
|
||||
if (AX >= ArrowLeft) and (AX < ArrowLeft + ArrowSize) then
|
||||
begin
|
||||
if (AY >= RowTop + 6) and (AY < RowTop + 6 + ArrowSize) then
|
||||
Exit(hpUp);
|
||||
if (AY >= RowTop + 8 + ArrowSize) and (AY < RowTop + 8 + 2 * ArrowSize) then
|
||||
Exit(hpDown);
|
||||
end;
|
||||
|
||||
{ Имя: по двойному щелчку его переименовывают. }
|
||||
if (AY >= RowTop + 4) and (AY < RowTop + 22) and (AX >= ArrowLeft + ArrowSize) then
|
||||
Exit(hpName);
|
||||
|
||||
BtnTop := RowTop + FTrackHeight - HeaderButtonSize - 6;
|
||||
if (AY < BtnTop) or (AY > BtnTop + HeaderButtonSize) then
|
||||
Exit;
|
||||
|
|
@ -704,9 +767,115 @@ begin
|
|||
T := T + Step;
|
||||
end;
|
||||
|
||||
{ Отмеченный участок: полоса по низу линейки и стойки по краям.
|
||||
|
||||
Рисуется после делений, чтобы не оказаться под ними, и только на
|
||||
линейке: закрашивать сами дорожки нельзя — под краской не разглядеть
|
||||
клипы, а участок отмечают как раз чтобы в них разбираться. }
|
||||
DrawRegion(ACanvas);
|
||||
|
||||
ACanvas.Brush.Style := bsSolid;
|
||||
end;
|
||||
|
||||
procedure TTimelineControl.DrawRegion(ACanvas: TCanvas);
|
||||
var
|
||||
L, R: Integer;
|
||||
begin
|
||||
if (FProject = nil) or (not FProject.HasRegion) then
|
||||
Exit;
|
||||
|
||||
L := Max(FHeaderWidth, TimeToX(FProject.RegionStart));
|
||||
R := Min(ClientWidth, TimeToX(FProject.RegionEnd));
|
||||
if R <= L then
|
||||
Exit;
|
||||
|
||||
ACanvas.Brush.Style := bsSolid;
|
||||
ACanvas.Brush.Color := ColorRegion;
|
||||
ACanvas.FillRect(L, FRulerHeight - 6, R, FRulerHeight - 1);
|
||||
|
||||
{ Стойки во всю высоту: край участка должен быть виден и там, где
|
||||
линейка уже уехала вверх за пределы видимого. }
|
||||
ACanvas.Pen.Color := ColorRegionEdge;
|
||||
if TimeToX(FProject.RegionStart) >= FHeaderWidth then
|
||||
ACanvas.Line(L, 0, L, ClientHeight);
|
||||
if TimeToX(FProject.RegionEnd) <= ClientWidth then
|
||||
ACanvas.Line(R - 1, 0, R - 1, ClientHeight);
|
||||
|
||||
{ Уголки, за которые тянут. Отметки должны не только показывать, где
|
||||
участок, но и позволять его двигать: клавишами это выходит долго —
|
||||
поставил, посмотрел, промахнулся, ставь заново. }
|
||||
DrawRegionHandle(ACanvas, TimeToX(FProject.RegionStart), True);
|
||||
DrawRegionHandle(ACanvas, TimeToX(FProject.RegionEnd), False);
|
||||
end;
|
||||
|
||||
{ Уголок отметки: прямоугольный треугольник, прижатый к своей стороне
|
||||
участка. Так видно, какой край он держит, — у симметричной стрелки это
|
||||
пришлось бы угадывать. }
|
||||
procedure TTimelineControl.DrawRegionHandle(ACanvas: TCanvas; AX: Integer;
|
||||
AIsStart: Boolean);
|
||||
var
|
||||
Pts: array[0..2] of TPoint;
|
||||
begin
|
||||
if (AX < FHeaderWidth - RegionHandleWidth) or
|
||||
(AX > ClientWidth + RegionHandleWidth) then
|
||||
Exit;
|
||||
|
||||
if AIsStart then
|
||||
begin
|
||||
Pts[0] := Point(AX, 0);
|
||||
Pts[1] := Point(AX + RegionHandleWidth, 0);
|
||||
Pts[2] := Point(AX, RegionHandleWidth);
|
||||
end
|
||||
else
|
||||
begin
|
||||
Pts[0] := Point(AX, 0);
|
||||
Pts[1] := Point(AX - RegionHandleWidth, 0);
|
||||
Pts[2] := Point(AX, RegionHandleWidth);
|
||||
end;
|
||||
|
||||
ACanvas.Brush.Style := bsSolid;
|
||||
ACanvas.Brush.Color := ColorRegionHandle;
|
||||
ACanvas.Pen.Color := ColorRegionEdge;
|
||||
ACanvas.Polygon(Pts);
|
||||
end;
|
||||
|
||||
{ Что под указателем на линейке: 1 — уголок начала, 2 — уголок конца. }
|
||||
{ Двигает край участка за уголком.
|
||||
|
||||
Край прилипает к границам клипов тем же способом, что и курсор: участок
|
||||
чаще всего отмечают по кускам, а попасть мышью в стык на общем плане
|
||||
нельзя. Края не пропускаются друг за друга: перевёрнутый участок — это
|
||||
не участок. }
|
||||
procedure TTimelineControl.DragRegionEdge(AX: Integer; AIsStart: Boolean);
|
||||
var
|
||||
T: Double;
|
||||
begin
|
||||
if FProject = nil then
|
||||
Exit;
|
||||
T := Max(0, SnapPlayhead(XToTime(AX)));
|
||||
if AIsStart then
|
||||
FProject.RegionStart := Min(T, FProject.RegionEnd - 0.001)
|
||||
else
|
||||
FProject.RegionEnd := Max(T, FProject.RegionStart + 0.001);
|
||||
Invalidate;
|
||||
if Assigned(FOnRegionChange) then
|
||||
FOnRegionChange(Self);
|
||||
end;
|
||||
|
||||
function TTimelineControl.RegionHandleAt(AX, AY: Integer): Integer;
|
||||
begin
|
||||
Result := 0;
|
||||
if (FProject = nil) or (not FProject.HasRegion) then
|
||||
Exit;
|
||||
if (AY < 0) or (AY > RegionHandleWidth + 2) then
|
||||
Exit;
|
||||
|
||||
if Abs(AX - TimeToX(FProject.RegionStart)) <= RegionHandleWidth then
|
||||
Exit(1);
|
||||
if Abs(AX - TimeToX(FProject.RegionEnd)) <= RegionHandleWidth then
|
||||
Exit(2);
|
||||
end;
|
||||
|
||||
procedure TTimelineControl.DrawClip(ACanvas: TCanvas; AClip: TClip;
|
||||
ATop: Integer);
|
||||
var
|
||||
|
|
@ -1298,18 +1467,25 @@ begin
|
|||
ACanvas.Font.Color := clGray;
|
||||
ACanvas.Font.Height := -12;
|
||||
ACanvas.Font.Style := [fsBold];
|
||||
ACanvas.TextOut(8, RowTop + 6, Track.Name);
|
||||
ACanvas.TextOut(ArrowLeft + ArrowSize + 6, RowTop + 6, Track.Name);
|
||||
ACanvas.Font.Style := [];
|
||||
|
||||
ACanvas.Font.Color := clGray;
|
||||
ACanvas.Font.Height := -10;
|
||||
if T = 0 then
|
||||
ACanvas.TextOut(8, RowTop + 24,
|
||||
ACanvas.TextOut(ArrowLeft + ArrowSize + 6, RowTop + 24,
|
||||
IntToStr(Track.Count) + ' кл. верх')
|
||||
else
|
||||
ACanvas.TextOut(8, RowTop + 24, IntToStr(Track.Count) + ' кл.');
|
||||
ACanvas.TextOut(ArrowLeft + ArrowSize + 6, RowTop + 24,
|
||||
IntToStr(Track.Count) + ' кл.');
|
||||
ACanvas.Brush.Style := bsSolid;
|
||||
|
||||
{ Стрелки порядка. У верхней дорожки некуда вверх, у нижней вниз —
|
||||
такая стрелка рисуется тускло и не нажимается. }
|
||||
DrawTrackArrow(ACanvas, ArrowLeft, RowTop + 6, True, T > 0);
|
||||
DrawTrackArrow(ACanvas, ArrowLeft, RowTop + 8 + ArrowSize, False,
|
||||
T < FProject.TrackCount - 1);
|
||||
|
||||
BtnTop := RowTop + FTrackHeight - HeaderButtonSize - 6;
|
||||
BtnLeft := FHeaderWidth - 2 * HeaderButtonSize - HeaderButtonGap - 8;
|
||||
{ М — выключить дорожку целиком, С — слушать только её. }
|
||||
|
|
@ -1321,6 +1497,36 @@ begin
|
|||
ACanvas.Brush.Style := bsSolid;
|
||||
end;
|
||||
|
||||
{ Треугольник порядка дорожек. }
|
||||
procedure TTimelineControl.DrawTrackArrow(ACanvas: TCanvas;
|
||||
ALeft, ATop: Integer; AUp, AEnabled: Boolean);
|
||||
var
|
||||
Pts: array[0..2] of TPoint;
|
||||
Mid: Integer;
|
||||
begin
|
||||
Mid := ALeft + ArrowSize div 2;
|
||||
if AUp then
|
||||
begin
|
||||
Pts[0] := Point(Mid, ATop + 2);
|
||||
Pts[1] := Point(ALeft + 1, ATop + ArrowSize - 3);
|
||||
Pts[2] := Point(ALeft + ArrowSize - 1, ATop + ArrowSize - 3);
|
||||
end
|
||||
else
|
||||
begin
|
||||
Pts[0] := Point(Mid, ATop + ArrowSize - 3);
|
||||
Pts[1] := Point(ALeft + 1, ATop + 2);
|
||||
Pts[2] := Point(ALeft + ArrowSize - 1, ATop + 2);
|
||||
end;
|
||||
|
||||
ACanvas.Brush.Style := bsSolid;
|
||||
if AEnabled then
|
||||
ACanvas.Brush.Color := ColorRulerText
|
||||
else
|
||||
ACanvas.Brush.Color := ColorButtonOff;
|
||||
ACanvas.Pen.Color := ACanvas.Brush.Color;
|
||||
ACanvas.Polygon(Pts);
|
||||
end;
|
||||
|
||||
procedure TTimelineControl.DrawPlayhead(ACanvas: TCanvas);
|
||||
var
|
||||
X: Integer;
|
||||
|
|
@ -1616,6 +1822,21 @@ begin
|
|||
Exit;
|
||||
end;
|
||||
|
||||
{ Правый щелчок по заголовку — меню дорожки. Удалять и переставлять
|
||||
дорожки больше негде: в главном меню есть только «добавить» и «убрать
|
||||
последнюю», а убрать нужно бывает не последнюю. }
|
||||
if (Button = mbRight) and (X < FHeaderWidth) and (Y >= FRulerHeight) then
|
||||
begin
|
||||
Idx := TrackIndexAt(Y);
|
||||
if (Idx >= 0) and Assigned(FOnTrackMenu) then
|
||||
begin
|
||||
FActiveTrack := Idx;
|
||||
Invalidate;
|
||||
FOnTrackMenu(Self, Idx, ClientToScreen(Point(X, Y)));
|
||||
end;
|
||||
Exit;
|
||||
end;
|
||||
|
||||
if Button <> mbLeft then
|
||||
Exit;
|
||||
|
||||
|
|
@ -1638,9 +1859,39 @@ begin
|
|||
FProject.Tracks[Idx].Solo := not FProject.Tracks[Idx].Solo;
|
||||
StructureChanged;
|
||||
end;
|
||||
hpUp:
|
||||
if Idx > 0 then
|
||||
begin
|
||||
FProject.MoveTrack(Idx, Idx - 1);
|
||||
FActiveTrack := Idx - 1;
|
||||
FProject.Modified := True;
|
||||
StructureChanged;
|
||||
end;
|
||||
hpDown:
|
||||
if Idx < FProject.TrackCount - 1 then
|
||||
begin
|
||||
FProject.MoveTrack(Idx, Idx + 1);
|
||||
FActiveTrack := Idx + 1;
|
||||
FProject.Modified := True;
|
||||
StructureChanged;
|
||||
end;
|
||||
hpName:
|
||||
begin
|
||||
FActiveTrack := Idx;
|
||||
Invalidate;
|
||||
{ Двойной щелчок по имени — переименование. Одиночный просто
|
||||
выбирает дорожку: имя правят редко, а выбирают часто. }
|
||||
if (ssDouble in Shift) and Assigned(FOnTrackRename) then
|
||||
FOnTrackRename(Self, Idx);
|
||||
end;
|
||||
hpBody:
|
||||
begin
|
||||
FActiveTrack := Idx;
|
||||
{ Заголовок можно потащить: порядок дорожек — это приоритет, и
|
||||
менять его перетаскиванием привычнее, чем через меню. }
|
||||
FAction := taDragTrack;
|
||||
FDragTrackIndex := Idx;
|
||||
FDragTrackFrom := Idx;
|
||||
Invalidate;
|
||||
if Assigned(FOnActiveTrackChange) then
|
||||
FOnActiveTrackChange(Self);
|
||||
|
|
@ -1649,9 +1900,28 @@ begin
|
|||
Exit;
|
||||
end;
|
||||
|
||||
{ Щелчок по линейке ставит курсор воспроизведения. }
|
||||
{ Щелчок по линейке: сперва уголки отмеченного участка, потом курсор.
|
||||
|
||||
Приоритет у уголков: они мелкие, промахнуться мимо них легко, а
|
||||
поставить курсор можно где угодно рядом. }
|
||||
if Y < FRulerHeight then
|
||||
begin
|
||||
case RegionHandleAt(X, Y) of
|
||||
1: begin FAction := taRegionIn; Exit; end;
|
||||
2: begin FAction := taRegionOut; Exit; end;
|
||||
end;
|
||||
|
||||
{ Верхний этаж линейки — про участок: протяжка по нему отмечает его
|
||||
заново, даже если отметок не было. Нижний — про курсор. }
|
||||
if (Y < RegionBarHeight) and (FProject <> nil) then
|
||||
begin
|
||||
FAction := taRegionOut;
|
||||
FProject.RegionStart := Max(0, SnapPlayhead(XToTime(X)));
|
||||
FProject.RegionEnd := FProject.RegionStart;
|
||||
Invalidate;
|
||||
Exit;
|
||||
end;
|
||||
|
||||
FAction := taScrub;
|
||||
Playhead := Max(0, SnapPlayhead(XToTime(X)));
|
||||
Exit;
|
||||
|
|
@ -1834,6 +2104,25 @@ begin
|
|||
на стык, соскакивал бы с него от малейшего движения мыши. }
|
||||
Playhead := Max(0, SnapPlayhead(XToTime(X)));
|
||||
|
||||
taDragTrack:
|
||||
if (FProject <> nil) and (FDragTrackIndex >= 0) then
|
||||
begin
|
||||
Idx := TrackIndexAt(Y);
|
||||
if (Idx >= 0) and (Idx <> FDragTrackIndex) then
|
||||
begin
|
||||
FProject.MoveTrack(FDragTrackIndex, Idx);
|
||||
FDragTrackIndex := Idx;
|
||||
FActiveTrack := Idx;
|
||||
Invalidate;
|
||||
end;
|
||||
end;
|
||||
|
||||
taRegionIn:
|
||||
DragRegionEdge(X, True);
|
||||
|
||||
taRegionOut:
|
||||
DragRegionEdge(X, False);
|
||||
|
||||
taVolume:
|
||||
if FDragClip <> nil then
|
||||
begin
|
||||
|
|
@ -1878,12 +2167,17 @@ begin
|
|||
|
||||
{ Утащили ниже последней дорожки — заводим новую, как в Vegas.
|
||||
За одно перетаскивание не больше одной: иначе движение мыши
|
||||
вниз наплодило бы их десяток. }
|
||||
вниз наплодило бы их десяток. Вид новой дорожки — по клипу:
|
||||
картинку на звуковую дорожку класть незачем. }
|
||||
if (Idx < 0) and (FProject <> nil) and (Y > TracksBottom) and
|
||||
(not FTrackAddedDuringDrag) then
|
||||
begin
|
||||
FProject.AddTrack('V' +
|
||||
IntToStr(FProject.TrackCountOf(tkVideo) + 1));
|
||||
case TProject.TrackKindFor(FDragClip.Kind) of
|
||||
tkAudio: FProject.AddTrackOfKind(FProject.UniqueTrackName('A'), tkAudio);
|
||||
tkText: FProject.AddTrackOfKind(FProject.UniqueTrackName('Т'), tkText);
|
||||
else
|
||||
FProject.AddTrack(FProject.UniqueTrackName('V'));
|
||||
end;
|
||||
FTrackAddedDuringDrag := True;
|
||||
Idx := FProject.TrackCount - 1;
|
||||
if Assigned(FOnTrackAdded) then
|
||||
|
|
@ -1891,11 +2185,18 @@ begin
|
|||
end;
|
||||
|
||||
{ Перенос на другую дорожку: клип следует за указателем по
|
||||
вертикали. Именно перенос, а не копирование. }
|
||||
вертикали. Именно перенос, а не копирование.
|
||||
|
||||
На дорожку чужого вида клип не пускаем. Уплощение обходит
|
||||
дорожки по видам, и клип, попавший не на свою, пропадает и из
|
||||
сборки, и из предпросмотра — молча: на монтаже он нарисован, а
|
||||
в ролике его нет. Проверено на живом проекте, где фотография
|
||||
оказалась на текстовой дорожке. }
|
||||
if (Idx >= 0) and (FProject <> nil) then
|
||||
begin
|
||||
TargetTrack := FProject.Tracks[Idx];
|
||||
if TargetTrack <> FDragTrack then
|
||||
if (TargetTrack <> FDragTrack) and
|
||||
(TargetTrack.Kind = TProject.TrackKindFor(FDragClip.Kind)) then
|
||||
begin
|
||||
FDragTrack.Extract(FDragClip);
|
||||
TargetTrack.Add(FDragClip);
|
||||
|
|
@ -2097,6 +2398,37 @@ begin
|
|||
{ Shift читается и здесь: курсор по щелчку ставится при отпускании, и
|
||||
решение о прилипании должно быть по клавише, которую держат сейчас. }
|
||||
FSnapSuspended := ssShift in Shift;
|
||||
{ Дорожку переставили — предпросмотр надо пересобрать: порядок дорожек
|
||||
и есть порядок перекрытия. Сообщаем один раз, в конце: во время
|
||||
перетаскивания дорожка проходит через все промежуточные места. }
|
||||
if FAction = taDragTrack then
|
||||
begin
|
||||
FAction := taNone;
|
||||
if (FProject <> nil) and (FDragTrackIndex <> FDragTrackFrom) then
|
||||
begin
|
||||
FProject.Modified := True;
|
||||
StructureChanged;
|
||||
end;
|
||||
FDragTrackIndex := -1;
|
||||
Exit;
|
||||
end;
|
||||
|
||||
{ Отметка участка протяжкой: щелчок без движения снимает отметки — это
|
||||
и есть привычный способ от них избавиться. }
|
||||
if (FAction in [taRegionIn, taRegionOut]) and (FProject <> nil) then
|
||||
begin
|
||||
FAction := taNone;
|
||||
if not FProject.HasRegion then
|
||||
begin
|
||||
FProject.RegionStart := 0;
|
||||
FProject.RegionEnd := 0;
|
||||
end;
|
||||
if Assigned(FOnRegionChange) then
|
||||
FOnRegionChange(Self);
|
||||
Invalidate;
|
||||
Exit;
|
||||
end;
|
||||
|
||||
WasEditing := FAction in [taDragClip, taTrimIn, taTrimOut, taVolume,
|
||||
taFadeIn, taFadeOut];
|
||||
WasTrimIn := FAction = taTrimIn;
|
||||
|
|
@ -2232,19 +2564,17 @@ begin
|
|||
Tail := TClip.Create;
|
||||
Tail.Id := Clip.Id + '_b';
|
||||
Tail.MediaId := Clip.MediaId;
|
||||
Tail.Kind := Clip.Kind;
|
||||
Tail.SrcIn := Clip.SrcIn + Offset;
|
||||
Tail.TlStart := FPlayhead;
|
||||
Tail.Duration := Clip.Duration - Offset;
|
||||
Tail.EdgeOut := Clip.EdgeOut;
|
||||
{ Хвост — тот же клип, только вторая его половина. Всё, что было
|
||||
настроено, должно перейти к нему: иначе после реза половина клипа
|
||||
теряет громкость и кадрирование. }
|
||||
Tail.Volume := Clip.Volume;
|
||||
Tail.Opacity := Clip.Opacity;
|
||||
{ Хвост — тот же клип, только вторая его половина. Всё настроенное
|
||||
переходит к нему одним махом: перечислять поля по одному значит
|
||||
однажды забыть новое. }
|
||||
Tail.CopySettingsFrom(Clip);
|
||||
{ Кроме появления: оно относится к началу клипа, а начало осталось у
|
||||
первой половины. }
|
||||
Tail.FadeIn := 0;
|
||||
Tail.FadeOut := Clip.FadeOut;
|
||||
Tail.Transform := Clip.Transform;
|
||||
|
||||
{ Связка хвоста — новая.
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ unit uTransform;
|
|||
interface
|
||||
|
||||
uses
|
||||
Classes, SysUtils;
|
||||
Classes, SysUtils, uAppTypes;
|
||||
|
||||
type
|
||||
{ Как соотносятся кадр исходника и кадр проекта. }
|
||||
|
|
@ -82,6 +82,16 @@ 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;
|
||||
|
|
@ -91,7 +101,7 @@ function FitModeCaption(AMode: TFitMode): string;
|
|||
implementation
|
||||
|
||||
uses
|
||||
Math, uAppTypes;
|
||||
Math;
|
||||
|
||||
function DefaultTransform: TMediaTransform;
|
||||
begin
|
||||
|
|
@ -143,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;
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ uses
|
|||
{ Interfaces нужен, чтобы связалась вся LCL и заработала загрузка
|
||||
файлов форм. }
|
||||
Interfaces, Forms, Classes, SysUtils, Math, TypInfo, uTheme,
|
||||
uMediaProbe, uProject, uTimelineCtrl,
|
||||
uMediaProbe, uProject, uTransform, uTimelineCtrl,
|
||||
uPanCropForm, uDepsForm, uMismatchForm, uProgressForm, uProjectInfoForm,
|
||||
uRemuxForm;
|
||||
|
||||
|
|
@ -153,6 +153,100 @@ begin
|
|||
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;
|
||||
|
|
@ -180,6 +274,7 @@ begin
|
|||
CheckForm(TRemuxForm);
|
||||
|
||||
CheckPlayheadSnap;
|
||||
CheckSplitKeepsSettings;
|
||||
|
||||
WriteLn;
|
||||
if Failed = 0 then
|
||||
|
|
|
|||
|
|
@ -781,6 +781,138 @@ begin
|
|||
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
|
||||
|
|
@ -1239,8 +1371,23 @@ begin
|
|||
end;
|
||||
end;
|
||||
|
||||
{ Вжигание текста — последним, уже по окончательному списку кусков:
|
||||
повторное уплощение стёрло бы отметки о разметке. Так же устроено и
|
||||
окно сборки. }
|
||||
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)), True, Proj.Master, Default(TEncodeProfile));
|
||||
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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue