Compare commits
48 commits
74baa8dd5a
...
82929a8d5b
| Author | SHA1 | Date | |
|---|---|---|---|
| 82929a8d5b | |||
| 1ed479739d | |||
| 45777bbdbb | |||
| a28955fa2e | |||
| 0bbff345ba | |||
| 59c778d2f6 | |||
| 07aee86631 | |||
| 96672341d2 | |||
| 71381bb774 | |||
| d9bbffcaac | |||
| 5bd4e822d9 | |||
| d098f1621e | |||
| 4c422ef57d | |||
| fb6b60bc3f | |||
| acb921d5f7 | |||
| 1e28dd098b | |||
| 36ccbd3015 | |||
| 62ba75ff2f | |||
| d55b624560 | |||
| e4a77d1a72 | |||
| b792b81a00 | |||
| e12d0df974 | |||
| 83c56d334f | |||
| 58d7748d51 | |||
| 41ea1b80de | |||
| 9d6e5580a1 | |||
| fd34e71f49 | |||
| 1e00cd9f44 | |||
| 80e6585134 | |||
| 36499edd50 | |||
| 1e6da9b592 | |||
| ad88683e8b | |||
| 11075d6b52 | |||
| bc7418ac35 | |||
| 71e9292885 | |||
| 53a90f0d4e | |||
|
|
e09e61103d | ||
|
|
f9e50632aa | ||
|
|
40a0e2041f | ||
|
|
3583f74864 | ||
|
|
79fe091fbb | ||
|
|
d0dc22630e | ||
|
|
cf20a0164e | ||
|
|
3acf79d174 | ||
|
|
b6e215a03f | ||
|
|
a06ecfd720 | ||
|
|
b7b41f7cf0 | ||
|
|
f1ef6e624a |
47
.gitignore
vendored
|
|
@ -1,7 +1,11 @@
|
|||
# Результаты сборки
|
||||
/lib/
|
||||
# Обёртка для macOS: её создаёт lazbuild при сборке под darwin.
|
||||
/nlve.app/
|
||||
/nlve
|
||||
/nlve.exe
|
||||
/nlve_intel
|
||||
/nlve_arm
|
||||
/nlve.dbg
|
||||
/nlve.res
|
||||
/tests/lib/
|
||||
|
|
@ -42,3 +46,46 @@ shot*.png
|
|||
|
||||
# Журнал последнего запуска
|
||||
/nlve.log
|
||||
|
||||
# Проверка свойств из файлов форм.
|
||||
/tests/lfmcheck/lfmcheck
|
||||
/tests/lfmcheck/units/
|
||||
|
||||
# Проверка хода полоски: двоичный файл и промежуточные модули.
|
||||
/tests/progress/progtest
|
||||
/tests/progress/*.o
|
||||
/tests/progress/*.ppu
|
||||
|
||||
# Сборка пробников. Сами исходники нужны: они и есть запись замера.
|
||||
/probe/*/units/
|
||||
/probe/menu/menuprobe
|
||||
/probe/menu/menuprobe.exe
|
||||
|
||||
# --- Тому, что ниже, в репозитории не место ---
|
||||
|
||||
# Готовые ролики и журналы сборки, которые ложатся рядом с ними.
|
||||
*.mkv
|
||||
*.mp4
|
||||
*.log
|
||||
|
||||
# Скачанные зависимости и подопытная сборка под Windows: сотни мегабайт,
|
||||
# у каждого свои и добываются меню «Зависимости».
|
||||
/test/
|
||||
|
||||
# Отладочные символы macOS: их делает компоновщик.
|
||||
/nlve.dSYM/
|
||||
/nlve.app/
|
||||
|
||||
# Пробники замеров — отдельные программки, к самому редактору отношения
|
||||
# не имеют.
|
||||
/probe/
|
||||
|
||||
# Рабочие бумаги: планы, логи сессий, изначальная постановка. Нужны в
|
||||
# работе, но это не исходники.
|
||||
/docs/plans/
|
||||
/docs/session-logs/
|
||||
/IMPLEMENTATION_PLAN.md
|
||||
/изначальная постановка.txt
|
||||
|
||||
# Мусор компоновщика Lazarus.
|
||||
link*.res
|
||||
|
|
|
|||
142
BACKLOG.md
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
# Что отложено
|
||||
|
||||
Список того, что известно и сознательно не сделано. Не «когда-нибудь
|
||||
починим», а «знаем, чего это стоит, и решили пока не платить».
|
||||
|
||||
## macOS: собирается, но работает наполовину
|
||||
|
||||
Сборка проверена. В тулчейне fpcupdeluxe уже стоит всё нужное:
|
||||
кросс-компиляторы, Apple SDK (10.13, 11.0, 12.3) и виджетсет cocoa для
|
||||
обеих архитектур. Обе цели собираются с первого раза:
|
||||
|
||||
./build.sh macos → nlve_intel (Mach-O x86_64)
|
||||
nlve_arm (Mach-O arm64)
|
||||
|
||||
Чего не будет, пока не сделано:
|
||||
|
||||
**Предпросмотра нет.** Встраивание mpv написано под два случая: X11 (под
|
||||
gtk2 создаётся своё дочернее окно и его номер отдаётся mpv в `wid`) и
|
||||
Windows (там у панели есть настоящий HWND). Под cocoa нужен третий путь —
|
||||
отдать mpv указатель на NSView. Это не правка на десять строк: нужно
|
||||
разобраться, как LCL под cocoa отдаёт NSView панели, и проверить, что mpv
|
||||
рисует именно в неё.
|
||||
|
||||
**Окно зависимостей врёт.** Списки в `uDeps.pas` разделены на два случая:
|
||||
Windows и всё остальное. Мак попадает во второй и получает совет
|
||||
`sudo apt install ffmpeg`. Нужен свой список: `brew install ffmpeg mpv`
|
||||
или готовые сборки.
|
||||
|
||||
**Проверить нечем.** У нас нет мака. Собранный, но ни разу не запущенный
|
||||
файл — это предположение, а не сборка. Всё, что можно утверждать сейчас:
|
||||
компилятор и компоновщик отработали без ошибок.
|
||||
|
||||
**Двух файлов может оказаться мало.** Intel и Apple Silicon сейчас
|
||||
раздаются отдельно. Склеить их в один универсальный умеет `lipo`, но в
|
||||
тулчейне он есть только для powerpc — для x86_64 и arm64 его надо
|
||||
доставить.
|
||||
|
||||
**Обёртка .app не настроена.** lazbuild её создаёт, но внутри лежит
|
||||
ссылка на файл с именем `nlve`, которого после переименования нет.
|
||||
Сейчас она просто удаляется. Настоящему приложению нужны свой Info.plist,
|
||||
значок и, для раздачи, подпись и нотаризация у Apple.
|
||||
|
||||
## Лицензия не выбрана
|
||||
|
||||
libmpv грузится в наш процесс, а её сборки — GPL (в Ubuntu GPL-3+ из-за
|
||||
libsmbclient, у shinchiro тоже GPL). По толкованию FSF это делает
|
||||
совместную работу производной, и при раздаче программа должна быть под
|
||||
GPL-3+ или совместимой.
|
||||
|
||||
Пока ничего не раздаётся — вопрос не стоит. Решать его перед первой
|
||||
раздачей сборок. Возможный обход: отказаться от libmpv в пользу своего
|
||||
вывода кадров через ffmpeg, но это большая работа и заметно хуже по
|
||||
плавности.
|
||||
|
||||
ffmpeg проблемой не является: он запускается отдельной программой.
|
||||
|
||||
## Настоящая прозрачность в исходном файле
|
||||
|
||||
Цветовой ключ выбивает цвет, но бывает и прозрачность, записанная в самом
|
||||
файле: WebM с VP8/VP9, ProRes 4444, QuickTime Animation. ffmpeg их
|
||||
распаковывает в кадр с альфа-каналом, и ключ таким файлам не нужен —
|
||||
достаточно не выбрасывать альфу по дороге.
|
||||
|
||||
Что для этого потребуется, когда дойдут руки:
|
||||
|
||||
- при добавлении файла запоминать, есть ли у него альфа (`pix_fmt` с
|
||||
буквой «a»: yuva420p, yuva444p10le, argb);
|
||||
- такой клип считать несплошным так же, как клип с ключом, — то есть
|
||||
собирать участок слоями;
|
||||
- в цепочке фильтров следить, чтобы альфа не терялась: `scale` её
|
||||
сохраняет, а `pad` — только с прозрачным цветом полей;
|
||||
- аппаратное декодирование к таким файлам не применять: альфа в VP9
|
||||
лежит отдельным потоком, и видеокарта его не отдаёт.
|
||||
|
||||
Чего не будет в любом случае: раздать готовый ролик с прозрачностью в
|
||||
MP4/H.264 нельзя — контейнер и кодек альфу не хранят. Для этого нужен
|
||||
отдельный вывод в VP9 или ProRes 4444.
|
||||
|
||||
## Предпросмотр не показывает кадрирование обычного клипа
|
||||
|
||||
Кадрирование и поворот видны в окне только у клипов с ключом или
|
||||
прозрачностью: их плеер кладёт поверх основы сам и применяет к ним
|
||||
цепочку фильтров. Обычный клип идёт списком кусков, а список фильтровать
|
||||
нечем — он играется как есть, во весь кадр. В готовом ролике при этом всё
|
||||
верно: такой кусок помечен на пересборку и обкладывается полями.
|
||||
|
||||
Проверено опытом: у клипа выключен только ключ, кадрирование оставлено —
|
||||
окно показывает его во весь кадр.
|
||||
|
||||
Как это делается тем же механизмом, что и наложение: провести такой клип
|
||||
через ту же дорожку наложения, а под ним заставить основу быть чёрной —
|
||||
непрозрачный клип с полями в готовом ролике именно так и закрывает всё,
|
||||
что ниже. Значит, уплощению нужно понятие «здесь дыра, даже если ниже
|
||||
что-то есть».
|
||||
|
||||
Чего это стоит: ветка масштабирования в цепочке работает на каждом кадре,
|
||||
а не только там, где клип виден. Для накладки в 512 точек это пустяк, для
|
||||
куска 2688x1512 — уже нет. Поэтому сначала замер, потом решение.
|
||||
|
||||
## Окно настроек программы
|
||||
|
||||
Переключателей, которые стоят машинного времени, становится больше, а
|
||||
живут они кто где: прилипание и схлопывание — кнопками внизу, ключ в
|
||||
предпросмотре — в меню «Вид», выбор кодировщика вообще нигде (берётся
|
||||
NVENC, если он есть). Это разрозненно и не даёт увидеть общую цену.
|
||||
|
||||
Нужно одно окно, где собрано всё, что можно выключить ради скорости:
|
||||
|
||||
- ключ и прозрачность в предпросмотре (сейчас в меню «Вид»);
|
||||
- наложение в предпросмотре вообще — на слабой машине может не хватать и
|
||||
его, тогда честнее показывать одну основу;
|
||||
- кодировщик для пересборки: аппаратный или процессорный, и какой
|
||||
именно;
|
||||
- где держать кэш и когда его убирать;
|
||||
- прилипание клипов и курсора (сейчас кнопками, но им место и здесь).
|
||||
|
||||
Почему это важно: разработка идёт на машине с быстрой видеокартой, и
|
||||
всё, что здесь считается «недорогим», на другой может оказаться
|
||||
неподъёмным. Пока цифры замеряны только на одной машине, у человека
|
||||
должна быть возможность выключить любую из этих трат.
|
||||
|
||||
## Мелочи, до которых не дошли руки
|
||||
|
||||
**Вид дорожки не виден в её заголовке.** Дорожки картинки, звука и текста
|
||||
подписаны V1, A1, Т1, и по букве вид угадывается, но не более того.
|
||||
Клип, попавший на дорожку чужого вида, теперь не принимается и не
|
||||
теряется, но само различие стоило бы показать явно — значком или цветом
|
||||
полосы заголовка.
|
||||
|
||||
**Значок файла программы под Windows.** Значок окна задаётся из
|
||||
`uIconData`, а значок самого `.exe` в проводнике берётся из ресурса при
|
||||
сборке — там он до сих пор стандартный.
|
||||
|
||||
**Кэш наложений не убирается.** Каждая правка наплыва создаёт в кэше
|
||||
проекта новый файл; старые остаются лежать. Клипы с ключом и
|
||||
прозрачностью сюда больше не попадают — их показывает сам плеер, — так
|
||||
что расти кэшу теперь почти не с чего.
|
||||
|
||||
**Окно кадрирования открывается около четырёх секунд.** Оно синхронно
|
||||
достаёт два кадра ffmpeg-ом (по 2.2 с каждый на шестигигабайтном файле) и
|
||||
только потом показывается. Лечится показом окна сразу и дорисовкой кадра
|
||||
по готовности, либо одним кадром вместо двух.
|
||||
239
README.md
|
|
@ -46,6 +46,30 @@ Lazarus другой версии, и собранные им модули LCL
|
|||
`./nlve remux <файл>...` — в коде этого нет и не было: слово `remux` не
|
||||
является именем файла и молча пропускалось.
|
||||
|
||||
### Встраивание в рабочий стол
|
||||
|
||||
```
|
||||
tools/install-desktop.sh # берёт ./nlve рядом с репозиторием
|
||||
tools/install-desktop.sh /путь/к/nlve
|
||||
```
|
||||
|
||||
Скрипт кладёт пункт меню, значки и описание типа файла — всё в домашний
|
||||
каталог, без прав администратора. После этого `*.nlve` перестают быть
|
||||
просто текстом: у них своё имя типа (`application/x-nlve-project`), свой
|
||||
значок и открытие двойным щелчком. Проверяется так:
|
||||
|
||||
```
|
||||
xdg-mime query filetype проект.nlve # application/x-nlve-project
|
||||
xdg-mime query default application/x-nlve-project
|
||||
```
|
||||
|
||||
Внутри проект — обычный JSON, и тип объявлен наследником
|
||||
`application/json`. Наследником простого текста делать не стали: тогда
|
||||
файл снова открывался бы редактором по умолчанию.
|
||||
|
||||
Файловый менеджер может показать новый значок не сразу — ему нужно
|
||||
перечитать базу.
|
||||
|
||||
## Управление
|
||||
|
||||
| Действие | Клавиши |
|
||||
|
|
@ -62,9 +86,11 @@ Lazarus другой версии, и собранные им модули LCL
|
|||
| Прокрутка по дорожкам | колесо мыши |
|
||||
| Прокрутка по времени | Shift + колесо, средняя кнопка |
|
||||
| Прокрутка журнала вбок | Shift + колесо над журналом |
|
||||
| Растянуть форму звука по высоте | Shift + стрелки вверх и вниз |
|
||||
| Выключить дорожку / оставить только её | кнопки М и С в заголовке |
|
||||
| Громкость клипа | тянуть ручку на клипе вверх-вниз |
|
||||
| Автосхлопывание вкл/выкл | Ctrl+K |
|
||||
| Связать или развязать клипы | Ctrl+G |
|
||||
| Отменить прилипание на время | Shift при перетаскивании |
|
||||
| Вписать проект целиком | Ctrl+0 |
|
||||
| Новый, открыть, сохранить проект | Ctrl+N, Ctrl+O, Ctrl+S |
|
||||
|
|
@ -99,11 +125,61 @@ Lazarus другой версии, и собранные им модули LCL
|
|||
если кнопку нажали и потянули, курсор остаётся на месте, в том числе во
|
||||
время воспроизведения.
|
||||
|
||||
**Прилипание.** Края клипов притягиваются друг к другу и к курсору.
|
||||
Кнопка внизу включает и выключает его, а Shift во время перетаскивания
|
||||
меняет решение на противоположное: при включённом прилипании временно
|
||||
отключает, при выключенном — временно включает. Так одна клавиша полезна
|
||||
в обоих режимах, и не нужно помнить, какой сейчас.
|
||||
**Прилипание клипов** (кнопка с магнитом). Края клипов притягиваются
|
||||
друг к другу и к курсору. Shift во время перетаскивания меняет решение на
|
||||
противоположное: при включённом прилипании временно отключает, при
|
||||
выключенном — временно включает. Так одна клавиша полезна в обоих
|
||||
режимах, и не нужно помнить, какой сейчас.
|
||||
|
||||
**Прилипание курсора** (кнопка с клеем). Курсор, поставленный щелчком,
|
||||
притягивается к границам клипов на любой дорожке. Без этого в стык не
|
||||
попасть: на общем плане одна точка экрана — это секунды материала, а
|
||||
ставят курсор чаще всего именно на стык. Shift при щелчке работает так
|
||||
же — меняет решение на противоположное.
|
||||
|
||||
Переключателя два, а не один, потому что это разные привычки: клипы
|
||||
липнут при перетаскивании, курсор — при щелчке, и одно бывает нужно без
|
||||
другого.
|
||||
|
||||
**Ключ и прозрачность в предпросмотре** (меню «Вид»). Включено — окно
|
||||
показывает то же, что даст сборка: выбитый цвет и приглушение считаются
|
||||
на лету. Выключено — оверлей виден целиком, со своим фоном: так понятно,
|
||||
куда он встал и где его края.
|
||||
|
||||
Считается это по площади самого оверлея, а не всего кадра. Замер на
|
||||
2688x1512 (600 кадров, десять секунд материала): основа 2.07 с, с
|
||||
наложением 2.43 с, с ключом и прозрачностью 2.66 с. То есть наложение
|
||||
стоит 0.36 с, ключ добавляет ещё 0.23 с — вчетверо быстрее реального
|
||||
времени. На слабой машине это может быть заметнее, потому выключатель и
|
||||
есть.
|
||||
|
||||
**Отмеченный участок.** У линейки два этажа. Верхний, в двенадцать точек,
|
||||
— про отметки: протяжка по нему отмечает участок, даже если отметок не
|
||||
было, а щелчок без движения их снимает. По краям участка стоят жёлтые
|
||||
уголки, за них тянут. Нижний этаж — про курсор, как и раньше.
|
||||
|
||||
Клавиши тоже остались: `I` ставит начало по курсору, `O` — конец; оба
|
||||
пункта есть в меню «Правка», там же снятие отметок. Пока он отмечен, сборка
|
||||
собирает только его — это единственный способ проверить правку на длинном
|
||||
монтаже, не тратя на каждую проверку столько же времени, сколько идёт сам
|
||||
ролик. Отметки живут в файле проекта: участок относится к монтажу, а не к
|
||||
привычкам человека.
|
||||
|
||||
**Дорожки.** Слева в заголовке две стрелки: поднять и опустить. У верхней
|
||||
дорожки некуда вверх, у нижней вниз — такая стрелка нарисована тускло и
|
||||
не нажимается. Заголовок можно и просто потащить мышью. Двойной щелчок по
|
||||
названию переименовывает дорожку: на монтаже из десятка дорожек «карта»,
|
||||
«звук мотора» и «титры» читаются несравнимо лучше, чем V1, A2 и Т1.
|
||||
|
||||
Правый щелчок по заголовку открывает меню: поднять, опустить,
|
||||
переименовать, удалить. Удаление дорожки с клипами спрашивает
|
||||
подтверждение и называет число клипов.
|
||||
|
||||
**Копирование свойств клипа.** Ctrl+Shift+C берёт с выделенного клипа всё
|
||||
оформление — кадрирование, поворот, увеличение, смещение, прозрачность,
|
||||
цветовой ключ, затухания, громкость, — Ctrl+Shift+V применяет их ко всем
|
||||
выделенным. Вид клипа и его текст при этом не переносятся: свойства
|
||||
копируют между разными клипами, и превращать картинку в надпись незачем.
|
||||
|
||||
**Схлопывание.** Кнопка внизу показывает и режим, и состояние: утоплена —
|
||||
автосхлопывание включено, и тогда при перетаскивании клипа всё, что
|
||||
|
|
@ -137,6 +213,20 @@ Ctrl+Shift+R, который схлопывает дыру под курсоро
|
|||
В списке файлов слева показывается кадр из каждого файла, его параметры
|
||||
и длительность. Кадры делаются в фоне и запоминаются в кэше проекта.
|
||||
|
||||
На клипах видна форма звука. Считается она по заранее разобранным пикам —
|
||||
сто значений в секунду, каждое есть наибольший модуль отсчёта за свои
|
||||
десять миллисекунд. Рисовать по настоящим отсчётам нельзя: в секунде их
|
||||
48 тысяч, а в клипе на экране сотни пикселей.
|
||||
|
||||
Разбор идёт в фоне и запоминается в кэше проекта. Для записи на 892 с это
|
||||
около 13 секунд — столько занимает чтение шести гигабайт с диска, и
|
||||
быстрее не выйдет; при следующем открытии проекта пики берутся готовыми.
|
||||
|
||||
**Shift со стрелками вверх и вниз растягивает форму по высоте.** Звук
|
||||
бывает тихим, и угадывать за человека, во сколько раз растянуть картинку,
|
||||
неправильно. Усиление меняет только рисунок, самого звука не касается, и
|
||||
запоминается между запусками.
|
||||
|
||||
## Файл не совпадает с проектом
|
||||
|
||||
Проект имеет одно разрешение и один набор кодеков на всё — иначе куски не
|
||||
|
|
@ -146,11 +236,23 @@ Ctrl+Shift+R, который схлопывает дыру под курсоро
|
|||
Пересобираются **только они**. Остальной материал по-прежнему копируется
|
||||
без потерь и почти мгновенно. Ролик целиком не перекодируется никогда.
|
||||
|
||||
Как именно кадр такого файла ложится в кадр проекта, решает окно
|
||||
«Кадрирование и поворот» из контекстного меню библиотеки: вписать целиком
|
||||
с полями, заполнить кадр с обрезкой или растянуть, плюс поворот на 90,
|
||||
отражение, увеличение и смещение. Показывается настоящий кадр, пропущенный
|
||||
через ту же цепочку фильтров, что пойдёт в дело.
|
||||
Как именно кадр ложится в кадр проекта, решает окно «Кадрирование и
|
||||
поворот»: вписать целиком с полями, заполнить кадр с обрезкой или
|
||||
растянуть, плюс поворот на 90, отражение, увеличение и смещение.
|
||||
|
||||
Слева в окне — кадр источника с рамкой: её тянут мышью за середину и за
|
||||
правый нижний угол, и она держит пропорции кадра проекта. Справа —
|
||||
настоящий результат, пропущенный через ту же цепочку фильтров, что пойдёт
|
||||
в дело.
|
||||
|
||||
**Настройка принадлежит клипу**, а открывается кнопкой в правом нижнем
|
||||
видимом углу самого клипа. Именно видимом: у клипа длиной в час настоящий
|
||||
угол почти всегда за краем экрана. Два куска одного файла можно
|
||||
кадрировать по-разному.
|
||||
|
||||
У файла в библиотеке хранится такая же настройка, но она лишь заготовка:
|
||||
её берёт новый клип при добавлении. Правится она из контекстного меню
|
||||
библиотеки.
|
||||
|
||||
По умолчанию кадр вписывается целиком. Растяжение надо выбрать руками:
|
||||
раньше оно применялось молча, и вертикальное видео размазывалось по ширине.
|
||||
|
|
@ -258,6 +360,98 @@ Linux потом искала по нему `.dll`.
|
|||
случая, когда программа падает до появления окна: иначе всё, что она о
|
||||
себе рассказала, пропадает вместе с окном.
|
||||
|
||||
## Наложение, переходы и прозрачность
|
||||
|
||||
Ползунок на клипе картинки задаёт **непрозрачность**, на звуковом —
|
||||
громкость. Клип с непрозрачностью меньше единицы смешивается с тем, что
|
||||
под ним.
|
||||
|
||||
**Звуковые дорожки смешиваются.** Правило «верхний побеждает» придумано
|
||||
для картинки — оно и делает возможной сборку копированием. Для звука оно
|
||||
бессмысленно: без смешивания нельзя положить фоновую музыку под голос. И
|
||||
стоит оно ничего, потому что звук всё равно перекодируется.
|
||||
|
||||
Плеер играет одну звуковую дорожку за раз, поэтому в предпросмотре смесь
|
||||
собирается его же цепочкой фильтров: каждая дорожка монтажа подаётся
|
||||
своим списком кусков, дальше `amix`. Громкость клипов подставляется в ту
|
||||
же цепочку — иначе предпросмотр врал бы про баланс. Проверено: в
|
||||
предпросмотре и в готовом ролике уровни совпадают до десятой доли
|
||||
децибела.
|
||||
|
||||
Два клипа **на одной дорожке**, наползшие друг на друга, дают переход:
|
||||
один плавно перетекает в другой. На разных дорожках перекрытие означает
|
||||
не переход, а наложение — там по-прежнему побеждает верхний, если он
|
||||
непрозрачен.
|
||||
|
||||
Различие не косметическое. Если бы кроссфейдом считалось любое
|
||||
перекрытие, то в проекте, где клипы на разных дорожках перекрываются
|
||||
постоянно, каждое такое место пришлось бы пересобирать.
|
||||
|
||||
У клипа есть плавное появление и уход. Для картинки это затемнение, для
|
||||
звука — нарастание громкости.
|
||||
|
||||
**Всё это стоит перекодирования** — но только своего участка. Перед
|
||||
сборкой программа сообщает, сколько секунд из скольких придётся
|
||||
пересобрать, и даёт отказаться.
|
||||
|
||||
Звук при наложении сводится прямо в отсчётах, без фильтров ffmpeg: доля
|
||||
каждого слоя считается на каждый отсчёт, и длина результата получается
|
||||
ровно такой, какая нужна.
|
||||
|
||||
## Качество пересобираемых кусков
|
||||
|
||||
Куски, которые нельзя скопировать, целятся в наибольший битрейт среди
|
||||
файлов проекта. Без этого кодировщик берёт своё значение по умолчанию, и
|
||||
оно скромное: на съёмке 2688x1512 с 56 Мбит/с NVENC выдавал 5.9 Мбит/с —
|
||||
пересобранный кусок бросался в глаза среди скопированных соседей.
|
||||
|
||||
Кроме среднего задаётся потолок в полтора раза выше и буфер вдвое: без
|
||||
них кодировщик разгоняется на сложных кадрах ровно настолько, насколько
|
||||
позволит средний, и мелкие детали всё равно теряются.
|
||||
|
||||
Берётся именно наибольший, а не средний: пересобранный кусок стоит рядом
|
||||
со скопированными, и если он окажется хуже их, разница видна именно на
|
||||
нём. Лишнее место дешевле.
|
||||
|
||||
## Место на диске
|
||||
|
||||
Куски весят примерно столько же, сколько готовый ролик, а на длинном
|
||||
материале и больше: на часовой съёмке набралось 110 ГБ. Поэтому:
|
||||
|
||||
- **картинка не собирается в промежуточный файл.** Звук собирается
|
||||
раньше, и склейка сразу кладёт всё вместе в конечный файл. Раньше
|
||||
получалось два полных ролика на диске одновременно — 91 ГБ в кэше и 91
|
||||
ГБ рядом — и лишний проход копирования ради одной звуковой дорожки;
|
||||
- **после удачной сборки куски удаляются,** и в журнале сказано, сколько
|
||||
места освободилось. После неудачной — остаются: с них продолжают.
|
||||
|
||||
Оставить их можно настройкой `keep_parts` в файле настроек: пригодится,
|
||||
когда разбираешься, из чего собрался ролик.
|
||||
|
||||
## Прерванная сборка
|
||||
|
||||
Каждый доведённый до конца кусок помечается в списке рядом с самими
|
||||
кусками (`готово.txt` в кэше). Если сборка не дошла до конца — упала,
|
||||
была отменена или программу закрыли, — при следующей сборке в тот же файл
|
||||
программа предложит продолжить с готовых кусков.
|
||||
|
||||
Проверяется не только наличие файла: сборку могли прервать посреди
|
||||
записи, и файл оказался бы обрезанным. Поэтому куском считается только
|
||||
тот, который успел попасть в список.
|
||||
|
||||
Первая строка списка — отпечаток плана: пути, места и длины всех кусков.
|
||||
Не совпал — значит монтаж с тех пор менялся, и готовые куски не подойдут;
|
||||
программа скажет об этом и соберёт заново.
|
||||
|
||||
## Сколько осталось
|
||||
|
||||
Окно сборки показывает, сколько прошло и сколько примерно осталось.
|
||||
Оценка простая: если четверть пути заняла минуту, весь путь займёт
|
||||
четыре. На деле скорость меняется — копируемые куски идут мгновенно,
|
||||
пересобираемые медленно, — поэтому число называется «примерно» и
|
||||
пересчитывается на каждом шаге. Даже такая оценка отвечает на главный
|
||||
вопрос: успею ли я сходить за чаем.
|
||||
|
||||
## Как собирается ролик
|
||||
|
||||
1. Монтаж уплощается в последовательность непересекающихся кусков.
|
||||
|
|
@ -367,6 +561,30 @@ mpv сам выбрал `nvdec-copy`, всё работало, и ошибки
|
|||
«Invalid floating point operation», и предпросмотр не запускался. Лечится
|
||||
`SetExceptionMask` после загрузки библиотеки.
|
||||
|
||||
**Цепочку смешивания надо снимать до загрузки.** Она ссылается на дорожки
|
||||
по номерам, и оставшаяся от прошлого раза не даёт открыть новый файл
|
||||
вовсе: проверено на живом mpv — после такой загрузки дорожек ноль, кадра
|
||||
нет, не играет ничего. Выглядит это как чёрное окно и полная тишина, и
|
||||
на плеер не похоже совсем.
|
||||
|
||||
Снимать её после загрузки поздно: ломается сама загрузка.
|
||||
|
||||
**Внешняя звуковая дорожка не подключается свойством.** Задать
|
||||
`audio-files` перед загрузкой недостаточно: список дорожек после
|
||||
загрузки остаётся прежним, внешний файл не подключается вовсе, и играет
|
||||
звук, встроенный в саму картинку. Проверено на живом mpv через его канал
|
||||
управления: после установки свойства дорожек по-прежнему две, обе не
|
||||
внешние.
|
||||
|
||||
Из-за этого звук был слышен там, где на монтаже его нет. Попытка
|
||||
защититься, отключая звук при ненайденной внешней дорожке, сделала
|
||||
хуже — звука не стало нигде.
|
||||
|
||||
Правильный способ — команда `audio-add` с признаком `select` после
|
||||
загрузки: она добавляет дорожку, выбирает её и снимает выбор с
|
||||
внутренней. Проверено: дорожек становится четыре, внутренняя звуковая
|
||||
помечается невыбранной.
|
||||
|
||||
**Аргументы loadfile.** В mpv 0.38 у команды появился четвёртый аргумент —
|
||||
номер позиции в списке, и настройки файла сдвинулись на пятое место.
|
||||
Прежний вызов стал ошибкой «The loadfile option must be an integer», и под
|
||||
|
|
@ -433,6 +651,7 @@ Wine с новой библиотекой не грузилось ничего.
|
|||
| `src/uPanCropForm.pas` | Окно кадрирования и поворота |
|
||||
| `src/uProjectInfoForm.pas` | Сведения о проекте и ответ ffprobe |
|
||||
| `src/uThumbs.pas` | Кадры-миниатюры для списка файлов |
|
||||
| `src/uWaveform.pas` | Пики звука для формы волны на клипах |
|
||||
| `src/uTheme.pas` | Цвета окна, своя кнопка, своё окно сообщения |
|
||||
| `src/uLogView.pas` | Журнал: своя отрисовка, свои полосы прокрутки |
|
||||
| `src/uThemedScroll.pas` | Полоса прокрутки и обвязка для таблиц |
|
||||
|
|
|
|||
86
build.sh
|
|
@ -6,6 +6,7 @@
|
|||
# ./build.sh current то же самое
|
||||
# ./build.sh windows редактор под Windows (кросс-сборка)
|
||||
# ./build.sh tool консольный инструмент проверок
|
||||
# ./build.sh macos две сборки под macOS: nlve_intel и nlve_arm
|
||||
# ./build.sh test инструмент плюс прогон проверок
|
||||
# ./build.sh all всё перечисленное
|
||||
# ./build.sh run собрать под эту систему и запустить
|
||||
|
|
@ -22,6 +23,8 @@ HERE=$(cd "$(dirname "$0")" && pwd)
|
|||
FPCUP=${FPCUP:-/home/chaiman/fpcupdeluxe}
|
||||
LAZBUILD="$FPCUP/lazarus/lazbuild"
|
||||
LAZCONF="$FPCUP/config_lazarus"
|
||||
# Компилятор нужен напрямую для консольных проверок без формы.
|
||||
FPC="$FPCUP/fpc/bin/x86_64-linux/fpc.sh"
|
||||
|
||||
# Собранный виджетсет только один — gtk2. Ни qt5, ни gtk3 в этом
|
||||
# тулчейне нет, подставлять их бессмысленно.
|
||||
|
|
@ -99,6 +102,48 @@ build_windows() {
|
|||
rm -rf "$HERE/lib/x86_64-win64"
|
||||
}
|
||||
|
||||
build_macos() {
|
||||
check_toolchain
|
||||
|
||||
# Сборка под macOS кладёт файл туда же, куда и сборка под Linux, и
|
||||
# затирает её: имя цели в проекте одно на все системы. Поэтому каждый
|
||||
# файл переименовывается сразу, а линуксовая сборка в конце делается
|
||||
# заново.
|
||||
if run_lazbuild "Редактор под macOS (Intel)" \
|
||||
--cpu=x86_64 --os=darwin --ws=cocoa "$HERE/nlve.lpi"; then
|
||||
mv -f "$HERE/nlve" "$HERE/nlve_intel"
|
||||
echo "готово: $HERE/nlve_intel"
|
||||
file "$HERE/nlve_intel" 2>/dev/null | cut -d: -f2- | sed 's/^/ /'
|
||||
else
|
||||
echo "не собралось" >&2
|
||||
status=1
|
||||
fi
|
||||
|
||||
if run_lazbuild "Редактор под macOS (Apple Silicon)" \
|
||||
--cpu=aarch64 --os=darwin --ws=cocoa "$HERE/nlve.lpi"; then
|
||||
mv -f "$HERE/nlve" "$HERE/nlve_arm"
|
||||
echo "готово: $HERE/nlve_arm"
|
||||
file "$HERE/nlve_arm" 2>/dev/null | cut -d: -f2- | sed 's/^/ /'
|
||||
else
|
||||
echo "не собралось" >&2
|
||||
status=1
|
||||
fi
|
||||
|
||||
# Обёртку .app делает lazbuild, и внутри неё ссылка на файл с именем
|
||||
# nlve, которого после переименования уже нет. Обманчивая пустышка не
|
||||
# нужна.
|
||||
rm -rf "$HERE/nlve.app"
|
||||
|
||||
# Модули под darwin лежат отдельно, но сборку под Linux мы затёрли —
|
||||
# возвращаем её на место.
|
||||
note "Возвращаю сборку под эту систему"
|
||||
build_current
|
||||
|
||||
echo
|
||||
echo "ВНИМАНИЕ: под macOS предпросмотра не будет — встраивание mpv"
|
||||
echo "написано под X11 и Windows. Подробности в BACKLOG.md."
|
||||
}
|
||||
|
||||
build_tool() {
|
||||
note "Консольный инструмент проверок"
|
||||
if "$HERE/tests/build.sh"; then
|
||||
|
|
@ -120,6 +165,42 @@ run_tests() {
|
|||
echo "проверки не пройдены" >&2
|
||||
status=1
|
||||
fi
|
||||
|
||||
# Свойства, записанные в файлах форм, проверяются по сведениям о типах.
|
||||
# Файл формы разбирается при запуске, а не при сборке: опечатка в имени
|
||||
# свойства даёт не ошибку компиляции, а падение окна на старте.
|
||||
note "Проверка свойств из файлов форм"
|
||||
if "$LAZBUILD" --primary-config-path="$LAZCONF" \
|
||||
"$HERE/tests/lfmcheck/lfmcheck.lpi" >/dev/null 2>&1; then
|
||||
if "$HERE/tests/lfmcheck/lfmcheck"; then
|
||||
:
|
||||
else
|
||||
status=1
|
||||
fi
|
||||
# Окно зависимостей при создании записывает рядом с собой список
|
||||
# пакетов по умолчанию. Проверке это не мешает, а в дереве исходников
|
||||
# такому файлу не место.
|
||||
rm -f "$HERE/tests/lfmcheck/deps-linux.json"
|
||||
else
|
||||
echo "не собралось" >&2
|
||||
status=1
|
||||
fi
|
||||
|
||||
# Ход полоски выполнения проверяется отдельной программой: расчёт вынесен
|
||||
# в uAppTypes именно затем, чтобы его можно было прогнать числами.
|
||||
note "Проверка хода полоски выполнения"
|
||||
if "$FPC" -Fu"$HERE/src" -FE"$HERE/tests/progress" \
|
||||
-o"$HERE/tests/progress/progtest" \
|
||||
"$HERE/tests/progress/progtest.lpr" >/dev/null 2>&1; then
|
||||
if "$HERE/tests/progress/progtest"; then
|
||||
:
|
||||
else
|
||||
status=1
|
||||
fi
|
||||
else
|
||||
echo "не собралось" >&2
|
||||
status=1
|
||||
fi
|
||||
}
|
||||
|
||||
do_clean() {
|
||||
|
|
@ -137,6 +218,9 @@ case "${1:-current}" in
|
|||
windows|win|win64)
|
||||
build_windows
|
||||
;;
|
||||
macos|mac|darwin)
|
||||
build_macos
|
||||
;;
|
||||
tool)
|
||||
build_tool
|
||||
;;
|
||||
|
|
@ -163,7 +247,7 @@ case "${1:-current}" in
|
|||
;;
|
||||
*)
|
||||
fail "неизвестная цель: $1
|
||||
Доступны: current, windows, tool, test, all, run, clean"
|
||||
Доступны: current, windows, macos, tool, test, all, run, clean"
|
||||
;;
|
||||
esac
|
||||
|
||||
|
|
|
|||
5
icons_online/attribution.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
Log panel: <a target="_blank" href="https://icons8.com/icon/79872/property">Property</a> icon by <a target="_blank" href="https://icons8.com">Icons8</a>
|
||||
Ripple gaps: <a target="_blank" href="https://icons8.com/icon/2EQegp8tbR6Q/merge-vertical">Merge Vertical</a> icon by <a target="_blank" href="https://icons8.com">Icons8</a>
|
||||
Magnet: <a target="_blank" href="https://icons8.com/icon/79942/magnet">Magnet</a> icon by <a target="_blank" href="https://icons8.com">Icons8</a>
|
||||
Playhead snapping: <a target="_blank" href="https://icons8.com/icon/111296/glue">Клей</a> icon by <a target="_blank" href="https://icons8.com">Icons8</a>
|
||||
App icon: <a target="_blank" href="https://icons8.com/icon/69093/showing-video-frames">Showing Video Frames</a> icon by <a target="_blank" href="https://icons8.com">Icons8</a>
|
||||
BIN
icons_online/icons8-merge-vertical-64.png
Normal file
|
After Width: | Height: | Size: 463 B |
BIN
icons_online/icons8-property-30.png
Normal file
|
After Width: | Height: | Size: 190 B |
BIN
icons_online/icons8-клей-30.png
Normal file
|
After Width: | Height: | Size: 378 B |
BIN
icons_online/icons8-магнит-30.png
Normal file
|
After Width: | Height: | Size: 291 B |
BIN
icons_online/icons8-объединение-по-вертикали-30.png
Normal file
|
After Width: | Height: | Size: 228 B |
BIN
icons_online/icons8-показ-видеокадров-30.png
Normal file
|
After Width: | Height: | Size: 183 B |
BIN
icons_online/icons8-цель-30.png
Normal file
|
After Width: | Height: | Size: 793 B |
3
nlve.lpr
|
|
@ -18,6 +18,9 @@ begin
|
|||
Application.Title := 'nlve';
|
||||
Application.Scaled := True;
|
||||
Application.Initialize;
|
||||
{ Цвета меню задаются до создания окон: под gtk2 это строка стиля, а её
|
||||
разбирают один раз — уже созданные виджеты о ней не узнают. }
|
||||
ApplyMenuStyle;
|
||||
{ Подписка на появление окон: своим окнам цвета задаются в их же
|
||||
обработчике создания, а это подстраховка на случай окна, созданного
|
||||
самой библиотекой. }
|
||||
|
|
|
|||
BIN
packaging/application-x-nlve-project.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
22
packaging/application-x-nlve-project.xml
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Тип файла проекта nlve.
|
||||
|
||||
Зачем. Внутри проект — обычный JSON, и без этого описания система
|
||||
считает его просто текстом: открывает редактором и рисует значок
|
||||
текстового файла. Здесь у него появляется своё имя, свой значок и своя
|
||||
программа по двойному щелчку.
|
||||
|
||||
Тип наследуется от JSON, но не от простого текста: показывать его как
|
||||
текст незачем, а вот средствам, умеющим JSON, знать о родстве полезно.
|
||||
-->
|
||||
<mime-info xmlns="http://www.freedesktop.org/standards/shared-mime-info">
|
||||
<mime-type type="application/x-nlve-project">
|
||||
<comment>nlve project</comment>
|
||||
<comment xml:lang="ru">Проект nlve</comment>
|
||||
<sub-class-of type="application/json"/>
|
||||
<glob pattern="*.nlve" weight="80"/>
|
||||
<icon name="application-x-nlve-project"/>
|
||||
<generic-icon name="video-x-generic"/>
|
||||
</mime-type>
|
||||
</mime-info>
|
||||
17
packaging/nlve.desktop.in
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
[Desktop Entry]
|
||||
Type=Application
|
||||
Version=1.0
|
||||
Name=nlve
|
||||
GenericName=Video editor
|
||||
GenericName[ru]=Видеоредактор
|
||||
Comment=Non-linear video editing without re-encoding
|
||||
Comment[ru]=Монтаж видео без перекодирования
|
||||
Exec=@BIN@ %f
|
||||
Path=@DIR@
|
||||
Icon=nlve
|
||||
Terminal=false
|
||||
StartupNotify=true
|
||||
Categories=AudioVideo;Video;AudioVideoEditing;
|
||||
MimeType=application/x-nlve-project;video/mp4;video/x-matroska;
|
||||
Keywords=video;editor;montage;
|
||||
Keywords[ru]=видео;монтаж;редактор;
|
||||
BIN
packaging/nlve.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
235
src/uAboutForm.pas
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
{
|
||||
uAboutForm — окно «О программе».
|
||||
|
||||
Две вкладки. «Ресурсы» — на чём программа держится и чьи значки
|
||||
использует: бесплатная лицензия Icons8 требует ссылку на каждый значок.
|
||||
«Возможности» — что программа умеет.
|
||||
|
||||
Вкладки свои, а не TPageControl: системные под Windows остались бы
|
||||
светлой полосой поверх тёмного окна.
|
||||
|
||||
Текст показывает uArticleView: он сам переносит строки по ширине окна и
|
||||
делает ссылки нажимаемыми.
|
||||
}
|
||||
unit uAboutForm;
|
||||
|
||||
{$mode objfpc}{$H+}
|
||||
|
||||
interface
|
||||
|
||||
uses
|
||||
Classes, SysUtils, Controls, Forms, Graphics, StdCtrls, Dialogs,
|
||||
uTheme, uArticleView;
|
||||
|
||||
procedure ShowAboutForm;
|
||||
|
||||
implementation
|
||||
|
||||
type
|
||||
{ Обработчики живут в объекте: событиям нужны методы. }
|
||||
TAboutDialog = class(TComponent)
|
||||
public
|
||||
Form: TForm;
|
||||
BtnResources: TThemedButton;
|
||||
BtnFeatures: TThemedButton;
|
||||
ViewResources: TArticleView;
|
||||
ViewFeatures: TArticleView;
|
||||
procedure ShowResources(Sender: TObject);
|
||||
procedure ShowFeatures(Sender: TObject);
|
||||
procedure Select(AResources: Boolean);
|
||||
end;
|
||||
|
||||
procedure TAboutDialog.Select(AResources: Boolean);
|
||||
begin
|
||||
BtnResources.Down := AResources;
|
||||
BtnFeatures.Down := not AResources;
|
||||
ViewResources.Visible := AResources;
|
||||
ViewFeatures.Visible := not AResources;
|
||||
end;
|
||||
|
||||
procedure TAboutDialog.ShowResources(Sender: TObject);
|
||||
begin
|
||||
Select(True);
|
||||
end;
|
||||
|
||||
procedure TAboutDialog.ShowFeatures(Sender: TObject);
|
||||
begin
|
||||
Select(False);
|
||||
end;
|
||||
|
||||
{ Списки под Windows и под Linux разные: там сборки скачивает сама
|
||||
программа, здесь их ставит пакетный менеджер. }
|
||||
function ResourcesText: string;
|
||||
begin
|
||||
Result :=
|
||||
'# nlve' + LineEnding +
|
||||
'Монтаж без перекодирования: куски вырезаются и склеиваются ' +
|
||||
'копированием потока.' + LineEnding +
|
||||
'Автор: 4aiman' + LineEnding +
|
||||
LineEnding +
|
||||
'# Значки' + LineEnding +
|
||||
'Icons8. Нужна ссылка на каждый значок.' + LineEnding +
|
||||
'Прилипание — Magnet' + LineEnding +
|
||||
' https://icons8.com/icon/79942/magnet' + LineEnding +
|
||||
'Схлопывание — Merge Vertical' + LineEnding +
|
||||
' https://icons8.com/icon/2EQegp8tbR6Q/merge-vertical' + LineEnding +
|
||||
'Журнал — Property' + LineEnding +
|
||||
' https://icons8.com/icon/79872/property' + LineEnding +
|
||||
'Прилипание курсора — Клей' + LineEnding +
|
||||
' https://icons8.com/icon/111296/glue' + LineEnding +
|
||||
'Значок программы — Showing Video Frames' + LineEnding +
|
||||
' https://icons8.com/icon/69093/showing-video-frames' + LineEnding +
|
||||
'Сам Icons8' + LineEnding +
|
||||
' https://icons8.com' + LineEnding +
|
||||
LineEnding +
|
||||
'# Видео и звук' + LineEnding +
|
||||
{$IFDEF WINDOWS}
|
||||
'ffmpeg — нарезка, склейка, перекодирование. Сборка BtbN, GPL.' +
|
||||
LineEnding +
|
||||
' https://github.com/BtbN/FFmpeg-Builds' + LineEnding +
|
||||
' https://ffmpeg.org' + LineEnding +
|
||||
'libmpv — предпросмотр. Сборка shinchiro, GPL.' + LineEnding +
|
||||
' https://github.com/shinchiro/mpv-winbuild-cmake' + LineEnding +
|
||||
' https://mpv.io' + LineEnding +
|
||||
'7-Zip — распаковка сборок libmpv. LGPL-2.1.' + LineEnding +
|
||||
' https://www.7-zip.org' + LineEnding +
|
||||
{$ELSE}
|
||||
'ffmpeg — нарезка, склейка, перекодирование. GPL-2 и новее.' +
|
||||
LineEnding +
|
||||
' sudo apt install ffmpeg' + LineEnding +
|
||||
' https://ffmpeg.org' + LineEnding +
|
||||
'libmpv — предпросмотр. GPL-3 и новее.' + LineEnding +
|
||||
' sudo apt install mpv' + LineEnding +
|
||||
' https://mpv.io' + LineEnding +
|
||||
{$ENDIF}
|
||||
LineEnding +
|
||||
'# Инструменты' + LineEnding +
|
||||
'Free Pascal' + LineEnding +
|
||||
' https://www.freepascal.org' + LineEnding +
|
||||
'Lazarus LCL' + LineEnding +
|
||||
' https://www.lazarus-ide.org' + LineEnding +
|
||||
LineEnding +
|
||||
'ffmpeg и libmpv в состав программы не входят: ffmpeg запускается ' +
|
||||
'отдельной программой, libmpv ставится отдельно.';
|
||||
end;
|
||||
|
||||
function FeaturesText: string;
|
||||
begin
|
||||
Result :=
|
||||
'# Монтаж' + LineEnding +
|
||||
'Дорожки для картинки и для звука.' + LineEnding +
|
||||
'При перекрытии картинку даёт верхняя дорожка, звук всех дорожек ' +
|
||||
'смешивается.' + LineEnding +
|
||||
'Перенос, растягивание за края, рез по курсору (S), удаление.' +
|
||||
LineEnding +
|
||||
'Прилипание к соседям и к курсору. Shift отменяет его.' + LineEnding +
|
||||
'Связки: видео и его звук ходят вместе. Ctrl+G связывает и ' +
|
||||
'развязывает.' + LineEnding +
|
||||
'Схлопывание дыр: Ctrl+Shift+R или само при перетаскивании.' +
|
||||
LineEnding +
|
||||
'Отмена и повтор: Ctrl+Z, Ctrl+Y.' + LineEnding +
|
||||
LineEnding +
|
||||
'# На дорожках' + LineEnding +
|
||||
'Форма звука. Shift со стрелками усиливает её на тихих записях.' +
|
||||
LineEnding +
|
||||
'Громкость и прозрачность — ручкой на клипе.' + LineEnding +
|
||||
'Появление и уход — за верхние углы клипа.' + LineEnding +
|
||||
'Перекрытие на одной дорожке помечено крестом: там переход.' +
|
||||
LineEnding +
|
||||
'Цвет краёв показывает, потребует ли рез перекодирования.' + LineEnding +
|
||||
LineEnding +
|
||||
'# Предпросмотр' + LineEnding +
|
||||
'Играет монтаж целиком, не собирая файлов.' + LineEnding +
|
||||
'Показывает кадр проекта, а не кадр исходника.' + LineEnding +
|
||||
'Прозрачность и переходы видны.' + LineEnding +
|
||||
LineEnding +
|
||||
'# Кадрирование' + LineEnding +
|
||||
'Вписать, заполнить или растянуть. Увеличение, смещение, поворот, ' +
|
||||
'отражение.' + LineEnding +
|
||||
'Рамку можно тянуть мышью.' + LineEnding +
|
||||
'У каждого клипа своё.' + LineEnding +
|
||||
LineEnding +
|
||||
'# Сборка' + LineEnding +
|
||||
'Обычная — копированием потоков, за время копирования файла.' +
|
||||
LineEnding +
|
||||
'Заранее сказано, сколько секунд придётся пересобрать и почему.' +
|
||||
LineEnding +
|
||||
'Точный рез перекодирует только огрызок до опорного кадра.' +
|
||||
LineEnding +
|
||||
'С перекодированием: от 720p до 4K, свой поток, HEVC или H.264, ' +
|
||||
'на видеокарте.' + LineEnding +
|
||||
'Перепаковка в другой контейнер без пересчёта.' + LineEnding +
|
||||
LineEnding +
|
||||
'# Прочее' + LineEnding +
|
||||
'Индекс опорных кадров читается из заголовка: 20 мс вместо 12 секунд.' +
|
||||
LineEnding +
|
||||
'Фоновая работа видна полоской внизу и штриховкой на монтаже.' +
|
||||
LineEnding +
|
||||
'Свои цвета во всех окнах.' + LineEnding +
|
||||
'Настройки и недавние проекты переживают перезапуск.';
|
||||
end;
|
||||
|
||||
procedure ShowAboutForm;
|
||||
var
|
||||
Dlg: TAboutDialog;
|
||||
BtnClose: TThemedButton;
|
||||
begin
|
||||
Dlg := TAboutDialog.Create(nil);
|
||||
try
|
||||
Dlg.Form := TForm.CreateNew(nil);
|
||||
Dlg.Form.Caption := 'О программе';
|
||||
Dlg.Form.Position := poScreenCenter;
|
||||
Dlg.Form.ClientWidth := 720;
|
||||
Dlg.Form.ClientHeight := 560;
|
||||
Dlg.Form.Constraints.MinWidth := 420;
|
||||
Dlg.Form.Constraints.MinHeight := 300;
|
||||
|
||||
Dlg.BtnResources := TThemedButton.Create(Dlg.Form);
|
||||
Dlg.BtnResources.Parent := Dlg.Form;
|
||||
Dlg.BtnResources.SetBounds(12, 12, 140, 28);
|
||||
Dlg.BtnResources.Caption := 'Ресурсы';
|
||||
Dlg.BtnResources.TabStop := False;
|
||||
Dlg.BtnResources.OnClick := @Dlg.ShowResources;
|
||||
|
||||
Dlg.BtnFeatures := TThemedButton.Create(Dlg.Form);
|
||||
Dlg.BtnFeatures.Parent := Dlg.Form;
|
||||
Dlg.BtnFeatures.SetBounds(158, 12, 140, 28);
|
||||
Dlg.BtnFeatures.Caption := 'Возможности';
|
||||
Dlg.BtnFeatures.TabStop := False;
|
||||
Dlg.BtnFeatures.OnClick := @Dlg.ShowFeatures;
|
||||
|
||||
Dlg.ViewResources := TArticleView.Create(Dlg.Form);
|
||||
Dlg.ViewResources.Parent := Dlg.Form;
|
||||
Dlg.ViewResources.SetBounds(12, 48, 696, 460);
|
||||
Dlg.ViewResources.Anchors := [akTop, akLeft, akRight, akBottom];
|
||||
|
||||
Dlg.ViewFeatures := TArticleView.Create(Dlg.Form);
|
||||
Dlg.ViewFeatures.Parent := Dlg.Form;
|
||||
Dlg.ViewFeatures.SetBounds(12, 48, 696, 460);
|
||||
Dlg.ViewFeatures.Anchors := [akTop, akLeft, akRight, akBottom];
|
||||
|
||||
BtnClose := TThemedButton.Create(Dlg.Form);
|
||||
BtnClose.Parent := Dlg.Form;
|
||||
BtnClose.SetBounds(608, 520, 100, 28);
|
||||
BtnClose.Anchors := [akRight, akBottom];
|
||||
BtnClose.Caption := 'Закрыть';
|
||||
BtnClose.ModalResult := mrOk;
|
||||
BtnClose.Default := True;
|
||||
BtnClose.Cancel := True;
|
||||
|
||||
ApplyDarkTheme(Dlg.Form);
|
||||
|
||||
{ Текст задаётся после раскраски: раскладка считается по тому шрифту,
|
||||
которым он будет нарисован. }
|
||||
Dlg.ViewResources.SetText(ResourcesText);
|
||||
Dlg.ViewFeatures.SetText(FeaturesText);
|
||||
Dlg.Select(True);
|
||||
|
||||
Dlg.Form.ShowModal;
|
||||
finally
|
||||
Dlg.Form.Free;
|
||||
Dlg.Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
end.
|
||||
|
|
@ -11,9 +11,18 @@ unit uAppTypes;
|
|||
interface
|
||||
|
||||
uses
|
||||
SysUtils;
|
||||
SysUtils
|
||||
{$IFDEF UNIX}, BaseUnix{$ENDIF};
|
||||
|
||||
type
|
||||
{ Цвет в записи, принятой в LCL: $00BBGGRR.
|
||||
|
||||
Свой тип, а не TColor из Graphics: модель не должна тянуть за собой
|
||||
оконную библиотеку. Стоило один раз потянуть — и консольная проверка
|
||||
перестала собираться. Значения совпадают, окно приводит одно к
|
||||
другому без пересчёта. }
|
||||
TRgb = LongInt;
|
||||
|
||||
{ Чем заполнен сегмент готового ряда.
|
||||
|
||||
skVideo — кусок настоящего исходного файла, копируется без перекодирования.
|
||||
|
|
@ -26,6 +35,67 @@ type
|
|||
показа. Позиция на таймлайне здесь не хранится намеренно — она равна
|
||||
сумме длительностей предыдущих сегментов, дублировать её значит
|
||||
завести источник рассогласования. }
|
||||
{ Один слой наложения.
|
||||
|
||||
Появляется там, где кусок нельзя описать одним источником: клип
|
||||
полупрозрачен и сквозь него видно нижний, либо два клипа на одной
|
||||
дорожке наползли друг на друга и должны перетечь один в другой.
|
||||
|
||||
Слои перечислены снизу вверх. }
|
||||
TSegmentLayer = record
|
||||
FilePath: string;
|
||||
SrcStart: Double;
|
||||
{ Цепочка кадрирования этого слоя: с полями до кадра проекта. Годится
|
||||
там, где слой сам по себе занимает весь кадр. }
|
||||
Filter: string;
|
||||
{ Та же цепочка без полей и место, куда слой встаёт в кадре.
|
||||
|
||||
Верхний слой полями обкладывать нельзя: поле чёрное и непрозрачное,
|
||||
и уменьшенный клип закрывал бы им всё нижнее видео вместо того,
|
||||
чтобы лечь в свой угол. }
|
||||
FitFilter: string;
|
||||
X: Integer;
|
||||
Y: Integer;
|
||||
{ Постоянная непрозрачность слоя. }
|
||||
Opacity: Double;
|
||||
{ Слой — неподвижная картинка, а не видео.
|
||||
|
||||
Её надо повторять: одиночный файл даёт ffmpeg ровно один кадр, и
|
||||
кусок с наложением схлопывался в одну шестидесятую секунды —
|
||||
фотография из ролика просто пропадала. }
|
||||
IsImage: Boolean;
|
||||
{ Цветовой ключ слоя: этот цвет становится прозрачным. Ключ выключен,
|
||||
если KeyEnabled равно False. }
|
||||
KeyEnabled: Boolean;
|
||||
KeyColor: TRgb;
|
||||
KeySimilarity: Integer;
|
||||
KeyBlend: Integer;
|
||||
{ Переход: непрозрачность меняется от начала куска к концу. Если оба
|
||||
значения равны Opacity, перехода нет. }
|
||||
FadeFrom: Double;
|
||||
FadeTo: Double;
|
||||
end;
|
||||
|
||||
TSegmentLayers = array of TSegmentLayer;
|
||||
|
||||
{ Окно наложения для плеера: когда, куда и каким фильтром класть
|
||||
оверлей поверх основы.
|
||||
|
||||
Живёт в базовых типах, а не в уплощении, потому что плеер про
|
||||
уплощение знать не должен: ему отдают готовое описание. }
|
||||
TOverlayWindow = record
|
||||
TlStart: Double;
|
||||
TlEnd: Double;
|
||||
{ Цепочка фильтров для самого оверлея: размер, поворот. Без полей —
|
||||
поля закрыли бы нижнее видео. }
|
||||
Filter: string;
|
||||
X: Integer;
|
||||
Y: Integer;
|
||||
Width: Integer;
|
||||
Height: Integer;
|
||||
end;
|
||||
TOverlayWindows = array of TOverlayWindow;
|
||||
|
||||
TFlatSegment = record
|
||||
FilePath: string;
|
||||
SrcStart: Double;
|
||||
|
|
@ -53,6 +123,16 @@ type
|
|||
{ Готовая цепочка фильтров ffmpeg: как кадр этого исходника ложится в
|
||||
кадр проекта. Считается один раз при уплощении. }
|
||||
Filter: string;
|
||||
{ Разметка субтитров для этого куска: времена уже сдвинуты к его
|
||||
началу. Пусто — текста здесь нет. Заполняется только при сборке с
|
||||
вжиганием. }
|
||||
SubFile: string;
|
||||
{ Слои наложения. Пусто — обычный кусок с одним источником, который
|
||||
можно скопировать. Непусто — участок придётся собрать заново.
|
||||
|
||||
Ради этого и заведено: наложение и переходы стоят перекодирования,
|
||||
но только своего участка, а не всего ролика. }
|
||||
Layers: TSegmentLayers;
|
||||
end;
|
||||
|
||||
TFlatSegmentArray = array of TFlatSegment;
|
||||
|
|
@ -77,6 +157,30 @@ function TotalDuration(const Segments: TFlatSegmentArray): Double;
|
|||
«куска», для 5 — «кусков». Без этого в интерфейсе появляется «1 кусков». }
|
||||
function Plural(ACount: Integer; const AOne, AFew, AMany: string): string;
|
||||
|
||||
{ Как двигается показанная доля выполнения.
|
||||
|
||||
Настоящая доля известна только вехами: файл разобран, вставка готова.
|
||||
Между вехами не происходит ничего видимого, и полоска, стоящая на месте,
|
||||
выглядит как зависшая программа.
|
||||
|
||||
Поэтому между вехами она ползёт сама — к следующей вехе, но никогда её
|
||||
не достигая: каждый шаг покрывает малую долю оставшегося расстояния.
|
||||
Пока работа идёт как задумано, веха приходит быстро и полоска прыгает к
|
||||
ней. Затянулась — шаги мельчают, и заполнение само собой замедляется.
|
||||
|
||||
Назад полоска не идёт никогда. }
|
||||
function AdvanceProgress(AShown, AReal, ANext, AStepShare: Double): Double;
|
||||
|
||||
{ Каталог, в котором лежит сама программа.
|
||||
|
||||
Не ExtractFilePath(ParamStr(0)): если программу запустили через
|
||||
символьную ссылку — а так она и попадает в PATH и в меню, — то путь
|
||||
укажет на ссылку, и рядом с ней окажутся и журнал, и файл зависимостей,
|
||||
и поиск ffmpeg. Ссылки раскрываются, пока не кончатся.
|
||||
|
||||
Под Windows ссылок такого рода нет, и путь берётся как есть. }
|
||||
function ProgramDir: string;
|
||||
|
||||
implementation
|
||||
|
||||
var
|
||||
|
|
@ -127,4 +231,44 @@ begin
|
|||
Result := AMany;
|
||||
end;
|
||||
|
||||
function AdvanceProgress(AShown, AReal, ANext, AStepShare: Double): Double;
|
||||
begin
|
||||
Result := AShown;
|
||||
if AReal > Result then
|
||||
Result := AReal;
|
||||
if ANext > Result then
|
||||
Result := Result + (ANext - Result) * AStepShare;
|
||||
{ До самого конца полоска не доходит: конец наступает тогда, когда
|
||||
работа кончилась, а не когда мы досчитали. }
|
||||
if Result > 0.999 then
|
||||
Result := 0.999;
|
||||
end;
|
||||
|
||||
function ProgramDir: string;
|
||||
var
|
||||
Path, Target: string;
|
||||
{$IFDEF UNIX}
|
||||
Guard: Integer;
|
||||
{$ENDIF}
|
||||
begin
|
||||
Path := ExpandFileName(ParamStr(0));
|
||||
{$IFDEF UNIX}
|
||||
{ Ограничение на число переходов — от закольцованных ссылок. }
|
||||
Guard := 0;
|
||||
while Guard < 16 do
|
||||
begin
|
||||
Target := fpReadLink(Path);
|
||||
if Target = '' then
|
||||
Break;
|
||||
{ Ссылка может указывать и относительным путём — считаем его от
|
||||
каталога самой ссылки. }
|
||||
if (Length(Target) > 0) and (Target[1] <> '/') then
|
||||
Target := ExpandFileName(ExtractFilePath(Path) + Target);
|
||||
Path := Target;
|
||||
Inc(Guard);
|
||||
end;
|
||||
{$ENDIF}
|
||||
Result := ExtractFilePath(Path);
|
||||
end;
|
||||
|
||||
end.
|
||||
|
|
|
|||
414
src/uArticleView.pas
Normal file
|
|
@ -0,0 +1,414 @@
|
|||
{
|
||||
uArticleView — страница текста со ссылками.
|
||||
|
||||
Журнал (uLogView) для этого не годится: он показывает сообщения
|
||||
программы, где перенос сбил бы выравнивание, и строки там не
|
||||
переносятся принципиально. Здесь наоборот — сплошной текст, который
|
||||
должен сам ложиться по ширине окна и переливаться заново при его
|
||||
изменении. Считать переносы заранее нельзя ещё и потому, что текст
|
||||
когда-нибудь поедет в переводы: длина строки в другом языке будет иной.
|
||||
|
||||
Разметка нарочно бедная, вся в одну строку:
|
||||
|
||||
«# заголовок» — заголовок раздела
|
||||
пустая строка — отступ между абзацами
|
||||
ведущие пробелы — отступ, сохраняется при переносе
|
||||
http:// и https:// — ссылка, её можно нажать
|
||||
|
||||
Больше ничего не нужно: это окно «О программе», а не браузер.
|
||||
}
|
||||
unit uArticleView;
|
||||
|
||||
{$mode objfpc}{$H+}
|
||||
|
||||
interface
|
||||
|
||||
uses
|
||||
Classes, SysUtils, Math, Controls, Graphics, LCLType, LCLIntf, Types,
|
||||
uTheme, uThemedScroll;
|
||||
|
||||
type
|
||||
{ Кусок строки: либо обычный текст, либо ссылка. }
|
||||
TRun = record
|
||||
Text: string;
|
||||
Url: string;
|
||||
Left: Integer;
|
||||
Width: Integer;
|
||||
end;
|
||||
|
||||
TVisualLine = record
|
||||
Runs: array of TRun;
|
||||
Top: Integer;
|
||||
Height: Integer;
|
||||
Heading: Boolean;
|
||||
end;
|
||||
|
||||
TArticleView = class(TCustomControl)
|
||||
private
|
||||
FLines: TStringList;
|
||||
FVisual: array of TVisualLine;
|
||||
FContentHeight: Integer;
|
||||
FScroll: Integer;
|
||||
FBar: TThemedScrollBar;
|
||||
FHotLine: Integer;
|
||||
FHotRun: Integer;
|
||||
procedure BarChange(Sender: TObject);
|
||||
procedure Relayout;
|
||||
procedure SyncBar;
|
||||
{ Ссылка под указателем. Возвращает пустую строку, если её там нет. }
|
||||
function UrlAt(X, Y: Integer; out ALine, ARun: Integer): string;
|
||||
function LineHeight(AHeading: Boolean): Integer;
|
||||
protected
|
||||
procedure Paint; override;
|
||||
procedure Resize; override;
|
||||
procedure MouseMove(Shift: TShiftState; X, Y: Integer); override;
|
||||
procedure MouseUp(Button: TMouseButton; Shift: TShiftState;
|
||||
X, Y: Integer); override;
|
||||
procedure MouseLeave; override;
|
||||
function DoMouseWheel(Shift: TShiftState; WheelDelta: Integer;
|
||||
MousePos: TPoint): Boolean; override;
|
||||
public
|
||||
constructor Create(AOwner: TComponent); override;
|
||||
destructor Destroy; override;
|
||||
procedure SetText(const AText: string);
|
||||
published
|
||||
property Align;
|
||||
property Anchors;
|
||||
property Font;
|
||||
property ParentFont;
|
||||
property Visible;
|
||||
end;
|
||||
|
||||
implementation
|
||||
|
||||
const
|
||||
PadLeft = 10;
|
||||
PadTop = 8;
|
||||
BarWidth = 12;
|
||||
{ Отступ между абзацами и после заголовка — доли высоты строки. }
|
||||
ParaGap = 6;
|
||||
|
||||
constructor TArticleView.Create(AOwner: TComponent);
|
||||
begin
|
||||
inherited Create(AOwner);
|
||||
DoubleBuffered := True;
|
||||
Color := ThemeSurface;
|
||||
ParentColor := False;
|
||||
FLines := TStringList.Create;
|
||||
FHotLine := -1;
|
||||
FHotRun := -1;
|
||||
|
||||
FBar := TThemedScrollBar.Create(Self);
|
||||
FBar.Parent := Self;
|
||||
FBar.Setup(True);
|
||||
FBar.OnChange := @BarChange;
|
||||
FBar.Visible := False;
|
||||
end;
|
||||
|
||||
destructor TArticleView.Destroy;
|
||||
begin
|
||||
FLines.Free;
|
||||
inherited Destroy;
|
||||
end;
|
||||
|
||||
function TArticleView.LineHeight(AHeading: Boolean): Integer;
|
||||
begin
|
||||
Canvas.Font.Assign(Font);
|
||||
if AHeading then
|
||||
Canvas.Font.Style := [fsBold];
|
||||
Result := Canvas.TextHeight('Wg') + 3;
|
||||
Canvas.Font.Style := [];
|
||||
end;
|
||||
|
||||
procedure TArticleView.SetText(const AText: string);
|
||||
begin
|
||||
FLines.Text := AText;
|
||||
FScroll := 0;
|
||||
Relayout;
|
||||
Invalidate;
|
||||
end;
|
||||
|
||||
{ Раскладка: каждая строка исходника режется на слова и укладывается по
|
||||
ширине. Ссылка не режется никогда — из двух половин её не собрать. }
|
||||
procedure TArticleView.Relayout;
|
||||
var
|
||||
I, N, Indent, X, MaxX, W, Start: Integer;
|
||||
Src, Word, Rest: string;
|
||||
Heading: Boolean;
|
||||
Line: TVisualLine;
|
||||
Y: Integer;
|
||||
|
||||
procedure PushLine;
|
||||
begin
|
||||
if Length(Line.Runs) = 0 then
|
||||
Exit;
|
||||
Line.Top := Y;
|
||||
Line.Height := LineHeight(Line.Heading);
|
||||
Inc(Y, Line.Height);
|
||||
SetLength(FVisual, Length(FVisual) + 1);
|
||||
FVisual[High(FVisual)] := Line;
|
||||
Line.Runs := nil;
|
||||
X := Indent;
|
||||
end;
|
||||
|
||||
procedure PushWord(const AWord: string; const AUrl: string);
|
||||
var
|
||||
Wid: Integer;
|
||||
begin
|
||||
Canvas.Font.Assign(Font);
|
||||
if Line.Heading then
|
||||
Canvas.Font.Style := [fsBold];
|
||||
Wid := Canvas.TextWidth(AWord);
|
||||
Canvas.Font.Style := [];
|
||||
|
||||
{ Не влезло — переносим, но только если на строке уже что-то есть:
|
||||
иначе слово шире окна ушло бы в бесконечный перенос. }
|
||||
if (X + Wid > MaxX) and (Length(Line.Runs) > 0) then
|
||||
PushLine;
|
||||
|
||||
N := Length(Line.Runs);
|
||||
SetLength(Line.Runs, N + 1);
|
||||
Line.Runs[N].Text := AWord;
|
||||
Line.Runs[N].Url := AUrl;
|
||||
Line.Runs[N].Left := X;
|
||||
Line.Runs[N].Width := Wid;
|
||||
Inc(X, Wid);
|
||||
end;
|
||||
|
||||
begin
|
||||
FVisual := nil;
|
||||
FContentHeight := 0;
|
||||
if FLines = nil then
|
||||
Exit;
|
||||
|
||||
MaxX := ClientWidth - PadLeft - BarWidth - 6;
|
||||
if MaxX < 80 then
|
||||
MaxX := 80;
|
||||
|
||||
Y := PadTop;
|
||||
Line.Runs := nil;
|
||||
Line.Heading := False;
|
||||
|
||||
for I := 0 to FLines.Count - 1 do
|
||||
begin
|
||||
Src := FLines[I];
|
||||
|
||||
if Trim(Src) = '' then
|
||||
begin
|
||||
Inc(Y, ParaGap + LineHeight(False) div 2);
|
||||
Continue;
|
||||
end;
|
||||
|
||||
Heading := Copy(Src, 1, 2) = '# ';
|
||||
if Heading then
|
||||
begin
|
||||
Src := Copy(Src, 3, Length(Src));
|
||||
Inc(Y, ParaGap);
|
||||
end;
|
||||
|
||||
Indent := PadLeft;
|
||||
while (Length(Src) > 0) and (Src[1] = ' ') do
|
||||
begin
|
||||
Inc(Indent, 12);
|
||||
Delete(Src, 1, 1);
|
||||
end;
|
||||
|
||||
Line.Heading := Heading;
|
||||
X := Indent;
|
||||
Rest := Src;
|
||||
|
||||
while Rest <> '' do
|
||||
begin
|
||||
Start := Pos(' ', Rest);
|
||||
if Start = 0 then
|
||||
begin
|
||||
Word := Rest;
|
||||
Rest := '';
|
||||
end
|
||||
else
|
||||
begin
|
||||
Word := Copy(Rest, 1, Start);
|
||||
Rest := Copy(Rest, Start + 1, Length(Rest));
|
||||
end;
|
||||
|
||||
if (Pos('http://', Word) = 1) or (Pos('https://', Word) = 1) then
|
||||
PushWord(Word, TrimRight(Word))
|
||||
else
|
||||
PushWord(Word, '');
|
||||
end;
|
||||
PushLine;
|
||||
|
||||
if Heading then
|
||||
Inc(Y, 2);
|
||||
end;
|
||||
|
||||
FContentHeight := Y + PadTop;
|
||||
SyncBar;
|
||||
end;
|
||||
|
||||
procedure TArticleView.SyncBar;
|
||||
var
|
||||
Page: Integer;
|
||||
begin
|
||||
if FBar = nil then
|
||||
Exit;
|
||||
Page := ClientHeight;
|
||||
FBar.SetBounds(ClientWidth - BarWidth, 0, BarWidth, ClientHeight);
|
||||
FBar.SetRange(FContentHeight, Page);
|
||||
FBar.Visible := FBar.Needed;
|
||||
if FScroll > FContentHeight - Page then
|
||||
FScroll := Max(0, FContentHeight - Page);
|
||||
FBar.Position := FScroll;
|
||||
end;
|
||||
|
||||
procedure TArticleView.BarChange(Sender: TObject);
|
||||
begin
|
||||
if FScroll = FBar.Position then
|
||||
Exit;
|
||||
FScroll := FBar.Position;
|
||||
Invalidate;
|
||||
end;
|
||||
|
||||
procedure TArticleView.Resize;
|
||||
begin
|
||||
inherited Resize;
|
||||
Relayout;
|
||||
Invalidate;
|
||||
end;
|
||||
|
||||
function TArticleView.DoMouseWheel(Shift: TShiftState; WheelDelta: Integer;
|
||||
MousePos: TPoint): Boolean;
|
||||
var
|
||||
Step: Integer;
|
||||
begin
|
||||
Step := LineHeight(False) * 3;
|
||||
if WheelDelta > 0 then
|
||||
FScroll := Max(0, FScroll - Step)
|
||||
else
|
||||
FScroll := Min(Max(0, FContentHeight - ClientHeight), FScroll + Step);
|
||||
if FBar <> nil then
|
||||
FBar.Position := FScroll;
|
||||
Invalidate;
|
||||
Result := True;
|
||||
end;
|
||||
|
||||
function TArticleView.UrlAt(X, Y: Integer; out ALine, ARun: Integer): string;
|
||||
var
|
||||
{ Не Top: так называется свойство самого элемента. }
|
||||
I, J, LineTop: Integer;
|
||||
begin
|
||||
Result := '';
|
||||
ALine := -1;
|
||||
ARun := -1;
|
||||
for I := 0 to High(FVisual) do
|
||||
begin
|
||||
LineTop := FVisual[I].Top - FScroll;
|
||||
if (Y < LineTop) or (Y >= LineTop + FVisual[I].Height) then
|
||||
Continue;
|
||||
for J := 0 to High(FVisual[I].Runs) do
|
||||
if (FVisual[I].Runs[J].Url <> '') and
|
||||
(X >= FVisual[I].Runs[J].Left) and
|
||||
(X < FVisual[I].Runs[J].Left + FVisual[I].Runs[J].Width) then
|
||||
begin
|
||||
ALine := I;
|
||||
ARun := J;
|
||||
Exit(FVisual[I].Runs[J].Url);
|
||||
end;
|
||||
Exit;
|
||||
end;
|
||||
end;
|
||||
|
||||
procedure TArticleView.MouseMove(Shift: TShiftState; X, Y: Integer);
|
||||
var
|
||||
L, R: Integer;
|
||||
begin
|
||||
inherited MouseMove(Shift, X, Y);
|
||||
UrlAt(X, Y, L, R);
|
||||
if (L <> FHotLine) or (R <> FHotRun) then
|
||||
begin
|
||||
FHotLine := L;
|
||||
FHotRun := R;
|
||||
if L >= 0 then
|
||||
Cursor := crHandPoint
|
||||
else
|
||||
Cursor := crDefault;
|
||||
Invalidate;
|
||||
end;
|
||||
end;
|
||||
|
||||
procedure TArticleView.MouseLeave;
|
||||
begin
|
||||
inherited MouseLeave;
|
||||
if FHotLine >= 0 then
|
||||
begin
|
||||
FHotLine := -1;
|
||||
FHotRun := -1;
|
||||
Cursor := crDefault;
|
||||
Invalidate;
|
||||
end;
|
||||
end;
|
||||
|
||||
procedure TArticleView.MouseUp(Button: TMouseButton; Shift: TShiftState;
|
||||
X, Y: Integer);
|
||||
var
|
||||
Url: string;
|
||||
L, R: Integer;
|
||||
begin
|
||||
inherited MouseUp(Button, Shift, X, Y);
|
||||
if Button <> mbLeft then
|
||||
Exit;
|
||||
Url := UrlAt(X, Y, L, R);
|
||||
if Url <> '' then
|
||||
OpenURL(Url);
|
||||
end;
|
||||
|
||||
procedure TArticleView.Paint;
|
||||
var
|
||||
I, J, LineTop: Integer;
|
||||
Run: TRun;
|
||||
Hot: Boolean;
|
||||
begin
|
||||
Canvas.Brush.Color := ThemeSurface;
|
||||
Canvas.Brush.Style := bsSolid;
|
||||
Canvas.FillRect(ClientRect);
|
||||
|
||||
Canvas.Brush.Style := bsClear;
|
||||
for I := 0 to High(FVisual) do
|
||||
begin
|
||||
LineTop := FVisual[I].Top - FScroll;
|
||||
if LineTop + FVisual[I].Height < 0 then
|
||||
Continue;
|
||||
if LineTop > ClientHeight then
|
||||
Break;
|
||||
|
||||
Canvas.Font.Assign(Font);
|
||||
if FVisual[I].Heading then
|
||||
Canvas.Font.Style := [fsBold];
|
||||
|
||||
for J := 0 to High(FVisual[I].Runs) do
|
||||
begin
|
||||
Run := FVisual[I].Runs[J];
|
||||
Hot := (I = FHotLine) and (J = FHotRun);
|
||||
|
||||
if Run.Url <> '' then
|
||||
begin
|
||||
if Hot then
|
||||
Canvas.Font.Color := ThemeAccent
|
||||
else
|
||||
Canvas.Font.Color := ThemeLink;
|
||||
Canvas.Font.Style := Canvas.Font.Style + [fsUnderline];
|
||||
end
|
||||
else if FVisual[I].Heading then
|
||||
Canvas.Font.Color := ThemeText
|
||||
else
|
||||
Canvas.Font.Color := ThemeText;
|
||||
|
||||
Canvas.TextOut(Run.Left, LineTop, Run.Text);
|
||||
|
||||
Canvas.Font.Style := Canvas.Font.Style - [fsUnderline];
|
||||
end;
|
||||
Canvas.Font.Style := [];
|
||||
end;
|
||||
end;
|
||||
|
||||
end.
|
||||
397
src/uColorKeyForm.pas
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
{
|
||||
uColorKeyForm — окно цветового ключа.
|
||||
|
||||
Цветовой ключ выбивает из клипа один цвет: всё, что достаточно на него
|
||||
похоже, становится прозрачным, и сквозь клип видно то, что лежит под
|
||||
ним. Так работает «зелёный экран» в кино, только цвет выбираем мы.
|
||||
|
||||
Главное в этом окне — не поля, а картинка. Цвет берут щелчком прямо по
|
||||
кадру: назвать пурпурный числом человек не сможет, а ткнуть в него
|
||||
мышью — сможет. Выбитое показывается клеткой, как в редакторах
|
||||
изображений: клетка видна там, где будет видно нижний клип.
|
||||
|
||||
Порог считается тем же способом, что и в ffmpeg: расстояние между
|
||||
цветом точки и заданным цветом в пространстве RGB, поделённое на
|
||||
наибольшее возможное. Иначе картинка в окне обещала бы одно, а сборка
|
||||
давала другое.
|
||||
}
|
||||
unit uColorKeyForm;
|
||||
|
||||
{$mode objfpc}{$H+}
|
||||
|
||||
interface
|
||||
|
||||
uses
|
||||
Classes, SysUtils, Controls, Forms, Graphics, StdCtrls, Dialogs, ExtCtrls,
|
||||
ComCtrls, uAppTypes, uProject, uTheme;
|
||||
|
||||
{ Показывает окно. True — человек нажал «Готово», и в AKey лежит
|
||||
изменённое.
|
||||
|
||||
APath — файл клипа, ATime — время внутри файла, откуда брать кадр для
|
||||
показа. }
|
||||
function EditColorKey(var AKey: TColorKey; const APath: string;
|
||||
const ATime: Double; const ATempFile: string): Boolean;
|
||||
|
||||
implementation
|
||||
|
||||
uses
|
||||
Math, FPimage, IntfGraphics, uFfmpegCmd, uProcRunner;
|
||||
|
||||
const
|
||||
{ Клетка под выбитым: две краски и размер квадратика. }
|
||||
ColorCheckA = TColor($4A4A4A);
|
||||
ColorCheckB = TColor($5E5E5E);
|
||||
CheckSize = 8;
|
||||
|
||||
type
|
||||
TColorKeyDialog = class(TComponent)
|
||||
public
|
||||
Form: TForm;
|
||||
pbFrame: TPaintBox;
|
||||
cbOn: TCheckBox;
|
||||
BtnColor: TThemedButton;
|
||||
tbSimilar: TTrackBar;
|
||||
tbBlend: TTrackBar;
|
||||
lblSimilar: TLabel;
|
||||
lblBlend: TLabel;
|
||||
lblHint: TLabel;
|
||||
Key: TColorKey;
|
||||
{ Кадр как он есть и кадр с уже выбитым цветом. Первый нужен, чтобы
|
||||
брать из него цвет по щелчку: во втором выбитое уже заменено
|
||||
клеткой, и попасть по исходному цвету было бы нельзя. }
|
||||
Source: TBitmap;
|
||||
Shown: TBitmap;
|
||||
procedure AnyChange(Sender: TObject);
|
||||
procedure ColorClick(Sender: TObject);
|
||||
procedure FramePaint(Sender: TObject);
|
||||
procedure FrameMouseDown(Sender: TObject; AButton: TMouseButton;
|
||||
AShift: TShiftState; AX, AY: Integer);
|
||||
{ Прямоугольник, в который вписан кадр. }
|
||||
function FrameRect: TRect;
|
||||
procedure Rebuild;
|
||||
procedure UpdateLabels;
|
||||
end;
|
||||
|
||||
{ Насколько точка отличается от заданного цвета: 0 — тот же цвет, 1 —
|
||||
наибольшее возможное расхождение. Повторяет расчёт фильтра colorkey. }
|
||||
function ColorDistance(AR, AG, AB: Integer; AKey: TRgb): Double;
|
||||
var
|
||||
DR, DG, DB: Integer;
|
||||
begin
|
||||
DR := AR - (AKey and $FF);
|
||||
DG := AG - ((AKey shr 8) and $FF);
|
||||
DB := AB - ((AKey shr 16) and $FF);
|
||||
Result := Sqrt((DR * DR + DG * DG + DB * DB) / (255.0 * 255.0 * 3.0));
|
||||
end;
|
||||
|
||||
{ Прозрачность точки после ключа: 0 — выбита совсем, 1 — осталась. }
|
||||
function KeyAlpha(const ADist, ASimilar, ABlend: Double): Double;
|
||||
begin
|
||||
if ABlend > 0.0001 then
|
||||
Result := Max(0, Min(1, (ADist - ASimilar) / ABlend))
|
||||
else if ADist > ASimilar then
|
||||
Result := 1
|
||||
else
|
||||
Result := 0;
|
||||
end;
|
||||
|
||||
{ Кадр с выбитым цветом: где выбито — клетка, где нет — исходная точка,
|
||||
а на смягчённом крае они смешаны.
|
||||
|
||||
Точки берутся через промежуточное изображение, а не через Canvas.Pixels:
|
||||
каждое обращение к Pixels под gtk2 идёт через оконную систему, и на
|
||||
кадре в четверть миллиона точек окно бы просто встало. }
|
||||
procedure ApplyKey(ASrc, ADst: TBitmap; const AKey: TColorKey);
|
||||
var
|
||||
Img: TLazIntfImage;
|
||||
X, Y, R, G, B: Integer;
|
||||
A, Similar, Blend: Double;
|
||||
C: TFPColor;
|
||||
Check: TColor;
|
||||
begin
|
||||
Similar := AKey.Similarity / 100;
|
||||
Blend := AKey.Blend / 100;
|
||||
Img := ASrc.CreateIntfImage;
|
||||
try
|
||||
if AKey.Enabled then
|
||||
for Y := 0 to Img.Height - 1 do
|
||||
for X := 0 to Img.Width - 1 do
|
||||
begin
|
||||
C := Img.Colors[X, Y];
|
||||
R := C.Red shr 8;
|
||||
G := C.Green shr 8;
|
||||
B := C.Blue shr 8;
|
||||
A := KeyAlpha(ColorDistance(R, G, B, AKey.Color), Similar, Blend);
|
||||
if A >= 0.999 then
|
||||
Continue;
|
||||
|
||||
if ((X div CheckSize) + (Y div CheckSize)) and 1 = 0 then
|
||||
Check := ColorCheckA
|
||||
else
|
||||
Check := ColorCheckB;
|
||||
C.Red := Round(R * A + (Check and $FF) * (1 - A)) * 257;
|
||||
C.Green := Round(G * A + ((Check shr 8) and $FF) * (1 - A)) * 257;
|
||||
C.Blue := Round(B * A + ((Check shr 16) and $FF) * (1 - A)) * 257;
|
||||
Img.Colors[X, Y] := C;
|
||||
end;
|
||||
ADst.LoadFromIntfImage(Img);
|
||||
finally
|
||||
Img.Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
function TColorKeyDialog.FrameRect: TRect;
|
||||
var
|
||||
W, H: Integer;
|
||||
Scale: Double;
|
||||
begin
|
||||
Result := Rect(0, 0, 0, 0);
|
||||
if (Source = nil) or (Source.Width = 0) or (Source.Height = 0) then
|
||||
Exit;
|
||||
Scale := Min(pbFrame.Width / Source.Width, pbFrame.Height / Source.Height);
|
||||
W := Max(1, Round(Source.Width * Scale));
|
||||
H := Max(1, Round(Source.Height * Scale));
|
||||
Result := Bounds((pbFrame.Width - W) div 2, (pbFrame.Height - H) div 2, W, H);
|
||||
end;
|
||||
|
||||
procedure TColorKeyDialog.FramePaint(Sender: TObject);
|
||||
var
|
||||
R: TRect;
|
||||
begin
|
||||
pbFrame.Canvas.Brush.Color := ThemeSurface;
|
||||
pbFrame.Canvas.FillRect(0, 0, pbFrame.Width, pbFrame.Height);
|
||||
if Shown = nil then
|
||||
begin
|
||||
pbFrame.Canvas.Font.Color := ThemeText;
|
||||
pbFrame.Canvas.TextOut(8, 8, 'Кадр получить не удалось');
|
||||
Exit;
|
||||
end;
|
||||
R := FrameRect;
|
||||
pbFrame.Canvas.StretchDraw(R, Shown);
|
||||
end;
|
||||
|
||||
procedure TColorKeyDialog.FrameMouseDown(Sender: TObject;
|
||||
AButton: TMouseButton; AShift: TShiftState; AX, AY: Integer);
|
||||
var
|
||||
R: TRect;
|
||||
SX, SY: Integer;
|
||||
begin
|
||||
if Source = nil then
|
||||
Exit;
|
||||
R := FrameRect;
|
||||
if (AX < R.Left) or (AX >= R.Right) or (AY < R.Top) or (AY >= R.Bottom) then
|
||||
Exit;
|
||||
|
||||
SX := Round((AX - R.Left) / (R.Right - R.Left) * Source.Width);
|
||||
SY := Round((AY - R.Top) / (R.Bottom - R.Top) * Source.Height);
|
||||
SX := Max(0, Min(Source.Width - 1, SX));
|
||||
SY := Max(0, Min(Source.Height - 1, SY));
|
||||
|
||||
Key.Color := TRgb(Source.Canvas.Pixels[SX, SY]);
|
||||
BtnColor.Swatch := TColor(Key.Color);
|
||||
{ Ткнули в цвет — значит, его и хотят выбить. Требовать после этого
|
||||
отдельно поставить галку было бы издевательством. }
|
||||
if not cbOn.Checked then
|
||||
cbOn.Checked := True;
|
||||
Rebuild;
|
||||
end;
|
||||
|
||||
procedure TColorKeyDialog.ColorClick(Sender: TObject);
|
||||
var
|
||||
Dlg: TColorDialog;
|
||||
begin
|
||||
Dlg := TColorDialog.Create(nil);
|
||||
try
|
||||
Dlg.Color := TColor(Key.Color);
|
||||
if not Dlg.Execute then
|
||||
Exit;
|
||||
Key.Color := TRgb(Dlg.Color);
|
||||
BtnColor.Swatch := Dlg.Color;
|
||||
Rebuild;
|
||||
finally
|
||||
Dlg.Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
procedure TColorKeyDialog.UpdateLabels;
|
||||
begin
|
||||
lblSimilar.Caption := Format('Допуск: %d%%', [tbSimilar.Position]);
|
||||
lblBlend.Caption := Format('Смягчение края: %d%%', [tbBlend.Position]);
|
||||
end;
|
||||
|
||||
procedure TColorKeyDialog.AnyChange(Sender: TObject);
|
||||
begin
|
||||
Key.Enabled := cbOn.Checked;
|
||||
Key.Similarity := tbSimilar.Position;
|
||||
Key.Blend := tbBlend.Position;
|
||||
UpdateLabels;
|
||||
Rebuild;
|
||||
end;
|
||||
|
||||
procedure TColorKeyDialog.Rebuild;
|
||||
begin
|
||||
if Source = nil then
|
||||
Exit;
|
||||
if Shown = nil then
|
||||
Shown := TBitmap.Create;
|
||||
ApplyKey(Source, Shown, Key);
|
||||
pbFrame.Invalidate;
|
||||
end;
|
||||
|
||||
{ Кадр из середины куска. Первый кадр часто чёрный, и выбирать по нему
|
||||
цвет не по чему. }
|
||||
function GrabFrame(const APath: string; const ATime: Double;
|
||||
const ATempFile: string): TBitmap;
|
||||
var
|
||||
Res: TProcResult;
|
||||
Png: TPortableNetworkGraphic;
|
||||
begin
|
||||
Result := nil;
|
||||
if APath = '' then
|
||||
Exit;
|
||||
Res := RunProcess(FfmpegExe,
|
||||
ArgsPreviewFrame(APath, ATempFile, ATime, ''), nil, nil, nil);
|
||||
if (not Res.Started) or (Res.ExitCode <> 0) or (not FileExists(ATempFile)) then
|
||||
Exit;
|
||||
|
||||
Png := TPortableNetworkGraphic.Create;
|
||||
try
|
||||
try
|
||||
Png.LoadFromFile(ATempFile);
|
||||
Result := TBitmap.Create;
|
||||
{ Формат точек не трогаем. Присвоение ему пересоздаёт хранилище
|
||||
картинки, и только что положенный кадр пропадает — окно
|
||||
показывало ровный чёрный прямоугольник вместо кадра. }
|
||||
Result.Assign(Png);
|
||||
except
|
||||
FreeAndNil(Result);
|
||||
end;
|
||||
finally
|
||||
Png.Free;
|
||||
end;
|
||||
SysUtils.DeleteFile(ATempFile);
|
||||
end;
|
||||
|
||||
function EditColorKey(var AKey: TColorKey; const APath: string;
|
||||
const ATime: Double; const ATempFile: string): Boolean;
|
||||
var
|
||||
Dlg: TColorKeyDialog;
|
||||
BtnOk, BtnCancel: TThemedButton;
|
||||
Lbl: TLabel;
|
||||
begin
|
||||
Result := False;
|
||||
Dlg := TColorKeyDialog.Create(nil);
|
||||
try
|
||||
Dlg.Key := AKey;
|
||||
Dlg.Source := GrabFrame(APath, ATime, ATempFile);
|
||||
|
||||
Dlg.Form := TForm.CreateNew(nil);
|
||||
Dlg.Form.Caption := 'Цветовой ключ';
|
||||
Dlg.Form.Position := poScreenCenter;
|
||||
Dlg.Form.ClientWidth := 760;
|
||||
Dlg.Form.ClientHeight := 560;
|
||||
|
||||
Dlg.pbFrame := TPaintBox.Create(Dlg.Form);
|
||||
Dlg.pbFrame.Parent := Dlg.Form;
|
||||
Dlg.pbFrame.SetBounds(12, 12, 736, 360);
|
||||
Dlg.pbFrame.Anchors := [akLeft, akTop, akRight, akBottom];
|
||||
Dlg.pbFrame.OnPaint := @Dlg.FramePaint;
|
||||
Dlg.pbFrame.OnMouseDown := @Dlg.FrameMouseDown;
|
||||
|
||||
Lbl := TLabel.Create(Dlg.Form);
|
||||
Lbl.Parent := Dlg.Form;
|
||||
Lbl.SetBounds(12, 380, 736, 20);
|
||||
Lbl.Anchors := [akLeft, akRight, akBottom];
|
||||
Lbl.Caption := 'Щёлкните по кадру, чтобы взять цвет. Клетка — то, ' +
|
||||
'что станет прозрачным.';
|
||||
|
||||
Dlg.cbOn := TCheckBox.Create(Dlg.Form);
|
||||
Dlg.cbOn.Parent := Dlg.Form;
|
||||
Dlg.cbOn.SetBounds(12, 408, 220, 22);
|
||||
Dlg.cbOn.Anchors := [akLeft, akBottom];
|
||||
Dlg.cbOn.Caption := 'Выбивать цвет';
|
||||
Dlg.cbOn.Checked := AKey.Enabled;
|
||||
Dlg.cbOn.OnClick := @Dlg.AnyChange;
|
||||
|
||||
Dlg.BtnColor := TThemedButton.Create(Dlg.Form);
|
||||
Dlg.BtnColor.Parent := Dlg.Form;
|
||||
Dlg.BtnColor.SetBounds(240, 404, 120, 28);
|
||||
Dlg.BtnColor.Anchors := [akLeft, akBottom];
|
||||
Dlg.BtnColor.Caption := 'Цвет';
|
||||
Dlg.BtnColor.Swatch := TColor(AKey.Color);
|
||||
Dlg.BtnColor.OnClick := @Dlg.ColorClick;
|
||||
|
||||
Dlg.lblSimilar := TLabel.Create(Dlg.Form);
|
||||
Dlg.lblSimilar.Parent := Dlg.Form;
|
||||
Dlg.lblSimilar.SetBounds(12, 442, 200, 20);
|
||||
Dlg.lblSimilar.Anchors := [akLeft, akBottom];
|
||||
|
||||
Dlg.tbSimilar := TTrackBar.Create(Dlg.Form);
|
||||
Dlg.tbSimilar.Parent := Dlg.Form;
|
||||
Dlg.tbSimilar.SetBounds(210, 438, 538, 30);
|
||||
Dlg.tbSimilar.Anchors := [akLeft, akRight, akBottom];
|
||||
Dlg.tbSimilar.Min := 1;
|
||||
Dlg.tbSimilar.Max := 60;
|
||||
Dlg.tbSimilar.TickStyle := tsNone;
|
||||
Dlg.tbSimilar.Position := Max(1, Min(60, AKey.Similarity));
|
||||
Dlg.tbSimilar.OnChange := @Dlg.AnyChange;
|
||||
|
||||
Dlg.lblBlend := TLabel.Create(Dlg.Form);
|
||||
Dlg.lblBlend.Parent := Dlg.Form;
|
||||
Dlg.lblBlend.SetBounds(12, 478, 200, 20);
|
||||
Dlg.lblBlend.Anchors := [akLeft, akBottom];
|
||||
|
||||
Dlg.tbBlend := TTrackBar.Create(Dlg.Form);
|
||||
Dlg.tbBlend.Parent := Dlg.Form;
|
||||
Dlg.tbBlend.SetBounds(210, 474, 538, 30);
|
||||
Dlg.tbBlend.Anchors := [akLeft, akRight, akBottom];
|
||||
Dlg.tbBlend.Min := 0;
|
||||
Dlg.tbBlend.Max := 40;
|
||||
Dlg.tbBlend.TickStyle := tsNone;
|
||||
Dlg.tbBlend.Position := Max(0, Min(40, AKey.Blend));
|
||||
Dlg.tbBlend.OnChange := @Dlg.AnyChange;
|
||||
|
||||
Dlg.lblHint := TLabel.Create(Dlg.Form);
|
||||
Dlg.lblHint.Parent := Dlg.Form;
|
||||
Dlg.lblHint.SetBounds(12, 512, 480, 36);
|
||||
Dlg.lblHint.Anchors := [akLeft, akRight, akBottom];
|
||||
Dlg.lblHint.AutoSize := False;
|
||||
Dlg.lblHint.WordWrap := True;
|
||||
Dlg.lblHint.Caption := 'Участки с ключом придётся пересобрать: ' +
|
||||
'копированием прозрачность не делается.';
|
||||
|
||||
BtnOk := TThemedButton.Create(Dlg.Form);
|
||||
BtnOk.Parent := Dlg.Form;
|
||||
BtnOk.SetBounds(540, 518, 100, 30);
|
||||
BtnOk.Anchors := [akRight, akBottom];
|
||||
BtnOk.Caption := 'Готово';
|
||||
BtnOk.ModalResult := mrOk;
|
||||
BtnOk.Default := True;
|
||||
|
||||
BtnCancel := TThemedButton.Create(Dlg.Form);
|
||||
BtnCancel.Parent := Dlg.Form;
|
||||
BtnCancel.SetBounds(648, 518, 100, 30);
|
||||
BtnCancel.Anchors := [akRight, akBottom];
|
||||
BtnCancel.Caption := 'Отмена';
|
||||
BtnCancel.ModalResult := mrCancel;
|
||||
BtnCancel.Cancel := True;
|
||||
|
||||
ApplyDarkTheme(Dlg.Form);
|
||||
Dlg.UpdateLabels;
|
||||
Dlg.Rebuild;
|
||||
|
||||
if Dlg.Form.ShowModal <> mrOk then
|
||||
Exit;
|
||||
|
||||
AKey := Dlg.Key;
|
||||
Result := True;
|
||||
finally
|
||||
Dlg.Source.Free;
|
||||
Dlg.Shown.Free;
|
||||
Dlg.Form.Free;
|
||||
Dlg.Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
end.
|
||||
|
|
@ -24,7 +24,7 @@ unit uDeps;
|
|||
interface
|
||||
|
||||
uses
|
||||
Classes, SysUtils;
|
||||
Classes, SysUtils, uAppTypes;
|
||||
|
||||
type
|
||||
TStringArray = array of string;
|
||||
|
|
@ -89,7 +89,7 @@ uses
|
|||
|
||||
function DepTargetDir: string;
|
||||
begin
|
||||
Result := ExtractFilePath(ExpandFileName(ParamStr(0)));
|
||||
Result := ProgramDir;
|
||||
end;
|
||||
|
||||
function DepsConfigPath: string;
|
||||
|
|
|
|||
224
src/uEncodeForm.pas
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
{
|
||||
uEncodeForm — окно настроек сборки с перекодированием.
|
||||
|
||||
Зачем оно нужно. Обычная сборка копирует потоки и потому мгновенна, но
|
||||
отдаёт ролик ровно в том виде, в каком снят исходник. Когда ролик надо
|
||||
отдать под площадку — в 1080p с определённым потоком, — приходится
|
||||
перекодировать, и это уже осознанный выбор, а не поведение по
|
||||
умолчанию. Поэтому отдельный пункт меню и отдельное окно.
|
||||
|
||||
Окно собирается кодом, без файла формы: элементов в нём меньше десятка,
|
||||
и описание заняло бы больше места, чем сам код. Так же сделано окно
|
||||
сообщения в uTheme.
|
||||
|
||||
Перекодирование идёт на видеокарте. Процессорный кодировщик на
|
||||
2688x1512 работает медленнее реального времени, и часовой ролик считал
|
||||
бы часами; выбор кодировщика делает uFfmpegCmd, предпочитая NVENC.
|
||||
Если аппаратного кодировщика нет, окно об этом честно предупреждает.
|
||||
}
|
||||
unit uEncodeForm;
|
||||
|
||||
{$mode objfpc}{$H+}
|
||||
|
||||
interface
|
||||
|
||||
uses
|
||||
Classes, SysUtils, Controls, Forms, Graphics, StdCtrls, Dialogs,
|
||||
uTheme, uExport;
|
||||
|
||||
{ Спрашивает настройки. Возвращает False, если человек передумал.
|
||||
|
||||
ADefaultCodec — кодек проекта: с него разумно начинать, иначе первый же
|
||||
выбор менял бы формат без спроса. }
|
||||
function AskEncodeProfile(out AProfile: TEncodeProfile;
|
||||
const ADefaultCodec: string): Boolean;
|
||||
|
||||
implementation
|
||||
|
||||
uses
|
||||
uFfmpegCmd;
|
||||
|
||||
type
|
||||
TPreset = record
|
||||
Caption: string;
|
||||
Width: Integer;
|
||||
Height: Integer;
|
||||
{ Поток в килобитах в секунду. Значения взяты как обычная рабочая
|
||||
середина для съёмки с движением: меньше — заметны квадраты на
|
||||
траве и воде, больше — файл растёт без видимой пользы. }
|
||||
Bitrate: Integer;
|
||||
end;
|
||||
|
||||
const
|
||||
Presets: array[0..3] of TPreset = (
|
||||
(Caption: '1280x720 (720p)'; Width: 1280; Height: 720; Bitrate: 8000),
|
||||
(Caption: '1920x1080 (1080p)'; Width: 1920; Height: 1080; Bitrate: 16000),
|
||||
(Caption: '2560x1440 (1440p)'; Width: 2560; Height: 1440; Bitrate: 28000),
|
||||
(Caption: '3840x2160 (4K)'; Width: 3840; Height: 2160; Bitrate: 50000)
|
||||
);
|
||||
|
||||
type
|
||||
{ Обработчики живут в объекте: событиям нужны методы. }
|
||||
TEncodeDialog = class(TComponent)
|
||||
public
|
||||
Form: TForm;
|
||||
cbPreset: TComboBox;
|
||||
cbCodec: TComboBox;
|
||||
edBitrate: TEdit;
|
||||
lblEncoder: TLabel;
|
||||
procedure PresetChange(Sender: TObject);
|
||||
procedure CodecChange(Sender: TObject);
|
||||
procedure UpdateEncoderNote;
|
||||
end;
|
||||
|
||||
procedure TEncodeDialog.PresetChange(Sender: TObject);
|
||||
begin
|
||||
if (cbPreset.ItemIndex >= 0) and (cbPreset.ItemIndex <= High(Presets)) then
|
||||
edBitrate.Text := IntToStr(Presets[cbPreset.ItemIndex].Bitrate);
|
||||
end;
|
||||
|
||||
procedure TEncodeDialog.CodecChange(Sender: TObject);
|
||||
begin
|
||||
UpdateEncoderNote;
|
||||
end;
|
||||
|
||||
procedure TEncodeDialog.UpdateEncoderNote;
|
||||
var
|
||||
Codec, Enc: string;
|
||||
begin
|
||||
if cbCodec.ItemIndex = 0 then
|
||||
Codec := 'hevc'
|
||||
else
|
||||
Codec := 'h264';
|
||||
Enc := PreferredEncoder(Codec);
|
||||
|
||||
if Pos('nvenc', Enc) > 0 then
|
||||
begin
|
||||
lblEncoder.Font.Color := ThemeTextDim;
|
||||
lblEncoder.Caption := 'Кодировщик: ' + Enc + ' — на видеокарте.';
|
||||
end
|
||||
else
|
||||
begin
|
||||
{ Предупреждение, а не запрет: пусть человек сам решит, готов ли
|
||||
ждать. Молча уйти на процессор нельзя — разница в разы. }
|
||||
lblEncoder.Font.Color := ThemeAccent;
|
||||
lblEncoder.Caption := 'Кодировщик: ' + Enc +
|
||||
' — аппаратного не нашлось, сборка будет очень долгой.';
|
||||
end;
|
||||
end;
|
||||
|
||||
function AskEncodeProfile(out AProfile: TEncodeProfile;
|
||||
const ADefaultCodec: string): Boolean;
|
||||
var
|
||||
Dlg: TEncodeDialog;
|
||||
Lbl: TLabel;
|
||||
BtnOk, BtnCancel: TThemedButton;
|
||||
I, Bitrate: Integer;
|
||||
begin
|
||||
Result := False;
|
||||
AProfile := Default(TEncodeProfile);
|
||||
|
||||
Dlg := TEncodeDialog.Create(nil);
|
||||
try
|
||||
Dlg.Form := TForm.CreateNew(nil);
|
||||
Dlg.Form.Caption := 'Сборка с перекодированием';
|
||||
Dlg.Form.BorderStyle := bsDialog;
|
||||
Dlg.Form.Position := poScreenCenter;
|
||||
Dlg.Form.ClientWidth := 460;
|
||||
Dlg.Form.ClientHeight := 220;
|
||||
|
||||
Lbl := TLabel.Create(Dlg.Form);
|
||||
Lbl.Parent := Dlg.Form;
|
||||
Lbl.SetBounds(16, 16, 120, 20);
|
||||
Lbl.Caption := 'Размер кадра';
|
||||
|
||||
Dlg.cbPreset := TComboBox.Create(Dlg.Form);
|
||||
Dlg.cbPreset.Parent := Dlg.Form;
|
||||
Dlg.cbPreset.SetBounds(160, 12, 280, 26);
|
||||
Dlg.cbPreset.Style := csDropDownList;
|
||||
for I := 0 to High(Presets) do
|
||||
Dlg.cbPreset.Items.Add(Presets[I].Caption);
|
||||
Dlg.cbPreset.ItemIndex := 1;
|
||||
Dlg.cbPreset.OnChange := @Dlg.PresetChange;
|
||||
|
||||
Lbl := TLabel.Create(Dlg.Form);
|
||||
Lbl.Parent := Dlg.Form;
|
||||
Lbl.SetBounds(16, 56, 140, 20);
|
||||
Lbl.Caption := 'Поток, кбит/с';
|
||||
|
||||
Dlg.edBitrate := TEdit.Create(Dlg.Form);
|
||||
Dlg.edBitrate.Parent := Dlg.Form;
|
||||
Dlg.edBitrate.SetBounds(160, 52, 120, 26);
|
||||
Dlg.edBitrate.Text := IntToStr(Presets[1].Bitrate);
|
||||
|
||||
Lbl := TLabel.Create(Dlg.Form);
|
||||
Lbl.Parent := Dlg.Form;
|
||||
Lbl.SetBounds(16, 96, 140, 20);
|
||||
Lbl.Caption := 'Кодек';
|
||||
|
||||
Dlg.cbCodec := TComboBox.Create(Dlg.Form);
|
||||
Dlg.cbCodec.Parent := Dlg.Form;
|
||||
Dlg.cbCodec.SetBounds(160, 92, 280, 26);
|
||||
Dlg.cbCodec.Style := csDropDownList;
|
||||
Dlg.cbCodec.Items.Add('HEVC (H.265) — меньше файл');
|
||||
Dlg.cbCodec.Items.Add('H.264 — совместимее');
|
||||
if LowerCase(ADefaultCodec) = 'h264' then
|
||||
Dlg.cbCodec.ItemIndex := 1
|
||||
else
|
||||
Dlg.cbCodec.ItemIndex := 0;
|
||||
Dlg.cbCodec.OnChange := @Dlg.CodecChange;
|
||||
|
||||
Dlg.lblEncoder := TLabel.Create(Dlg.Form);
|
||||
Dlg.lblEncoder.Parent := Dlg.Form;
|
||||
Dlg.lblEncoder.SetBounds(16, 132, 424, 36);
|
||||
Dlg.lblEncoder.WordWrap := True;
|
||||
Dlg.lblEncoder.AutoSize := False;
|
||||
Dlg.lblEncoder.ParentColor := False;
|
||||
|
||||
BtnOk := TThemedButton.Create(Dlg.Form);
|
||||
BtnOk.Parent := Dlg.Form;
|
||||
BtnOk.SetBounds(240, 180, 100, 28);
|
||||
BtnOk.Caption := 'Собрать';
|
||||
BtnOk.ModalResult := mrOk;
|
||||
BtnOk.Default := True;
|
||||
|
||||
BtnCancel := TThemedButton.Create(Dlg.Form);
|
||||
BtnCancel.Parent := Dlg.Form;
|
||||
BtnCancel.SetBounds(348, 180, 100, 28);
|
||||
BtnCancel.Caption := 'Отмена';
|
||||
BtnCancel.ModalResult := mrCancel;
|
||||
BtnCancel.Cancel := True;
|
||||
|
||||
{ Цвета задаются до первого показа, как и во всех остальных окнах. }
|
||||
ApplyDarkTheme(Dlg.Form);
|
||||
Dlg.UpdateEncoderNote;
|
||||
|
||||
if Dlg.Form.ShowModal <> mrOk then
|
||||
Exit;
|
||||
|
||||
I := Dlg.cbPreset.ItemIndex;
|
||||
if (I < 0) or (I > High(Presets)) then
|
||||
I := 1;
|
||||
|
||||
Bitrate := StrToIntDef(Trim(Dlg.edBitrate.Text), Presets[I].Bitrate);
|
||||
{ Ноль и отрицательное — не поток, а описка. Верхняя граница выбрана
|
||||
с запасом: выше него кодировщики уже отказываются работать. }
|
||||
if (Bitrate < 100) or (Bitrate > 400000) then
|
||||
Bitrate := Presets[I].Bitrate;
|
||||
|
||||
AProfile.Enabled := True;
|
||||
AProfile.Width := Presets[I].Width;
|
||||
AProfile.Height := Presets[I].Height;
|
||||
AProfile.BitrateKbps := Bitrate;
|
||||
if Dlg.cbCodec.ItemIndex = 1 then
|
||||
AProfile.Codec := 'h264'
|
||||
else
|
||||
AProfile.Codec := 'hevc';
|
||||
Result := True;
|
||||
finally
|
||||
Dlg.Form.Free;
|
||||
Dlg.Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
end.
|
||||
650
src/uExport.pas
|
|
@ -51,6 +51,20 @@ uses
|
|||
Classes, SysUtils, uAppTypes, uKeyframeIndex, uMediaProbe;
|
||||
|
||||
type
|
||||
{ Настройки сборки с перекодированием.
|
||||
|
||||
Enabled = False означает обычную сборку копированием — то, ради чего
|
||||
редактор и делался. Всё остальное имеет смысл только при True. }
|
||||
TEncodeProfile = record
|
||||
Enabled: Boolean;
|
||||
Width: Integer;
|
||||
Height: Integer;
|
||||
BitrateKbps: Integer;
|
||||
{ Кодек в привычных именах: h264 или hevc. Кодировщик под него
|
||||
подбирается сам, с предпочтением аппаратного. }
|
||||
Codec: string;
|
||||
end;
|
||||
|
||||
{ Что получилось с одной частью.
|
||||
|
||||
Кусок монтажа обычно даёт одну часть, но при точном резе — две:
|
||||
|
|
@ -87,6 +101,12 @@ type
|
|||
TExportThread = class(TThread)
|
||||
private
|
||||
FSegments: TFlatSegmentArray;
|
||||
{ Куски звука. Отдельный список: звук можно двигать независимо от
|
||||
картинки, и точки реза у них разные. }
|
||||
FAudio: TFlatSegmentArray;
|
||||
{ Собранная звуковая дорожка и картинка без звука. }
|
||||
FAudioPath: string;
|
||||
FVideoPath: string;
|
||||
FOutputPath: string;
|
||||
FCacheDir: string;
|
||||
FKeepIntermediate: Boolean;
|
||||
|
|
@ -95,6 +115,13 @@ type
|
|||
FStageText: string;
|
||||
FFraction: Double;
|
||||
FPendingLog: string;
|
||||
FLogPath: string;
|
||||
FResume: Boolean;
|
||||
FReusedParts: Integer;
|
||||
{ Имена кусков, доведённых до конца в этой или прошлой сборке. }
|
||||
FDone: TStringList;
|
||||
{ Признак того, что готовые куски относятся к этому же монтажу. }
|
||||
FPlanKey: string;
|
||||
FReport: TExportReport;
|
||||
|
||||
FOnProgress: TNotifyEvent;
|
||||
|
|
@ -110,6 +137,8 @@ type
|
|||
FPacketCount: Int64;
|
||||
|
||||
FMaster: TMediaInfo;
|
||||
FProfile: TEncodeProfile;
|
||||
FSubtitlePath: string;
|
||||
{ Формат промежуточных кусков: 'matroska' или 'mpegts'. }
|
||||
FIntermediateFormat: string;
|
||||
FIntermediateExt: string;
|
||||
|
|
@ -120,6 +149,16 @@ type
|
|||
procedure SyncProgress;
|
||||
procedure SyncLog;
|
||||
procedure Log(const AText: string);
|
||||
procedure WriteLogLine(const AText: string);
|
||||
{ Отпечаток плана сборки: пути, места и длины кусков. По нему видно,
|
||||
что готовые куски сделаны из того же монтажа, а не от прошлой
|
||||
правки. }
|
||||
function PlanKey: string;
|
||||
procedure LoadDoneList;
|
||||
function IsDone(const AName: string): Boolean;
|
||||
procedure MarkDone(const AName: string);
|
||||
{ Готов ли кусок с прошлого раза. Заполняет отчёт по нему. }
|
||||
function TakeReadyPart(AIndex: Integer; out ARep: TSegmentReport): Boolean;
|
||||
procedure SetStage(const AText: string);
|
||||
procedure PublishProgress;
|
||||
|
||||
|
|
@ -134,15 +173,23 @@ type
|
|||
function WriteConcatList(out AListPath: string): Boolean;
|
||||
function ConcatAll(const AListPath: string): Boolean;
|
||||
procedure Verify;
|
||||
{ Сборка звука: куски сводятся в сырой поток и сжимаются одним
|
||||
проходом. Сырой поток нужен для точности — стык получается с
|
||||
точностью до отсчёта, а не до звукового кадра. }
|
||||
function BuildAudio: Boolean;
|
||||
function MixLayers(const ASeg: TFlatSegment; ADest: TStream;
|
||||
ANeed: Int64; ARate, AChannels: Integer): Boolean;
|
||||
function MuxFinal: Boolean;
|
||||
protected
|
||||
procedure Execute; override;
|
||||
public
|
||||
{ AMaster нужен, чтобы понять, влезут ли кодеки проекта в MPEG-TS.
|
||||
Можно передать пустую запись — тогда способ склейки выберется
|
||||
по одному лишь числу источников. }
|
||||
constructor Create(const ASegments: TFlatSegmentArray;
|
||||
destructor Destroy; override;
|
||||
constructor Create(const ASegments, AAudio: TFlatSegmentArray;
|
||||
const AOutputPath: string; AKeepIntermediate: Boolean;
|
||||
const AMaster: TMediaInfo);
|
||||
const AMaster: TMediaInfo; const AProfile: TEncodeProfile);
|
||||
|
||||
procedure RequestCancel;
|
||||
|
||||
|
|
@ -154,6 +201,27 @@ type
|
|||
|
||||
property OnProgress: TNotifyEvent read FOnProgress write FOnProgress;
|
||||
property OnLog: TNotifyEvent read FOnLog write FOnLog;
|
||||
{ Разметка субтитров, которую надо вложить в готовый файл отдельной
|
||||
дорожкой. Пусто — либо текста нет, либо он вжигается в картинку.
|
||||
Годится только для Matroska: MP4 хранит субтитры без оформления. }
|
||||
property SubtitlePath: string read FSubtitlePath write FSubtitlePath;
|
||||
{ Куда писать журнал сборки. Пусто — никуда.
|
||||
|
||||
Журнал кладётся рядом с готовым файлом и под тем же именем: когда
|
||||
через неделю выяснится, что в ролике не то, разбираться придётся
|
||||
именно по нему, а общий журнал программы к тому времени перезапишется
|
||||
следующим запуском. Строка пишется сразу, а не копится в памяти:
|
||||
если сборка упадёт, важна как раз последняя строка. }
|
||||
property LogPath: string read FLogPath write FLogPath;
|
||||
{ Продолжать прерванную сборку: готовые куски берутся с диска, а не
|
||||
считаются заново.
|
||||
|
||||
Что считается готовым, помечено в списке рядом с самими кусками.
|
||||
Проверять по одному только наличию файла нельзя: сборку могли
|
||||
прервать посреди записи, и файл окажется обрезанным. }
|
||||
property Resume: Boolean read FResume write FResume;
|
||||
{ Сколько кусков взято готовыми. Для отчёта. }
|
||||
property ReusedParts: Integer read FReusedParts;
|
||||
end;
|
||||
|
||||
{ Каталог для временных кусков рядом с готовым файлом. }
|
||||
|
|
@ -162,7 +230,33 @@ function CacheDirFor(const AOutputPath: string): string;
|
|||
implementation
|
||||
|
||||
uses
|
||||
Math, uFfmpegCmd, uProcRunner;
|
||||
Math, md5, FileUtil, uFfmpegCmd, uProcRunner;
|
||||
|
||||
{ Размер файла или ноль, если его нет. }
|
||||
function FileSizeOf(const APath: string): Int64;
|
||||
var
|
||||
F: TSearchRec;
|
||||
begin
|
||||
Result := 0;
|
||||
if APath = '' then
|
||||
Exit;
|
||||
if FindFirst(APath, faAnyFile, F) = 0 then
|
||||
begin
|
||||
Result := F.Size;
|
||||
FindClose(F);
|
||||
end;
|
||||
end;
|
||||
|
||||
{ Размер по-человечески: гигабайты, если их много. }
|
||||
function SizeText(ABytes: Int64): string;
|
||||
begin
|
||||
if ABytes >= Int64(1024) * 1024 * 1024 then
|
||||
Result := Format('%.1f ГБ', [ABytes / (1024 * 1024 * 1024)])
|
||||
else if ABytes >= 1024 * 1024 then
|
||||
Result := Format('%.0f МБ', [ABytes / (1024 * 1024)])
|
||||
else
|
||||
Result := Format('%d КБ', [ABytes div 1024]);
|
||||
end;
|
||||
|
||||
function CacheDirFor(const AOutputPath: string): string;
|
||||
begin
|
||||
|
|
@ -171,13 +265,20 @@ begin
|
|||
PathDelim;
|
||||
end;
|
||||
|
||||
constructor TExportThread.Create(const ASegments: TFlatSegmentArray;
|
||||
destructor TExportThread.Destroy;
|
||||
begin
|
||||
FDone.Free;
|
||||
inherited Destroy;
|
||||
end;
|
||||
|
||||
constructor TExportThread.Create(const ASegments, AAudio: TFlatSegmentArray;
|
||||
const AOutputPath: string; AKeepIntermediate: Boolean;
|
||||
const AMaster: TMediaInfo);
|
||||
const AMaster: TMediaInfo; const AProfile: TEncodeProfile);
|
||||
var
|
||||
I: Integer;
|
||||
begin
|
||||
FMaster := AMaster;
|
||||
FProfile := AProfile;
|
||||
inherited Create(True);
|
||||
FreeOnTerminate := False;
|
||||
|
||||
|
|
@ -195,6 +296,14 @@ begin
|
|||
FSegments[I].FilePath := ExpandFileName(FSegments[I].FilePath);
|
||||
end;
|
||||
|
||||
SetLength(FAudio, Length(AAudio));
|
||||
for I := 0 to High(AAudio) do
|
||||
begin
|
||||
FAudio[I] := AAudio[I];
|
||||
if FAudio[I].FilePath <> '' then
|
||||
FAudio[I].FilePath := ExpandFileName(FAudio[I].FilePath);
|
||||
end;
|
||||
|
||||
FOutputPath := ExpandFileName(AOutputPath);
|
||||
FCacheDir := CacheDirFor(FOutputPath);
|
||||
FKeepIntermediate := AKeepIntermediate;
|
||||
|
|
@ -230,8 +339,148 @@ begin
|
|||
FOnLog(Self);
|
||||
end;
|
||||
|
||||
{ Строка в журнал сборки рядом с готовым файлом.
|
||||
|
||||
Файл открывается и закрывается на каждой строке. Это медленно, но строк
|
||||
за сборку десятки, а взамен журнал остаётся целым даже если программа
|
||||
упадёт на следующем шаге — ради этого он и заводится. }
|
||||
procedure TExportThread.WriteLogLine(const AText: string);
|
||||
var
|
||||
F: TextFile;
|
||||
begin
|
||||
if FLogPath = '' then
|
||||
Exit;
|
||||
try
|
||||
AssignFile(F, FLogPath);
|
||||
if FileExists(FLogPath) then
|
||||
Append(F)
|
||||
else
|
||||
Rewrite(F);
|
||||
try
|
||||
WriteLn(F, AText);
|
||||
finally
|
||||
CloseFile(F);
|
||||
end;
|
||||
except
|
||||
{ Журнал не должен ронять сборку: не записалось — и ладно. }
|
||||
on E: Exception do
|
||||
FLogPath := '';
|
||||
end;
|
||||
end;
|
||||
|
||||
function TExportThread.PlanKey: string;
|
||||
var
|
||||
I: Integer;
|
||||
begin
|
||||
Result := Format('%dx%d %d/%d %s %s|', [FMaster.Width, FMaster.Height,
|
||||
FMaster.FpsNum, FMaster.FpsDen, FMaster.VideoCodec, FMaster.PixFmt]);
|
||||
for I := 0 to High(FSegments) do
|
||||
Result := Result + Format('%s@%s+%s#%d%s;',
|
||||
[ExtractFileName(FSegments[I].FilePath),
|
||||
SecondsToStr(FSegments[I].SrcStart),
|
||||
SecondsToStr(FSegments[I].Duration),
|
||||
Length(FSegments[I].Layers),
|
||||
FSegments[I].SubFile]);
|
||||
Result := MD5Print(MD5String(Result));
|
||||
end;
|
||||
|
||||
{ Список готовых кусков рядом с ними самими. Первая строка — отпечаток
|
||||
плана: не совпал, значит куски от другого монтажа и брать их нельзя. }
|
||||
procedure TExportThread.LoadDoneList;
|
||||
var
|
||||
Path: string;
|
||||
begin
|
||||
FDone := TStringList.Create;
|
||||
FPlanKey := PlanKey;
|
||||
Path := FCacheDir + 'готово.txt';
|
||||
|
||||
if not FResume then
|
||||
begin
|
||||
if FileExists(Path) then
|
||||
SysUtils.DeleteFile(Path);
|
||||
Exit;
|
||||
end;
|
||||
|
||||
if not FileExists(Path) then
|
||||
Exit;
|
||||
try
|
||||
FDone.LoadFromFile(Path);
|
||||
except
|
||||
FDone.Clear;
|
||||
end;
|
||||
|
||||
if (FDone.Count = 0) or (FDone[0] <> FPlanKey) then
|
||||
begin
|
||||
Log('Готовые куски от другой расстановки — собираю заново.');
|
||||
FDone.Clear;
|
||||
end;
|
||||
end;
|
||||
|
||||
function TExportThread.IsDone(const AName: string): Boolean;
|
||||
begin
|
||||
Result := (FDone <> nil) and (FDone.IndexOf(AName) > 0) and
|
||||
(FileExists(FCacheDir + AName)) and (FileSize(FCacheDir + AName) > 0);
|
||||
end;
|
||||
|
||||
procedure TExportThread.MarkDone(const AName: string);
|
||||
var
|
||||
Path: string;
|
||||
begin
|
||||
if FDone = nil then
|
||||
Exit;
|
||||
if FDone.Count = 0 then
|
||||
FDone.Add(FPlanKey);
|
||||
if FDone.IndexOf(AName) < 0 then
|
||||
FDone.Add(AName);
|
||||
Path := FCacheDir + 'готово.txt';
|
||||
try
|
||||
{ Записываем сразу: список нужен именно на случай, когда сборка не
|
||||
доживёт до конца. }
|
||||
FDone.SaveToFile(Path);
|
||||
except
|
||||
{ Не записалось — просто соберём заново в следующий раз. }
|
||||
end;
|
||||
end;
|
||||
|
||||
function TExportThread.TakeReadyPart(AIndex: Integer;
|
||||
out ARep: TSegmentReport): Boolean;
|
||||
var
|
||||
Names: array[0..2] of string;
|
||||
I: Integer;
|
||||
begin
|
||||
Result := False;
|
||||
ARep := Default(TSegmentReport);
|
||||
if (FDone = nil) or (FDone.Count = 0) then
|
||||
Exit;
|
||||
|
||||
{ Имя куска зависит от того, каким путём он делался: обычный, с
|
||||
наложением, с точным резом. Проверяем все три. }
|
||||
Names[0] := Format('part_%.3d_a%s', [AIndex, FIntermediateExt]);
|
||||
Names[1] := Format('part_%.3d_x%s', [AIndex, FIntermediateExt]);
|
||||
Names[2] := Format('part_%.3d_b%s', [AIndex, FIntermediateExt]);
|
||||
|
||||
for I := 0 to 1 do
|
||||
if IsDone(Names[I]) then
|
||||
begin
|
||||
{ Кусок с точным резом состоит из двух частей; если вторая тоже
|
||||
готова, её возьмёт следующий заход — здесь достаточно первой. }
|
||||
ARep.Index := AIndex;
|
||||
ARep.SourcePath := FSegments[AIndex].FilePath;
|
||||
ARep.RequestedStart := FSegments[AIndex].SrcStart;
|
||||
ARep.ActualStart := FSegments[AIndex].SrcStart;
|
||||
ARep.Duration := FSegments[AIndex].Duration;
|
||||
ARep.Packets := -1;
|
||||
ARep.Encoded := I = 1;
|
||||
ARep.TempPath := FCacheDir + Names[I];
|
||||
Inc(FReusedParts);
|
||||
Log(Format('Кусок %d уже собран — беру готовый.', [AIndex + 1]));
|
||||
Exit(True);
|
||||
end;
|
||||
end;
|
||||
|
||||
procedure TExportThread.Log(const AText: string);
|
||||
begin
|
||||
WriteLogLine(AText);
|
||||
FPendingLog := AText;
|
||||
Synchronize(@SyncLog);
|
||||
end;
|
||||
|
|
@ -445,13 +694,23 @@ begin
|
|||
Exit;
|
||||
end;
|
||||
|
||||
{ Кусок, доведённый до конца в прошлый раз, берём готовым. На длинном
|
||||
ролике это разница между «собрать заново всё» и «доделать хвост». }
|
||||
if TakeReadyPart(I, Rep) then
|
||||
begin
|
||||
AddPart(Rep);
|
||||
FDoneWork := FDoneWork + FSegments[I].Duration;
|
||||
PublishProgress;
|
||||
Continue;
|
||||
end;
|
||||
|
||||
SetStage(Format('Вырезаю кусок %d из %d', [I + 1, Length(FSegments)]));
|
||||
|
||||
{ Сгенерированные вставки берутся из файла, который заведомо длиннее
|
||||
нужного: так одна и та же чёрная вставка годится для дыр любой
|
||||
длины. Значит вырезать надо и из них, но притягивать к опорному
|
||||
кадру нечего — они начинаются с нуля, а ноль всегда опорный. }
|
||||
if FSegments[I].Kind <> skVideo then
|
||||
if (FSegments[I].Kind <> skVideo) and (FSegments[I].SubFile = '') then
|
||||
begin
|
||||
Dst := FCacheDir + Format('part_%.3d_a%s', [I, FIntermediateExt]);
|
||||
Rep.Index := I;
|
||||
|
|
@ -470,14 +729,56 @@ begin
|
|||
Exit;
|
||||
|
||||
Rep.TempPath := Dst;
|
||||
|
||||
MarkDone(ExtractFileName(Dst));
|
||||
AddPart(Rep);
|
||||
Continue;
|
||||
end;
|
||||
|
||||
{ Кусок от файла, не совпадающего с параметрами проекта. Скопировать
|
||||
его нельзя — пересобираем. Но только его: соседние куски, снятые тем
|
||||
же оборудованием, что и весь проект, по-прежнему копируются. }
|
||||
if FSegments[I].Transcode then
|
||||
{ Участок с наложением: полупрозрачный клип поверх нижнего либо
|
||||
переход между двумя клипами одной дорожки. Собирается из двух
|
||||
входов сразу, копированием такое не получить. }
|
||||
if Length(FSegments[I].Layers) > 0 then
|
||||
begin
|
||||
SetStage(Format('Собираю наложение, кусок %d из %d',
|
||||
[I + 1, Length(FSegments)]));
|
||||
Dst := FCacheDir + Format('part_%.3d_x%s', [I, FIntermediateExt]);
|
||||
Rep.Index := I;
|
||||
Rep.SourcePath := FSegments[I].Layers[0].FilePath;
|
||||
Rep.RequestedStart := FSegments[I].Layers[0].SrcStart;
|
||||
Rep.ActualStart := Rep.RequestedStart;
|
||||
Rep.Duration := FSegments[I].Duration;
|
||||
Rep.Packets := -1;
|
||||
Rep.Encoded := True;
|
||||
|
||||
FStepWork := FSegments[I].Duration;
|
||||
if not RunFfmpeg(ArgsRenderLayers(FSegments[I].Layers, Dst,
|
||||
FSegments[I].Duration, FMaster.Width, FMaster.Height,
|
||||
FMaster.FpsNum, FMaster.FpsDen, FMaster.PixFmt,
|
||||
PreferredEncoder(FMaster.VideoCodec),
|
||||
FIntermediateFormat = 'mpegts', FMaster.VideoBitrate,
|
||||
FSegments[I].SubFile),
|
||||
'Наложение в куске ' + IntToStr(I + 1)) then
|
||||
Exit;
|
||||
|
||||
Rep.TempPath := Dst;
|
||||
|
||||
MarkDone(ExtractFileName(Dst));
|
||||
AddPart(Rep);
|
||||
Continue;
|
||||
end;
|
||||
|
||||
{ Кусок от файла, не совпадающего с параметрами проекта, или с
|
||||
вжигаемым текстом. Скопировать его нельзя — пересобираем. Но только
|
||||
его: соседние куски, снятые тем же оборудованием, что и весь
|
||||
проект, по-прежнему копируются.
|
||||
|
||||
Сгенерированные вставки — картинки и чёрное — сюда попадают только
|
||||
с текстом: без него их незачем перекодировать, они и так в
|
||||
параметрах проекта. Раньше они уходили в ветку выше и копировались
|
||||
вместе с текстом мимо: галка вжигания стояла, а надписи на
|
||||
фотографии не появлялось. }
|
||||
if FSegments[I].Transcode or (FSegments[I].SubFile <> '') then
|
||||
begin
|
||||
SetStage(Format('Пересобираю кусок %d из %d (файл не совпадает с проектом)',
|
||||
[I + 1, Length(FSegments)]));
|
||||
|
|
@ -491,16 +792,27 @@ begin
|
|||
Rep.Encoded := True;
|
||||
|
||||
FStepWork := FSegments[I].Duration;
|
||||
{ У сгенерированной вставки время своё: она начинается с нуля, и
|
||||
просить у неё кусок с середины монтажа бессмысленно. }
|
||||
if FSegments[I].Kind <> skVideo then
|
||||
Rep.RequestedStart := 0
|
||||
else
|
||||
Rep.RequestedStart := FSegments[I].SrcStart;
|
||||
Rep.ActualStart := Rep.RequestedStart;
|
||||
|
||||
if not RunFfmpeg(ArgsTranscodeSegment(FSegments[I].FilePath, Dst,
|
||||
FSegments[I].SrcStart, FSegments[I].SrcStart + FSegments[I].Duration,
|
||||
Rep.RequestedStart, Rep.RequestedStart + FSegments[I].Duration,
|
||||
FMaster.Width, FMaster.Height, FMaster.FpsNum, FMaster.FpsDen,
|
||||
FMaster.VideoCodec, FMaster.PixFmt, FMaster.AudioCodec,
|
||||
FMaster.SampleRate, FMaster.Channels, FSegments[I].Volume,
|
||||
FIntermediateFormat, FSegments[I].Filter),
|
||||
FIntermediateFormat, FSegments[I].Filter, FSegments[I].SubFile,
|
||||
FMaster.VideoBitrate),
|
||||
'Пересборка куска ' + IntToStr(I + 1)) then
|
||||
Exit;
|
||||
|
||||
Rep.TempPath := Dst;
|
||||
|
||||
MarkDone(ExtractFileName(Dst));
|
||||
AddPart(Rep);
|
||||
Continue;
|
||||
end;
|
||||
|
|
@ -548,6 +860,7 @@ begin
|
|||
Rep.Duration := EncodeUntil - FSegments[I].SrcStart;
|
||||
Rep.TempPath := Dst;
|
||||
Rep.Encoded := True;
|
||||
MarkDone(ExtractFileName(Dst));
|
||||
AddPart(Rep);
|
||||
|
||||
if EncodeUntil < SegEnd - 0.0005 then
|
||||
|
|
@ -564,6 +877,7 @@ begin
|
|||
Rep.Duration := SegEnd - EncodeUntil;
|
||||
Rep.TempPath := Dst;
|
||||
Rep.Encoded := False;
|
||||
MarkDone(ExtractFileName(Dst));
|
||||
AddPart(Rep);
|
||||
end;
|
||||
|
||||
|
|
@ -595,6 +909,7 @@ begin
|
|||
Rep.Duration := FSegments[I].Duration;
|
||||
Rep.TempPath := Dst;
|
||||
Rep.Encoded := False;
|
||||
MarkDone(ExtractFileName(Dst));
|
||||
AddPart(Rep);
|
||||
end;
|
||||
|
||||
|
|
@ -632,9 +947,39 @@ end;
|
|||
|
||||
function TExportThread.ConcatAll(const AListPath: string): Boolean;
|
||||
begin
|
||||
SetStage('Склеиваю');
|
||||
FStepWork := FReport.TotalDuration;
|
||||
Result := RunFfmpeg(ArgsConcat(AListPath, FOutputPath), 'Склейка');
|
||||
|
||||
if FProfile.Enabled then
|
||||
begin
|
||||
{ Перекодирование всего ролика одним проходом. Так весь результат
|
||||
выходит из одного кодировщика с одними настройками: пересобирать
|
||||
куски по отдельности здесь нельзя, на стыках была бы видна смена
|
||||
качества. }
|
||||
SetStage('Склеиваю и перекодирую');
|
||||
if RunFfmpeg(ArgsConcatEncode(AListPath, FOutputPath,
|
||||
FProfile.Width, FProfile.Height, FMaster.FpsNum, FMaster.FpsDen,
|
||||
FProfile.BitrateKbps, FProfile.Codec, FMaster.PixFmt, True, FAudioPath),
|
||||
'Склейка с перекодированием') then
|
||||
Exit(True);
|
||||
|
||||
if Terminated then
|
||||
Exit(False);
|
||||
|
||||
{ Аппаратная распаковка не пошла — повторяем без неё.
|
||||
|
||||
Она даёт впятеро, но зависит от драйвера и от того, знаком ли
|
||||
видеокарте кодек исходника. Отказываться из-за неё от сборки
|
||||
нельзя: медленно лучше, чем никак. }
|
||||
Log('Аппаратная распаковка не пошла, повторяю на процессоре.');
|
||||
Exit(RunFfmpeg(ArgsConcatEncode(AListPath, FOutputPath,
|
||||
FProfile.Width, FProfile.Height, FMaster.FpsNum, FMaster.FpsDen,
|
||||
FProfile.BitrateKbps, FProfile.Codec, FMaster.PixFmt, False, FAudioPath),
|
||||
'Склейка с перекодированием без аппаратной распаковки'));
|
||||
end;
|
||||
|
||||
SetStage('Склеиваю');
|
||||
Result := RunFfmpeg(ArgsConcatMux(AListPath, FAudioPath, FSubtitlePath,
|
||||
FOutputPath), 'Склейка');
|
||||
end;
|
||||
|
||||
procedure TExportThread.Verify;
|
||||
|
|
@ -658,10 +1003,235 @@ begin
|
|||
FReport.ActualPackets := Actual;
|
||||
end;
|
||||
|
||||
|
||||
{ --- Звук --- }
|
||||
|
||||
function TExportThread.BuildAudio: Boolean;
|
||||
var
|
||||
RawPath, PartPath: string;
|
||||
All, Part: TFileStream;
|
||||
I: Integer;
|
||||
Need: Int64;
|
||||
Zero: array[0..65535] of Byte;
|
||||
Res: TProcResult;
|
||||
Chunk: Int64;
|
||||
Rate, Channels: Integer;
|
||||
begin
|
||||
Result := False;
|
||||
FAudioPath := '';
|
||||
if Length(FAudio) = 0 then
|
||||
Exit;
|
||||
|
||||
Rate := FMaster.SampleRate;
|
||||
if Rate <= 0 then
|
||||
Rate := 48000;
|
||||
Channels := FMaster.Channels;
|
||||
if Channels <= 0 then
|
||||
Channels := 2;
|
||||
|
||||
RawPath := FCacheDir + 'audio.raw';
|
||||
PartPath := FCacheDir + 'audio_part.raw';
|
||||
FillChar(Zero, SizeOf(Zero), 0);
|
||||
|
||||
SetStage('Собираю звук');
|
||||
|
||||
try
|
||||
All := TFileStream.Create(RawPath, fmCreate);
|
||||
try
|
||||
for I := 0 to High(FAudio) do
|
||||
begin
|
||||
if Terminated then
|
||||
Exit;
|
||||
|
||||
{ Сколько байт должен занять кусок. Считается из длительности, а
|
||||
не берётся у ffmpeg: тогда длина всей дорожки совпадает с
|
||||
монтажом до отсчёта, и звук не уезжает от картинки. }
|
||||
Need := Round(FAudio[I].Duration * Rate) * Channels * 2;
|
||||
|
||||
if Length(FAudio[I].Layers) > 0 then
|
||||
begin
|
||||
{ Переход или наложение: слои сводятся прямо в отсчётах. Здесь
|
||||
это проще и точнее, чем через фильтры ffmpeg — доля каждого
|
||||
слоя считается на каждый отсчёт, без округлений по кадрам. }
|
||||
if MixLayers(FAudio[I], All, Need, Rate, Channels) then
|
||||
Need := 0;
|
||||
end
|
||||
else if FAudio[I].FilePath <> '' then
|
||||
begin
|
||||
Res := RunProcess(FfmpegExe,
|
||||
ArgsExtractAudioRaw(FAudio[I].FilePath, PartPath,
|
||||
FAudio[I].SrcStart, FAudio[I].Duration, Rate, Channels,
|
||||
FAudio[I].Volume),
|
||||
nil, @ErrorLine, @CancelQuery);
|
||||
if Res.Cancelled then
|
||||
Exit;
|
||||
if Res.Started and (Res.ExitCode = 0) and FileExists(PartPath) then
|
||||
begin
|
||||
Part := TFileStream.Create(PartPath, fmOpenRead or fmShareDenyNone);
|
||||
try
|
||||
Chunk := Part.Size;
|
||||
if Chunk > Need then
|
||||
Chunk := Need;
|
||||
if Chunk > 0 then
|
||||
All.CopyFrom(Part, Chunk);
|
||||
Dec(Need, Chunk);
|
||||
finally
|
||||
Part.Free;
|
||||
end;
|
||||
SysUtils.DeleteFile(PartPath);
|
||||
end;
|
||||
end;
|
||||
|
||||
{ Чего не хватило — дополняем тишиной. Так же заполняются дыры и
|
||||
куски файлов без звука. }
|
||||
while Need > 0 do
|
||||
begin
|
||||
Chunk := SizeOf(Zero);
|
||||
if Chunk > Need then
|
||||
Chunk := Need;
|
||||
All.WriteBuffer(Zero, Chunk);
|
||||
Dec(Need, Chunk);
|
||||
end;
|
||||
|
||||
{ Учёт работы: секунда звука весит столько же, сколько секунда
|
||||
картинки на вырезании. }
|
||||
FDoneWork := FDoneWork + FAudio[I].Duration;
|
||||
PublishProgress;
|
||||
end;
|
||||
finally
|
||||
All.Free;
|
||||
end;
|
||||
|
||||
SetStage('Сжимаю звук');
|
||||
FAudioPath := FCacheDir + 'audio.m4a';
|
||||
Res := RunProcess(FfmpegExe,
|
||||
ArgsEncodeAudio(RawPath, FAudioPath, Rate, Channels, FMaster.AudioCodec),
|
||||
@ProgressLine, @ErrorLine, @CancelQuery);
|
||||
if Res.Cancelled then
|
||||
Exit;
|
||||
if (not Res.Started) or (Res.ExitCode <> 0) or (not FileExists(FAudioPath)) then
|
||||
begin
|
||||
FReport.Message := 'Не удалось сжать звук.';
|
||||
Exit;
|
||||
end;
|
||||
|
||||
Result := True;
|
||||
finally
|
||||
SysUtils.DeleteFile(RawPath);
|
||||
SysUtils.DeleteFile(PartPath);
|
||||
end;
|
||||
end;
|
||||
|
||||
{ Сведение слоёв звука в один поток.
|
||||
|
||||
Доля каждого слоя меняется от начала куска к концу: так получается
|
||||
переход. Смешивание идёт по отсчётам, поэтому длина результата ровно
|
||||
такая, какая нужна. }
|
||||
function TExportThread.MixLayers(const ASeg: TFlatSegment; ADest: TStream;
|
||||
ANeed: Int64; ARate, AChannels: Integer): Boolean;
|
||||
var
|
||||
L, I, N: Integer;
|
||||
Parts: array of TMemoryStream;
|
||||
PartPath: string;
|
||||
Res: TProcResult;
|
||||
Src: TFileStream;
|
||||
Samples, S, Ch: Integer;
|
||||
Acc: Double;
|
||||
K: Double;
|
||||
V: SmallInt;
|
||||
Out16: SmallInt;
|
||||
Buf: array of SmallInt;
|
||||
begin
|
||||
Result := False;
|
||||
SetLength(Parts, Length(ASeg.Layers));
|
||||
for I := 0 to High(Parts) do
|
||||
Parts[I] := nil;
|
||||
|
||||
try
|
||||
for L := 0 to High(ASeg.Layers) do
|
||||
begin
|
||||
PartPath := FCacheDir + Format('mix_%d.raw', [L]);
|
||||
Res := RunProcess(FfmpegExe,
|
||||
ArgsExtractAudioRaw(ASeg.Layers[L].FilePath, PartPath,
|
||||
ASeg.Layers[L].SrcStart, ASeg.Duration, ARate, AChannels, 1),
|
||||
nil, @ErrorLine, @CancelQuery);
|
||||
Parts[L] := TMemoryStream.Create;
|
||||
if Res.Started and (Res.ExitCode = 0) and FileExists(PartPath) then
|
||||
begin
|
||||
Src := TFileStream.Create(PartPath, fmOpenRead or fmShareDenyNone);
|
||||
try
|
||||
Parts[L].CopyFrom(Src, Src.Size);
|
||||
finally
|
||||
Src.Free;
|
||||
end;
|
||||
SysUtils.DeleteFile(PartPath);
|
||||
end;
|
||||
Parts[L].Position := 0;
|
||||
end;
|
||||
|
||||
Samples := ANeed div (AChannels * 2);
|
||||
if Samples <= 0 then
|
||||
Exit;
|
||||
SetLength(Buf, AChannels);
|
||||
|
||||
for S := 0 to Samples - 1 do
|
||||
begin
|
||||
for Ch := 0 to AChannels - 1 do
|
||||
begin
|
||||
Acc := 0;
|
||||
for L := 0 to High(ASeg.Layers) do
|
||||
begin
|
||||
{ Доля слоя меняется линейно по ходу куска. }
|
||||
if Samples > 1 then
|
||||
K := ASeg.Layers[L].FadeFrom +
|
||||
(ASeg.Layers[L].FadeTo - ASeg.Layers[L].FadeFrom) * S / (Samples - 1)
|
||||
else
|
||||
K := ASeg.Layers[L].FadeFrom;
|
||||
|
||||
N := (S * AChannels + Ch) * 2;
|
||||
if N + 1 < Parts[L].Size then
|
||||
begin
|
||||
Move(PByte(Parts[L].Memory)[N], V, 2);
|
||||
Acc := Acc + V * K;
|
||||
end;
|
||||
end;
|
||||
if Acc > 32767 then
|
||||
Acc := 32767;
|
||||
if Acc < -32768 then
|
||||
Acc := -32768;
|
||||
Out16 := Round(Acc);
|
||||
Buf[Ch] := Out16;
|
||||
end;
|
||||
ADest.WriteBuffer(Buf[0], AChannels * 2);
|
||||
end;
|
||||
|
||||
Result := True;
|
||||
finally
|
||||
for I := 0 to High(Parts) do
|
||||
Parts[I].Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
function TExportThread.MuxFinal: Boolean;
|
||||
var
|
||||
Res: TProcResult;
|
||||
begin
|
||||
SetStage('Сшиваю');
|
||||
Res := RunProcess(FfmpegExe,
|
||||
ArgsMux(FVideoPath, FAudioPath, FOutputPath, FSubtitlePath),
|
||||
@ProgressLine, @ErrorLine, @CancelQuery);
|
||||
Result := Res.Started and (Res.ExitCode = 0) and FileExists(FOutputPath);
|
||||
if Res.Cancelled then
|
||||
Result := False
|
||||
else if not Result then
|
||||
FReport.Message := 'Не удалось сшить картинку со звуком.';
|
||||
end;
|
||||
|
||||
procedure TExportThread.Execute;
|
||||
var
|
||||
ListPath: string;
|
||||
I: Integer;
|
||||
Freed: Int64;
|
||||
ToolError: string;
|
||||
begin
|
||||
try
|
||||
|
|
@ -688,16 +1258,40 @@ begin
|
|||
Log('Временные куски: ' + FCacheDir);
|
||||
|
||||
ChooseIntermediateFormat;
|
||||
{ Список готовых кусков — после выбора промежуточного формата: от него
|
||||
зависят их имена. }
|
||||
LoadDoneList;
|
||||
if FResume and (FDone.Count > 1) then
|
||||
Log(Format('Продолжаю прерванную сборку: готовых кусков %d.',
|
||||
[FDone.Count - 1]));
|
||||
|
||||
{ Единица учёта работы — секунда материала. Каждая секунда проходит
|
||||
через вырезание и через склейку, отсюда двойной вес. }
|
||||
FTotalWork := FReport.TotalDuration * 2;
|
||||
через вырезание, склейку и сборку звука, отсюда тройной вес. }
|
||||
FTotalWork := FReport.TotalDuration * 3;
|
||||
FDoneWork := 0;
|
||||
|
||||
if not ExtractSegments then
|
||||
Exit;
|
||||
if not WriteConcatList(ListPath) then
|
||||
Exit;
|
||||
|
||||
{ Звук собирается раньше картинки, и склейка сразу кладёт всё вместе в
|
||||
конечный файл.
|
||||
|
||||
Так было не всегда: сперва картинка собиралась в отдельный файл, а
|
||||
потом сшивалась со звуком. На часовом ролике это 91 ГБ
|
||||
промежуточного файла и лишний полный проход копирования — места
|
||||
требовалось вдвое больше готового ролика. }
|
||||
FVideoPath := '';
|
||||
if not BuildAudio then
|
||||
begin
|
||||
if FReport.Cancelled then
|
||||
Exit;
|
||||
{ Без звука ролик всё равно лучше, чем ничего. }
|
||||
Log('Звук собрать не удалось, собираю картинку без него.');
|
||||
FAudioPath := '';
|
||||
end;
|
||||
|
||||
if not ConcatAll(ListPath) then
|
||||
Exit;
|
||||
|
||||
|
|
@ -712,18 +1306,38 @@ begin
|
|||
FReport.Succeeded := True;
|
||||
FReport.Message := 'Готово.';
|
||||
|
||||
{ Уборка после удачной сборки.
|
||||
|
||||
Куски весят столько же, сколько готовый ролик, а на длинном
|
||||
материале и больше: на часовой съёмке набралось 110 ГБ. Держать их
|
||||
после того, как ролик собран, незачем — продолжать больше нечего.
|
||||
После неудачи, наоборот, не трогаем: с них и продолжим.
|
||||
|
||||
Считаем освобождённое: цифра говорит человеку, сколько места было
|
||||
занято, лучше любого объяснения. }
|
||||
if not FKeepIntermediate then
|
||||
begin
|
||||
Freed := 0;
|
||||
for I := 0 to High(FReport.Segments) do
|
||||
{ Удаляем только то, что сами создали в своём каталоге. Части,
|
||||
указывающие на общий кэш сгенерированных вставок, трогать нельзя:
|
||||
они нужны и предпросмотру, и следующей сборке. }
|
||||
if Copy(FReport.Segments[I].TempPath, 1, Length(FCacheDir)) = FCacheDir then
|
||||
begin
|
||||
Inc(Freed, FileSizeOf(FReport.Segments[I].TempPath));
|
||||
DeleteFile(FReport.Segments[I].TempPath);
|
||||
end;
|
||||
|
||||
Inc(Freed, FileSizeOf(FAudioPath));
|
||||
DeleteFile(FAudioPath);
|
||||
DeleteFile(ListPath);
|
||||
DeleteFile(FCacheDir + 'готово.txt');
|
||||
RemoveDir(FCacheDir);
|
||||
Log('Временные куски удалены.');
|
||||
end;
|
||||
Log(Format('Временные куски удалены, освободилось %s.',
|
||||
[SizeText(Freed)]));
|
||||
end
|
||||
else
|
||||
Log('Временные куски оставлены: ' + FCacheDir);
|
||||
except
|
||||
on E: Exception do
|
||||
begin
|
||||
|
|
|
|||
322
src/uExportForm.pas
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
{
|
||||
uExportForm — окно сборки ролика.
|
||||
|
||||
Здесь выбирается не только имя файла, но и то, что от него зависит:
|
||||
контейнер и способ попадания текста в ролик. Разделять их нельзя —
|
||||
выбор одного меняет допустимое в другом.
|
||||
|
||||
Почему при MP4 вжигание становится обязательным. Отдельной дорожкой
|
||||
субтитров со всем оформлением умеет только Matroska. В MP4 их кладут
|
||||
как mov_text, а он не хранит ни цвета, ни обводки, ни положения — от
|
||||
надписи остаётся голый текст в углу, и это хуже, чем ничего. Поэтому
|
||||
при MP4 галка ставится и запирается: выбора всё равно нет.
|
||||
|
||||
Запертой она остаётся ровно пока выбран MP4. Переключение на MKV
|
||||
возвращает и галку, и прежний ответ человека — тот, что он давал до
|
||||
того, как MP4 всё за него решил.
|
||||
}
|
||||
unit uExportForm;
|
||||
|
||||
{$mode objfpc}{$H+}
|
||||
|
||||
interface
|
||||
|
||||
uses
|
||||
Classes, SysUtils, Controls, Forms, Graphics, StdCtrls, Dialogs,
|
||||
uTheme;
|
||||
|
||||
type
|
||||
{ Что человек выбрал в окне. }
|
||||
TExportChoice = record
|
||||
Path: string;
|
||||
{ Вжигать ли текст в картинку. При MP4 всегда True. }
|
||||
BurnText: Boolean;
|
||||
{ Собирать только отмеченный участок. Имеет смысл, только когда
|
||||
участок отмечен. }
|
||||
RegionOnly: Boolean;
|
||||
end;
|
||||
|
||||
{ Показывает окно.
|
||||
|
||||
AHasText — есть ли в проекте надписи: без них про текст и спрашивать
|
||||
незачем. APreferMkv — в монтаже есть точные резы, и MKV надёжнее.
|
||||
ABurnDefault — ответ, данный в прошлый раз.
|
||||
|
||||
ARegion — описание отмеченного участка. Не пусто — в окне появляется
|
||||
галка «только отмеченный участок». Галка, а не надпись: надпись легко
|
||||
проглядеть, а тут вопрос из тех, где ошибка стоит часа работы — соберёшь
|
||||
кусок вместо ролика или наоборот. }
|
||||
function AskExport(var AChoice: TExportChoice; AHasText, APreferMkv: Boolean;
|
||||
const ADefaultDir: string; const ARegion: string = ''): Boolean;
|
||||
|
||||
implementation
|
||||
|
||||
type
|
||||
TExportDialog = class(TComponent)
|
||||
public
|
||||
Form: TForm;
|
||||
edPath: TEdit;
|
||||
BtnMp4: TThemedButton;
|
||||
BtnMkv: TThemedButton;
|
||||
cbBurn: TCheckBox;
|
||||
cbRegion: TCheckBox;
|
||||
lblNote: TLabel;
|
||||
HasText: Boolean;
|
||||
{ Ответ человека про вжигание, сохранённый до того, как MP4 навязал
|
||||
своё. К нему возвращаемся при переходе на MKV. }
|
||||
BurnWanted: Boolean;
|
||||
{ Идёт ли согласование: пока идёт, ответы человека не запоминаются.
|
||||
Установка галки кодом вызывает тот же обработчик, что и щелчок, и
|
||||
без этого признака навязанное при MP4 значение записывалось бы как
|
||||
осознанный выбор — при возврате на MKV галка так и оставалась
|
||||
стоять. }
|
||||
Applying: Boolean;
|
||||
procedure FormatClick(Sender: TObject);
|
||||
procedure BrowseClick(Sender: TObject);
|
||||
procedure BurnClick(Sender: TObject);
|
||||
procedure RegionClick(Sender: TObject);
|
||||
function IsMp4: Boolean;
|
||||
procedure ApplyFormat;
|
||||
end;
|
||||
|
||||
function TExportDialog.IsMp4: Boolean;
|
||||
begin
|
||||
Result := BtnMp4.Down;
|
||||
end;
|
||||
|
||||
{ Приводит имя файла и галку в согласие с выбранным контейнером. }
|
||||
procedure TExportDialog.ApplyFormat;
|
||||
var
|
||||
Ext: string;
|
||||
begin
|
||||
Applying := True;
|
||||
try
|
||||
if IsMp4 then
|
||||
Ext := '.mp4'
|
||||
else
|
||||
Ext := '.mkv';
|
||||
if Trim(edPath.Text) <> '' then
|
||||
edPath.Text := ChangeFileExt(edPath.Text, Ext);
|
||||
|
||||
if not HasText then
|
||||
begin
|
||||
lblNote.Caption := '';
|
||||
Exit;
|
||||
end;
|
||||
|
||||
if IsMp4 then
|
||||
begin
|
||||
{ Сначала запираем, потом ставим: наоборот обработчик успел бы счесть
|
||||
навязанное значение осознанным. }
|
||||
cbBurn.Enabled := False;
|
||||
cbBurn.Checked := True;
|
||||
lblNote.Caption := 'В MP4 отдельная дорожка субтитров теряет цвет, ' +
|
||||
'обводку и положение, поэтому текст вжигается. Участки с текстом ' +
|
||||
'придётся пересобрать.';
|
||||
end
|
||||
else
|
||||
begin
|
||||
cbBurn.Enabled := True;
|
||||
cbBurn.Checked := BurnWanted;
|
||||
if cbBurn.Checked then
|
||||
lblNote.Caption := 'Участки с текстом придётся пересобрать. ' +
|
||||
'Текст будет виден в любом проигрывателе.'
|
||||
else
|
||||
lblNote.Caption := 'Текст ляжет отдельной дорожкой субтитров: ' +
|
||||
'сборка останется мгновенной, но показывать его будет не всякий ' +
|
||||
'проигрыватель.';
|
||||
end;
|
||||
finally
|
||||
Applying := False;
|
||||
end;
|
||||
end;
|
||||
|
||||
procedure TExportDialog.FormatClick(Sender: TObject);
|
||||
begin
|
||||
BtnMp4.Down := Sender = BtnMp4;
|
||||
BtnMkv.Down := Sender = BtnMkv;
|
||||
ApplyFormat;
|
||||
end;
|
||||
|
||||
procedure TExportDialog.BurnClick(Sender: TObject);
|
||||
begin
|
||||
if Applying then
|
||||
Exit;
|
||||
{ Запоминаем только осознанный ответ: при MP4 галка стоит не по воле
|
||||
человека, и запоминать её значило бы соврать ему в следующий раз. }
|
||||
if cbBurn.Enabled then
|
||||
BurnWanted := cbBurn.Checked;
|
||||
ApplyFormat;
|
||||
end;
|
||||
|
||||
{ Галка участка ничего не пересчитывает: она только запоминает ответ.
|
||||
Всё, что от неё зависит, решается уже после закрытия окна. }
|
||||
procedure TExportDialog.RegionClick(Sender: TObject);
|
||||
begin
|
||||
end;
|
||||
|
||||
procedure TExportDialog.BrowseClick(Sender: TObject);
|
||||
var
|
||||
Dlg: TSaveDialog;
|
||||
begin
|
||||
Dlg := TSaveDialog.Create(nil);
|
||||
try
|
||||
Dlg.Title := 'Куда собрать ролик';
|
||||
Dlg.Options := Dlg.Options + [ofOverwritePrompt, ofPathMustExist];
|
||||
if IsMp4 then
|
||||
begin
|
||||
Dlg.DefaultExt := '.mp4';
|
||||
Dlg.Filter := 'MP4|*.mp4';
|
||||
end
|
||||
else
|
||||
begin
|
||||
Dlg.DefaultExt := '.mkv';
|
||||
Dlg.Filter := 'Matroska|*.mkv';
|
||||
end;
|
||||
Dlg.FileName := edPath.Text;
|
||||
if Dlg.Execute then
|
||||
edPath.Text := Dlg.FileName;
|
||||
finally
|
||||
Dlg.Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
function AskExport(var AChoice: TExportChoice; AHasText, APreferMkv: Boolean;
|
||||
const ADefaultDir: string; const ARegion: string): Boolean;
|
||||
var
|
||||
Dlg: TExportDialog;
|
||||
Lbl: TLabel;
|
||||
BtnBrowse, BtnOk, BtnCancel: TThemedButton;
|
||||
Dir: string;
|
||||
begin
|
||||
Result := False;
|
||||
Dlg := TExportDialog.Create(nil);
|
||||
try
|
||||
Dlg.HasText := AHasText;
|
||||
Dlg.BurnWanted := AChoice.BurnText;
|
||||
|
||||
Dlg.Form := TForm.CreateNew(nil);
|
||||
Dlg.Form.Caption := 'Сборка ролика';
|
||||
Dlg.Form.Position := poScreenCenter;
|
||||
Dlg.Form.ClientWidth := 620;
|
||||
Dlg.Form.ClientHeight := 272;
|
||||
|
||||
Lbl := TLabel.Create(Dlg.Form);
|
||||
Lbl.Parent := Dlg.Form;
|
||||
Lbl.SetBounds(12, 12, 200, 20);
|
||||
Lbl.Caption := 'Куда собрать';
|
||||
|
||||
Dlg.edPath := TEdit.Create(Dlg.Form);
|
||||
Dlg.edPath.Parent := Dlg.Form;
|
||||
Dlg.edPath.SetBounds(12, 34, 490, 26);
|
||||
Dlg.edPath.Anchors := [akTop, akLeft, akRight];
|
||||
|
||||
BtnBrowse := TThemedButton.Create(Dlg.Form);
|
||||
BtnBrowse.Parent := Dlg.Form;
|
||||
BtnBrowse.SetBounds(510, 33, 96, 28);
|
||||
BtnBrowse.Anchors := [akTop, akRight];
|
||||
BtnBrowse.Caption := 'Обзор...';
|
||||
BtnBrowse.OnClick := @Dlg.BrowseClick;
|
||||
|
||||
Lbl := TLabel.Create(Dlg.Form);
|
||||
Lbl.Parent := Dlg.Form;
|
||||
Lbl.SetBounds(12, 76, 100, 20);
|
||||
Lbl.Caption := 'Контейнер';
|
||||
|
||||
Dlg.BtnMp4 := TThemedButton.Create(Dlg.Form);
|
||||
Dlg.BtnMp4.Parent := Dlg.Form;
|
||||
Dlg.BtnMp4.SetBounds(110, 72, 90, 28);
|
||||
Dlg.BtnMp4.Caption := 'MP4';
|
||||
Dlg.BtnMp4.TabStop := False;
|
||||
Dlg.BtnMp4.OnClick := @Dlg.FormatClick;
|
||||
|
||||
Dlg.BtnMkv := TThemedButton.Create(Dlg.Form);
|
||||
Dlg.BtnMkv.Parent := Dlg.Form;
|
||||
Dlg.BtnMkv.SetBounds(206, 72, 90, 28);
|
||||
Dlg.BtnMkv.Caption := 'MKV';
|
||||
Dlg.BtnMkv.TabStop := False;
|
||||
Dlg.BtnMkv.OnClick := @Dlg.FormatClick;
|
||||
|
||||
if ARegion <> '' then
|
||||
begin
|
||||
Lbl := TLabel.Create(Dlg.Form);
|
||||
Lbl.Parent := Dlg.Form;
|
||||
Lbl.SetBounds(310, 76, 300, 20);
|
||||
Lbl.Anchors := [akTop, akLeft, akRight];
|
||||
Lbl.Caption := 'Только участок: ' + ARegion;
|
||||
Lbl.Font.Color := TColor($30A0D0);
|
||||
end;
|
||||
|
||||
Dlg.cbRegion := TCheckBox.Create(Dlg.Form);
|
||||
Dlg.cbRegion.Parent := Dlg.Form;
|
||||
Dlg.cbRegion.SetBounds(12, 112, 596, 22);
|
||||
Dlg.cbRegion.Anchors := [akTop, akLeft, akRight];
|
||||
Dlg.cbRegion.Caption := 'Только отмеченный участок: ' + ARegion;
|
||||
Dlg.cbRegion.Visible := ARegion <> '';
|
||||
Dlg.cbRegion.Checked := ARegion <> '';
|
||||
Dlg.cbRegion.OnClick := @Dlg.RegionClick;
|
||||
|
||||
Dlg.cbBurn := TCheckBox.Create(Dlg.Form);
|
||||
Dlg.cbBurn.Parent := Dlg.Form;
|
||||
Dlg.cbBurn.SetBounds(12, 138, 300, 22);
|
||||
Dlg.cbBurn.Caption := 'Вжечь текст в картинку';
|
||||
Dlg.cbBurn.Visible := AHasText;
|
||||
Dlg.cbBurn.OnClick := @Dlg.BurnClick;
|
||||
|
||||
Dlg.lblNote := TLabel.Create(Dlg.Form);
|
||||
Dlg.lblNote.Parent := Dlg.Form;
|
||||
Dlg.lblNote.SetBounds(12, 164, 596, 52);
|
||||
Dlg.lblNote.Anchors := [akTop, akLeft, akRight];
|
||||
Dlg.lblNote.AutoSize := False;
|
||||
Dlg.lblNote.WordWrap := True;
|
||||
|
||||
BtnOk := TThemedButton.Create(Dlg.Form);
|
||||
BtnOk.Parent := Dlg.Form;
|
||||
BtnOk.SetBounds(400, 230, 100, 30);
|
||||
BtnOk.Anchors := [akRight, akBottom];
|
||||
BtnOk.Caption := 'Собрать';
|
||||
BtnOk.ModalResult := mrOk;
|
||||
BtnOk.Default := True;
|
||||
|
||||
BtnCancel := TThemedButton.Create(Dlg.Form);
|
||||
BtnCancel.Parent := Dlg.Form;
|
||||
BtnCancel.SetBounds(508, 230, 100, 30);
|
||||
BtnCancel.Anchors := [akRight, akBottom];
|
||||
BtnCancel.Caption := 'Отмена';
|
||||
BtnCancel.ModalResult := mrCancel;
|
||||
BtnCancel.Cancel := True;
|
||||
|
||||
ApplyDarkTheme(Dlg.Form);
|
||||
|
||||
{ Начальное имя: где были в прошлый раз, с расширением по контейнеру. }
|
||||
Dir := ADefaultDir;
|
||||
if Dir = '' then
|
||||
Dir := GetCurrentDir;
|
||||
if AChoice.Path <> '' then
|
||||
Dlg.edPath.Text := AChoice.Path
|
||||
else
|
||||
Dlg.edPath.Text := IncludeTrailingPathDelimiter(Dir) + 'ролик.mkv';
|
||||
|
||||
{ Точные резы просят Matroska: в MP4 перекодированный стык может
|
||||
воспроизводиться неправильно. }
|
||||
Dlg.BtnMkv.Down := APreferMkv or
|
||||
(LowerCase(ExtractFileExt(Dlg.edPath.Text)) = '.mkv');
|
||||
Dlg.BtnMp4.Down := not Dlg.BtnMkv.Down;
|
||||
Dlg.ApplyFormat;
|
||||
|
||||
if Dlg.Form.ShowModal <> mrOk then
|
||||
Exit;
|
||||
|
||||
AChoice.Path := Trim(Dlg.edPath.Text);
|
||||
if AChoice.Path = '' then
|
||||
Exit;
|
||||
AChoice.BurnText := AHasText and Dlg.cbBurn.Checked;
|
||||
AChoice.RegionOnly := (ARegion <> '') and Dlg.cbRegion.Checked;
|
||||
Result := True;
|
||||
finally
|
||||
Dlg.Form.Free;
|
||||
Dlg.Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
end.
|
||||
|
|
@ -86,7 +86,13 @@ function ArgsTranscodeSegment(const ASrc, ADst: string; AStart, AEnd: Double;
|
|||
AWidth, AHeight, AFpsNum, AFpsDen: Integer;
|
||||
const AVideoCodec, APixFmt, AAudioCodec: string;
|
||||
ASampleRate, AChannels: Integer; AVolume: Double;
|
||||
const AFormat: string; const AFilter: string = ''): TArgs;
|
||||
const AFormat: string; const AFilter: string = '';
|
||||
const ASubFile: string = ''; ABitrate: Int64 = 0): TArgs;
|
||||
|
||||
{ Путь внутри строки фильтра. Двоеточие там разделяет настройки, обратная
|
||||
косая и кавычка имеют своё значение — всё это надо обезвредить, иначе
|
||||
путь с пробелом или двоеточием развалит разбор. }
|
||||
function EscapeFilterPath(const APath: string): string;
|
||||
|
||||
{ Кадр одиночной картинки для окна кадрирования. }
|
||||
function ArgsPreviewFrame(const ASrc, ADst: string; const ATime: Double;
|
||||
|
|
@ -109,7 +115,68 @@ function TsSupportsCodecs(const AVideoCodec, AAudioCodec: string): Boolean;
|
|||
{ Склеить куски по списку. Формат вывода ffmpeg определит по расширению. }
|
||||
function ArgsConcat(const AListFile, ADst: string): TArgs;
|
||||
|
||||
{ Склейка сразу с готовым звуком и разметкой — прямо в конечный файл.
|
||||
|
||||
Зачем. Раньше картинка сначала собиралась в отдельный файл, а потом
|
||||
сшивалась со звуком в конечный. На часовом ролике это означало 91 ГБ
|
||||
промежуточного файла и лишний полный проход копирования: места нужно
|
||||
вдвое больше готового ролика, и всё это ради того, чтобы приписать
|
||||
звуковую дорожку.
|
||||
|
||||
Звук собирается раньше картинки, и склейка сразу кладёт всё вместе куда
|
||||
надо. Пусто в AAudio — значит звука нет, тогда это обычная склейка. }
|
||||
function ArgsConcatMux(const AListFile, AAudio, ASubFile, ADst: string): TArgs;
|
||||
|
||||
{ Склеить куски и сразу перекодировать картинку в заданный размер и поток.
|
||||
|
||||
Нужно для сборки «под площадку»: когда ролик надо отдать в 1080p с
|
||||
определённым битрейтом, а исходники сняты в 2688x1512. Отдельный путь, а
|
||||
не пересборка каждого куска по отдельности: здесь важно, чтобы весь
|
||||
ролик получился одним кодировщиком с одними настройками — иначе на
|
||||
стыках видно смену качества.
|
||||
|
||||
Кодировщик выбирается аппаратный: перекодирование целого ролика на
|
||||
процессоре идёт медленнее реального времени, и смысл теряется. }
|
||||
function ArgsConcatEncode(const AListFile, ADst: string;
|
||||
AWidth, AHeight, AFpsNum, AFpsDen, ABitrateKbps: Integer;
|
||||
const AVideoCodec, APixFmt: string; AHwDecode: Boolean = True;
|
||||
const AAudio: string = ''): TArgs;
|
||||
|
||||
{ Перепаковать файл в другой контейнер без перекодирования. }
|
||||
{ Звук куска в сыром виде: 16 бит со знаком, частота и число каналов
|
||||
проекта. Сырой поток нужен потому, что куски потом просто дописываются
|
||||
друг за другом байтами — так стык получается с точностью до отсчёта, а
|
||||
не до звукового кадра. }
|
||||
function ArgsExtractAudioRaw(const ASource, ATarget: string;
|
||||
const AStart, ADuration: Double; ARate, AChannels: Integer;
|
||||
const AVolume: Double): TArgs;
|
||||
|
||||
{ Сжатие собранного сырого звука. Один проход на весь ролик. }
|
||||
function ArgsEncodeAudio(const ASource, ATarget: string;
|
||||
ARate, AChannels: Integer; const ACodec: string): TArgs;
|
||||
|
||||
{ Пересборка участка с наложением: два слоя сводятся в один кадр.
|
||||
|
||||
Постоянная непрозрачность делается наложением с приглушённым альфа-
|
||||
каналом, переход — фильтром xfade. Их нельзя перепутать: xfade умеет
|
||||
плавно менять долю во времени, а простое наложение — нет.
|
||||
|
||||
ASubFile — разметка текста, которую надо вжечь в этот кусок. Пусто — не
|
||||
вжигать. Без этого текст пропадал везде, где есть наложение: кусок шёл
|
||||
этой веткой, а она про субтитры не знала. }
|
||||
function ArgsRenderLayers(const ALayers: array of TSegmentLayer;
|
||||
const ATarget: string; const ADuration: Double;
|
||||
AWidth, AHeight, AFpsNum, AFpsDen: Integer;
|
||||
const APixFmt, ACodec: string; AIsTs: Boolean;
|
||||
ABitrate: Int64 = 0; const ASubFile: string = ''): TArgs;
|
||||
|
||||
{ Сшивание готовой картинки с готовым звуком. Оба потока копируются.
|
||||
|
||||
ASubFile — разметка субтитров, которую надо вложить отдельной дорожкой.
|
||||
Пусто — не вкладывать. }
|
||||
function ArgsMux(const AVideo, AAudio, ATarget: string;
|
||||
const ASubFile: string = ''): TArgs;
|
||||
|
||||
function ArgsRemux(const ASrc, ADst: string): TArgs;
|
||||
|
||||
{ Сведения о файле в JSON. }
|
||||
|
|
@ -148,7 +215,7 @@ function ArgsNormalize(const ASrc, ADst: string;
|
|||
function ArgsImageClip(const ASrc, ADst: string; ADuration: Double;
|
||||
AWidth, AHeight, AFpsNum, AFpsDen: Integer;
|
||||
const AVideoCodec, APixFmt, AAudioCodec: string;
|
||||
ASampleRate, AChannels: Integer): TArgs;
|
||||
ASampleRate, AChannels: Integer; ABitrate: Int64 = 0): TArgs;
|
||||
|
||||
{ Сделать чёрный клип с тишиной заданной длины.
|
||||
|
||||
|
|
@ -159,6 +226,13 @@ function ArgsBlackClip(const ADst: string; ADuration: Double;
|
|||
const AVideoCodec, APixFmt, AAudioCodec: string;
|
||||
ASampleRate, AChannels: Integer): TArgs;
|
||||
|
||||
{ Вставка тишины для звуковой дорожки: только звук, без картинки.
|
||||
|
||||
Отдельно от чёрного клипа, потому что перемотка внутри видео
|
||||
откатывается к опорному кадру, а в звуке опорным является каждый. }
|
||||
function ArgsSilenceClip(const ADst: string; ADuration: Double;
|
||||
const AAudioCodec: string; ASampleRate, AChannels: Integer): TArgs;
|
||||
|
||||
{ Кадр-миниатюра для списка файлов проекта.
|
||||
|
||||
Кадр берётся не с нуля: у многих съёмок первые секунды это крышка
|
||||
|
|
@ -179,13 +253,204 @@ function ParseProgressLine(const ALine: string; var ASeconds: Double;
|
|||
implementation
|
||||
|
||||
uses
|
||||
StrUtils;
|
||||
StrUtils, uTransform;
|
||||
|
||||
{ Ключи качества. Объявлено в самом начале: ими пользуется отрисовка
|
||||
слоёв, а она по тексту раньше самой функции. }
|
||||
function BitrateArgs(ABitrate: Int64): TArgs; forward;
|
||||
|
||||
{ Общие параметры вывода в формате проекта. Объявлено заранее, потому что
|
||||
используется выше по тексту, чем определено. }
|
||||
function ArgsExtractAudioRaw(const ASource, ATarget: string;
|
||||
const AStart, ADuration: Double; ARate, AChannels: Integer;
|
||||
const AVolume: Double): TArgs;
|
||||
var
|
||||
Filter: string;
|
||||
begin
|
||||
Result := ['-hide_banner', '-nostdin', '-loglevel', 'error', '-y',
|
||||
'-ss', SecondsToStr(AStart), '-t', SecondsToStr(ADuration),
|
||||
'-i', ASource, '-vn', '-map', '0:a:0?'];
|
||||
|
||||
{ Громкость применяется здесь же: звук всё равно перекодируется, второго
|
||||
прохода ради неё не нужно. }
|
||||
if Abs(AVolume - 1) > 0.001 then
|
||||
begin
|
||||
Filter := 'volume=' + SecondsToStr(AVolume);
|
||||
Result := Concat(Result, ['-af', Filter]);
|
||||
end;
|
||||
|
||||
Result := Concat(Result,
|
||||
['-ac', IntToStr(AChannels), '-ar', IntToStr(ARate),
|
||||
'-f', 's16le', ATarget]);
|
||||
end;
|
||||
|
||||
function ArgsEncodeAudio(const ASource, ATarget: string;
|
||||
ARate, AChannels: Integer; const ACodec: string): TArgs;
|
||||
var
|
||||
Codec: string;
|
||||
begin
|
||||
Codec := ACodec;
|
||||
if Codec = '' then
|
||||
Codec := 'aac';
|
||||
Result := ['-hide_banner', '-nostdin', '-loglevel', 'error', '-y',
|
||||
'-f', 's16le', '-ar', IntToStr(ARate), '-ac', IntToStr(AChannels),
|
||||
'-i', ASource, '-c:a', Codec, '-b:a', '192k',
|
||||
'-progress', 'pipe:1', '-nostats', ATarget];
|
||||
end;
|
||||
|
||||
function ArgsRenderLayers(const ALayers: array of TSegmentLayer;
|
||||
const ATarget: string; const ADuration: Double;
|
||||
AWidth, AHeight, AFpsNum, AFpsDen: Integer;
|
||||
const APixFmt, ACodec: string; AIsTs: Boolean;
|
||||
ABitrate: Int64; const ASubFile: string): TArgs;
|
||||
var
|
||||
I: Integer;
|
||||
Graph, Fit, Src, OutLabel: string;
|
||||
Crossfade: Boolean;
|
||||
begin
|
||||
Result := ['-hide_banner', '-nostdin', '-loglevel', 'warning', '-y'];
|
||||
|
||||
{ Каждый слой — отдельный вход, вырезанный ровно по длине участка.
|
||||
|
||||
Неподвижная картинка — случай особый: её надо повторять ключом -loop,
|
||||
иначе ffmpeg отдаёт один-единственный кадр, и весь кусок с наложением
|
||||
схлопывается в одну шестидесятую секунды. Перематывать её тоже
|
||||
незачем: у картинки нет времени. }
|
||||
for I := 0 to High(ALayers) do
|
||||
if ALayers[I].IsImage then
|
||||
Result := Concat(Result,
|
||||
['-loop', '1',
|
||||
'-framerate', Format('%d/%d', [AFpsNum, AFpsDen]),
|
||||
'-t', SecondsToStr(ADuration),
|
||||
'-i', ALayers[I].FilePath])
|
||||
else
|
||||
Result := Concat(Result,
|
||||
['-ss', SecondsToStr(ALayers[I].SrcStart),
|
||||
'-t', SecondsToStr(ADuration),
|
||||
'-i', ALayers[I].FilePath]);
|
||||
|
||||
{ Переход отличается от наложения тем, что доля меняется во времени. }
|
||||
Crossfade := (Length(ALayers) = 2) and
|
||||
((Abs(ALayers[1].FadeFrom - ALayers[1].FadeTo) > 0.001) or
|
||||
(Abs(ALayers[0].FadeFrom - ALayers[0].FadeTo) > 0.001));
|
||||
|
||||
Graph := '';
|
||||
for I := 0 to High(ALayers) do
|
||||
begin
|
||||
{ Нижний слой — холст, он занимает весь кадр, и поля ему нужны.
|
||||
Верхнему поля вредны: чёрное поле непрозрачно и закрыло бы холст,
|
||||
поэтому верхний кладётся на своё место как есть. Переход — случай
|
||||
особый: xfade смешивает два кадра целиком, и оба должны быть в
|
||||
размер проекта. }
|
||||
if (I > 0) and (not Crossfade) and (ALayers[I].FitFilter <> '') then
|
||||
Fit := ALayers[I].FitFilter
|
||||
else
|
||||
Fit := ALayers[I].Filter;
|
||||
if Fit = '' then
|
||||
Fit := Format('scale=%d:%d,setsar=1', [AWidth, AHeight]);
|
||||
{ Цветовой ключ ставится после вписывания и до наложения: он должен
|
||||
работать по кадру проекта, иначе смягчение края разъедется вместе с
|
||||
масштабом. Цвет записывается как 0xRRGGBB — порядок обратный тому,
|
||||
в каком он хранится у нас. }
|
||||
if ALayers[I].KeyEnabled then
|
||||
Fit := Fit + ',' + ColorKeyFilter(ALayers[I].KeyColor,
|
||||
ALayers[I].KeySimilarity, ALayers[I].KeyBlend);
|
||||
Graph := Graph + Format('[%d:v]%s,format=yuva420p[v%d];', [I, Fit, I]);
|
||||
end;
|
||||
|
||||
if Length(ALayers) = 1 then
|
||||
begin
|
||||
Src := 'v0';
|
||||
{ Ключ без нижнего слоя: выбитое надо чем-то заполнить, иначе при
|
||||
переводе в кадр без прозрачности вернётся исходный цвет — тот
|
||||
самый, который выбивали. Кладём на чёрное поле. }
|
||||
if ALayers[0].KeyEnabled then
|
||||
begin
|
||||
Graph := Graph + Format('color=c=black:s=%dx%d:r=%d/%d:d=%s[bg];' +
|
||||
'[bg][v0]overlay=0:0[k0];',
|
||||
[AWidth, AHeight, AFpsNum, AFpsDen, SecondsToStr(ADuration)]);
|
||||
Src := 'k0';
|
||||
end;
|
||||
if Abs(ALayers[0].FadeTo - ALayers[0].FadeFrom) < 0.001 then
|
||||
Graph := Graph + Format('[%s]null[out]', [Src])
|
||||
{ Один слой с меняющейся долей — это затухание в чёрное или из него. }
|
||||
else if ALayers[0].FadeTo >= ALayers[0].FadeFrom then
|
||||
Graph := Graph + Format('[%s]fade=t=in:st=0:d=%s:alpha=0[out]',
|
||||
[Src, SecondsToStr(ADuration)])
|
||||
else
|
||||
Graph := Graph + Format('[%s]fade=t=out:st=0:d=%s:alpha=0[out]',
|
||||
[Src, SecondsToStr(ADuration)]);
|
||||
end
|
||||
else if Crossfade then
|
||||
Graph := Graph + Format('[v0][v1]xfade=transition=fade:duration=%s:offset=0[out]',
|
||||
[SecondsToStr(ADuration)])
|
||||
else
|
||||
begin
|
||||
{ Верхний слой приглушается по альфа-каналу и кладётся на нижний —
|
||||
на своё место в кадре, теми же координатами, по которым его
|
||||
показывает предпросмотр. }
|
||||
Graph := Graph + Format('[v1]colorchannelmixer=aa=%s[t];' +
|
||||
'[v0][t]overlay=%d:%d[out]',
|
||||
[SecondsToStr(ALayers[High(ALayers)].Opacity),
|
||||
ALayers[High(ALayers)].X, ALayers[High(ALayers)].Y]);
|
||||
end;
|
||||
|
||||
{ Текст вжигается последним: он ложится поверх уже сведённой картинки,
|
||||
как и в куске без наложения. Разметка приходит со сдвигом под этот
|
||||
кусок, поэтому время в ней считается от его начала. }
|
||||
if ASubFile <> '' then
|
||||
Graph := Graph + Format(';[out]subtitles=filename=''%s''[outs]',
|
||||
[EscapeFilterPath(ASubFile)]);
|
||||
|
||||
{ Кодировщик выбирается предпочтительный, а не тот, что ffmpeg возьмёт
|
||||
по имени кодека. Имя «hevc» уводит его на процессорный x265, и участок
|
||||
наложения считался медленнее реального времени: замерено 9.6 с
|
||||
материала за 11.2 с работы. }
|
||||
if ASubFile <> '' then
|
||||
OutLabel := '[outs]'
|
||||
else
|
||||
OutLabel := '[out]';
|
||||
Result := Concat(Result,
|
||||
['-filter_complex', Graph, '-map', OutLabel,
|
||||
'-t', SecondsToStr(ADuration),
|
||||
'-c:v', PreferredEncoder(ACodec), '-r', Format('%d/%d', [AFpsNum, AFpsDen]),
|
||||
'-pix_fmt', APixFmt]);
|
||||
Result := Concat(Result, BitrateArgs(ABitrate));
|
||||
Result := Concat(Result,
|
||||
['-bf', '0', '-an', '-avoid_negative_ts', 'make_zero',
|
||||
'-progress', 'pipe:1', '-nostats']);
|
||||
if AIsTs then
|
||||
Result := Concat(Result, ['-f', 'mpegts']);
|
||||
Result := Concat(Result, [ATarget]);
|
||||
end;
|
||||
|
||||
function ArgsMux(const AVideo, AAudio, ATarget: string;
|
||||
const ASubFile: string): TArgs;
|
||||
begin
|
||||
Result := ['-hide_banner', '-nostdin', '-loglevel', 'warning', '-y',
|
||||
'-i', AVideo, '-i', AAudio];
|
||||
|
||||
{ Субтитры отдельной дорожкой: ничего не перекодируется, проигрыватель
|
||||
может их включить или выключить. Кодек ass сохраняет всё оформление —
|
||||
в MP4 такого нет, там субтитры кладутся голым текстом, поэтому туда
|
||||
их и не предлагают. }
|
||||
if ASubFile <> '' then
|
||||
Result := Concat(Result, ['-i', ASubFile]);
|
||||
|
||||
Result := Concat(Result, ['-map', '0:v:0', '-map', '1:a:0']);
|
||||
if ASubFile <> '' then
|
||||
Result := Concat(Result, ['-map', '2:s:0', '-c:s', 'ass',
|
||||
'-metadata:s:s:0', 'title=Надписи',
|
||||
'-metadata:s:s:0', 'language=rus']);
|
||||
|
||||
Result := Concat(Result, ['-c:v', 'copy', '-c:a', 'copy',
|
||||
'-avoid_negative_ts', 'make_zero',
|
||||
'-progress', 'pipe:1', '-nostats', ATarget]);
|
||||
end;
|
||||
|
||||
function MasterOutputArgs(AWidth, AHeight, AFpsNum, AFpsDen: Integer;
|
||||
const AVideoCodec, APixFmt, AAudioCodec: string;
|
||||
ASampleRate, AChannels: Integer): TArgs; forward;
|
||||
ASampleRate, AChannels: Integer; ABitrate: Int64 = 0): TArgs; forward;
|
||||
|
||||
{ Запрет двунаправленных кадров. Объявлено заранее по той же причине. }
|
||||
function NoBFramesArgs: TArgs; forward;
|
||||
|
|
@ -201,7 +466,7 @@ function LocateTool(const AName: string): string;
|
|||
var
|
||||
Own: string;
|
||||
begin
|
||||
Own := ExtractFilePath(ExpandFileName(ParamStr(0))) + AName
|
||||
Own := ProgramDir + AName
|
||||
{$IFDEF WINDOWS} + '.exe'{$ENDIF};
|
||||
if FileExists(Own) then
|
||||
Exit(Own);
|
||||
|
|
@ -387,11 +652,21 @@ begin
|
|||
Result := Concat(Result, ['-f', AFormat, ADst]);
|
||||
end;
|
||||
|
||||
function EscapeFilterPath(const APath: string): string;
|
||||
begin
|
||||
Result := StringReplace(APath, '\', '/', [rfReplaceAll]);
|
||||
Result := StringReplace(Result, '''', '\''', [rfReplaceAll]);
|
||||
Result := StringReplace(Result, ':', '\:', [rfReplaceAll]);
|
||||
end;
|
||||
|
||||
function ArgsTranscodeSegment(const ASrc, ADst: string; AStart, AEnd: Double;
|
||||
AWidth, AHeight, AFpsNum, AFpsDen: Integer;
|
||||
const AVideoCodec, APixFmt, AAudioCodec: string;
|
||||
ASampleRate, AChannels: Integer; AVolume: Double;
|
||||
const AFormat: string; const AFilter: string): TArgs;
|
||||
const AFormat: string; const AFilter: string;
|
||||
const ASubFile: string; ABitrate: Int64): TArgs;
|
||||
var
|
||||
Chain: string;
|
||||
begin
|
||||
Result := Concat(CommonFfmpegPrefix, [
|
||||
'-ss', SecondsToStr(AStart),
|
||||
|
|
@ -404,17 +679,26 @@ begin
|
|||
{ Как именно кадр ложится в кадр проекта, решено заранее и передано
|
||||
готовой цепочкой фильтров. }
|
||||
if AFilter <> '' then
|
||||
Result := Concat(Result, ['-vf', AFilter])
|
||||
Chain := AFilter
|
||||
else
|
||||
Result := Concat(Result, ['-vf', Format(
|
||||
'scale=%d:%d:force_original_aspect_ratio=decrease,pad=%d:%d:(ow-iw)/2:(oh-ih)/2,setsar=1',
|
||||
[AWidth, AHeight, AWidth, AHeight])]);
|
||||
Chain := Format(
|
||||
'scale=%d:%d:force_original_aspect_ratio=decrease,' +
|
||||
'pad=%d:%d:(ow-iw)/2:(oh-ih)/2,setsar=1',
|
||||
[AWidth, AHeight, AWidth, AHeight]);
|
||||
|
||||
{ Текст рисуется последним — уже по кадру проекта. Раньше он попал бы
|
||||
под масштабирование и уехал бы вместе с картинкой. }
|
||||
if ASubFile <> '' then
|
||||
Chain := Chain + ',subtitles=filename=''' +
|
||||
EscapeFilterPath(ASubFile) + '''';
|
||||
|
||||
Result := Concat(Result, ['-vf', Chain]);
|
||||
|
||||
if Abs(AVolume - 1) >= 0.001 then
|
||||
Result := Concat(Result, ['-af', 'volume=' + SecondsToStr(AVolume)]);
|
||||
|
||||
Result := Concat(Result, MasterOutputArgs(0, 0, AFpsNum, AFpsDen,
|
||||
AVideoCodec, APixFmt, AAudioCodec, ASampleRate, AChannels));
|
||||
AVideoCodec, APixFmt, AAudioCodec, ASampleRate, AChannels, ABitrate));
|
||||
Result := Concat(Result, NoBFramesArgs);
|
||||
Result := Concat(Result, ['-avoid_negative_ts', 'make_zero']);
|
||||
Result := Concat(Result, ProgressArgs);
|
||||
|
|
@ -436,6 +720,72 @@ begin
|
|||
(AAudioCodec = 'ac3') or (AAudioCodec = 'mp2');
|
||||
end;
|
||||
|
||||
function ArgsConcatEncode(const AListFile, ADst: string;
|
||||
AWidth, AHeight, AFpsNum, AFpsDen, ABitrateKbps: Integer;
|
||||
const AVideoCodec, APixFmt: string; AHwDecode: Boolean;
|
||||
const AAudio: string): TArgs;
|
||||
var
|
||||
Fps, Enc: string;
|
||||
begin
|
||||
Enc := PreferredEncoder(AVideoCodec);
|
||||
|
||||
Result := Concat(CommonFfmpegPrefix, []);
|
||||
|
||||
{ Распаковка тоже на видеокарте. Замерено на паре кусков 2688x1512:
|
||||
9.18 с без неё против 1.77 с с ней, впятеро.
|
||||
|
||||
Кадры при этом всё равно возвращаются в обычную память, а масштаб
|
||||
считает процессор. Полностью карточный путь через scale_cuda в том же
|
||||
замере оказался медленнее (5.74 с) — видимо, из-за преобразований
|
||||
формата, и городить его незачем.
|
||||
|
||||
Признак необязательный: если распаковка не пойдёт, вызывающий
|
||||
повторит без неё. }
|
||||
if AHwDecode and (Pos('nvenc', Enc) > 0) then
|
||||
Result := Concat(Result, ['-hwaccel', 'cuda']);
|
||||
|
||||
Result := Concat(Result, ['-f', 'concat', '-safe', '0', '-i', AListFile]);
|
||||
{ Готовый звук подмешивается прямо здесь, чтобы не собирать картинку в
|
||||
промежуточный файл ради одной звуковой дорожки. }
|
||||
if AAudio <> '' then
|
||||
Result := Concat(Result, ['-i', AAudio, '-map', '0:v:0', '-map', '1:a:0'])
|
||||
else
|
||||
Result := Concat(Result, ['-map', '0:v:0', '-map', '0:a:0?']);
|
||||
|
||||
{ Вписываем целиком и добиваем полями: соотношение сторон менять нельзя,
|
||||
иначе кадр растянется. }
|
||||
if (AWidth > 0) and (AHeight > 0) then
|
||||
Result := Concat(Result, ['-vf', Format(
|
||||
'scale=%d:%d:force_original_aspect_ratio=decrease,' +
|
||||
'pad=%d:%d:(ow-iw)/2:(oh-ih)/2,setsar=1',
|
||||
[AWidth, AHeight, AWidth, AHeight])]);
|
||||
|
||||
Result := Concat(Result, ['-c:v', Enc]);
|
||||
if ABitrateKbps > 0 then
|
||||
{ Верхняя граница и запас буфера задаются вместе со средним потоком:
|
||||
без них кодировщик волен выдать всплеск вдвое выше заказанного. }
|
||||
Result := Concat(Result, [
|
||||
'-b:v', IntToStr(ABitrateKbps) + 'k',
|
||||
'-maxrate', IntToStr(Round(ABitrateKbps * 1.5)) + 'k',
|
||||
'-bufsize', IntToStr(ABitrateKbps * 2) + 'k']);
|
||||
|
||||
Fps := '';
|
||||
if (AFpsNum > 0) and (AFpsDen > 0) then
|
||||
Fps := IntToStr(AFpsNum) + '/' + IntToStr(AFpsDen);
|
||||
if Fps <> '' then
|
||||
Result := Concat(Result, ['-r', Fps]);
|
||||
if APixFmt <> '' then
|
||||
Result := Concat(Result, ['-pix_fmt', APixFmt]);
|
||||
|
||||
{ Звук уже собран и сжат отдельным проходом — трогать его незачем. }
|
||||
Result := Concat(Result, ['-c:a', 'copy']);
|
||||
|
||||
Result := Concat(Result, ProgressArgs);
|
||||
if WantsFaststart(ADst) then
|
||||
Result := Concat(Result, ['-movflags', '+faststart']);
|
||||
Result := Concat(Result, [ADst]);
|
||||
end;
|
||||
|
||||
function ArgsConcat(const AListFile, ADst: string): TArgs;
|
||||
begin
|
||||
Result := Concat(CommonFfmpegPrefix, [
|
||||
|
|
@ -453,6 +803,46 @@ begin
|
|||
Result := Concat(Result, [ADst]);
|
||||
end;
|
||||
|
||||
function ArgsConcatMux(const AListFile, AAudio, ASubFile, ADst: string): TArgs;
|
||||
var
|
||||
Next: Integer;
|
||||
begin
|
||||
Result := Concat(CommonFfmpegPrefix, [
|
||||
'-f', 'concat',
|
||||
{ Без -safe 0 демультиплексор откажется брать абсолютные пути. }
|
||||
'-safe', '0',
|
||||
'-i', AListFile
|
||||
]);
|
||||
|
||||
Next := 1;
|
||||
if AAudio <> '' then
|
||||
Result := Concat(Result, ['-i', AAudio]);
|
||||
if ASubFile <> '' then
|
||||
Result := Concat(Result, ['-i', ASubFile]);
|
||||
|
||||
Result := Concat(Result, ['-map', '0:v:0']);
|
||||
if AAudio <> '' then
|
||||
begin
|
||||
Result := Concat(Result, ['-map', IntToStr(Next) + ':a:0']);
|
||||
Inc(Next);
|
||||
end
|
||||
else
|
||||
{ Звука своего нет — берём тот, что пришёл с кусками, если он там есть. }
|
||||
Result := Concat(Result, ['-map', '0:a:0?']);
|
||||
|
||||
if ASubFile <> '' then
|
||||
Result := Concat(Result, ['-map', IntToStr(Next) + ':s:0', '-c:s', 'ass',
|
||||
'-metadata:s:s:0', 'title=Надписи',
|
||||
'-metadata:s:s:0', 'language=rus']);
|
||||
|
||||
Result := Concat(Result, ['-c:v', 'copy', '-c:a', 'copy',
|
||||
'-avoid_negative_ts', 'make_zero']);
|
||||
Result := Concat(Result, ProgressArgs);
|
||||
if WantsFaststart(ADst) then
|
||||
Result := Concat(Result, ['-movflags', '+faststart']);
|
||||
Result := Concat(Result, [ADst]);
|
||||
end;
|
||||
|
||||
function ArgsRemux(const ASrc, ADst: string): TArgs;
|
||||
begin
|
||||
Result := Concat(CommonFfmpegPrefix, [
|
||||
|
|
@ -623,9 +1013,31 @@ begin
|
|||
end;
|
||||
|
||||
{ Реализация объявленной выше функции. }
|
||||
{ Ключи качества для пересобираемых кусков.
|
||||
|
||||
Без них кодировщик берёт своё значение по умолчанию, а оно скромное: на
|
||||
съёмке 2688x1512 с 56 Мбит/с NVENC выдавал 5.9 Мбит/с, и пересобранный
|
||||
кусок бросался в глаза среди скопированных. Поэтому целимся в тот же
|
||||
битрейт, что у исходного материала.
|
||||
|
||||
Кроме среднего задаётся потолок и размер буфера: без них кодировщик
|
||||
разгоняется на сложных кадрах ровно настолько, насколько ему позволит
|
||||
средний, и мелкие детали всё равно пропадают. }
|
||||
function BitrateArgs(ABitrate: Int64): TArgs;
|
||||
begin
|
||||
Result := [];
|
||||
{ Ниже ста килобит в секунду — это не битрейт, а неизвестность: ffprobe
|
||||
не всегда его сообщает. Тогда оставляем решение кодировщику. }
|
||||
if ABitrate < 100000 then
|
||||
Exit;
|
||||
Result := ['-b:v', IntToStr(ABitrate div 1000) + 'k',
|
||||
'-maxrate', IntToStr(ABitrate * 3 div 2000) + 'k',
|
||||
'-bufsize', IntToStr(ABitrate div 500) + 'k'];
|
||||
end;
|
||||
|
||||
function MasterOutputArgs(AWidth, AHeight, AFpsNum, AFpsDen: Integer;
|
||||
const AVideoCodec, APixFmt, AAudioCodec: string;
|
||||
ASampleRate, AChannels: Integer): TArgs;
|
||||
ASampleRate, AChannels: Integer; ABitrate: Int64): TArgs;
|
||||
var
|
||||
Fps: string;
|
||||
begin
|
||||
|
|
@ -641,6 +1053,7 @@ begin
|
|||
Result := Concat(Result, ['-r', Fps]);
|
||||
if APixFmt <> '' then
|
||||
Result := Concat(Result, ['-pix_fmt', APixFmt]);
|
||||
Result := Concat(Result, BitrateArgs(ABitrate));
|
||||
|
||||
if AAudioCodec <> '' then
|
||||
begin
|
||||
|
|
@ -673,7 +1086,7 @@ end;
|
|||
function ArgsImageClip(const ASrc, ADst: string; ADuration: Double;
|
||||
AWidth, AHeight, AFpsNum, AFpsDen: Integer;
|
||||
const AVideoCodec, APixFmt, AAudioCodec: string;
|
||||
ASampleRate, AChannels: Integer): TArgs;
|
||||
ASampleRate, AChannels: Integer; ABitrate: Int64): TArgs;
|
||||
begin
|
||||
{ -loop 1 повторяет единственный кадр, -t задаёт длину.
|
||||
Картинка вписывается в кадр целиком с чёрными полями по краям:
|
||||
|
|
@ -696,7 +1109,7 @@ begin
|
|||
[AWidth, AHeight, AWidth, AHeight])]);
|
||||
|
||||
Result := Concat(Result, MasterOutputArgs(0, 0, AFpsNum, AFpsDen,
|
||||
AVideoCodec, APixFmt, AAudioCodec, ASampleRate, AChannels));
|
||||
AVideoCodec, APixFmt, AAudioCodec, ASampleRate, AChannels, ABitrate));
|
||||
Result := Concat(Result, NoBFramesArgs);
|
||||
Result := Concat(Result, ['-shortest']);
|
||||
Result := Concat(Result, ProgressArgs);
|
||||
|
|
@ -733,6 +1146,32 @@ begin
|
|||
Result := Concat(Result, [ADst]);
|
||||
end;
|
||||
|
||||
function ArgsSilenceClip(const ADst: string; ADuration: Double;
|
||||
const AAudioCodec: string; ASampleRate, AChannels: Integer): TArgs;
|
||||
var
|
||||
Codec: string;
|
||||
Ch: Integer;
|
||||
begin
|
||||
Codec := AAudioCodec;
|
||||
if Codec = '' then
|
||||
Codec := 'aac';
|
||||
Ch := AChannels;
|
||||
if Ch < 1 then
|
||||
Ch := 2;
|
||||
|
||||
Result := Concat(CommonFfmpegPrefix, [
|
||||
'-f', 'lavfi',
|
||||
'-i', Format('anullsrc=channel_layout=%s:sample_rate=%d',
|
||||
[IfThen(Ch = 1, 'mono', 'stereo'), ASampleRate]),
|
||||
'-c:a', Codec,
|
||||
'-ac', IntToStr(Ch),
|
||||
'-ar', IntToStr(ASampleRate),
|
||||
'-t', SecondsToStr(ADuration)
|
||||
]);
|
||||
Result := Concat(Result, ProgressArgs);
|
||||
Result := Concat(Result, [ADst]);
|
||||
end;
|
||||
|
||||
function ArgsThumbnail(const ASrc, ADst: string; const ATime: Double;
|
||||
AWidth, AHeight: Integer): TArgs;
|
||||
begin
|
||||
|
|
|
|||
907
src/uFlatten.pas
|
|
@ -53,6 +53,14 @@ type
|
|||
|
||||
property StageText: string read FStageText;
|
||||
property Fraction: Double read FFraction;
|
||||
{ Сколько всего заказано и сколько уже сделано. Нужно показателю
|
||||
работы: по ним он знает, докуда ползти до следующей вехи. }
|
||||
function Total: Integer;
|
||||
property Done: Integer read FCurrent;
|
||||
{ Место на монтаже, которое считается прямо сейчас. Ноль в обоих —
|
||||
значит, к монтажу это отношения не имеет. }
|
||||
function BusyFrom: Double;
|
||||
function BusyTo: Double;
|
||||
property PendingLog: string read FPendingLog;
|
||||
property Succeeded: Boolean read FSucceeded;
|
||||
property Cancelled: Boolean read FCancelled;
|
||||
|
|
@ -113,6 +121,27 @@ begin
|
|||
Log('ffmpeg: ' + ALine);
|
||||
end;
|
||||
|
||||
function TGenerateThread.Total: Integer;
|
||||
begin
|
||||
Result := Length(FRequests);
|
||||
end;
|
||||
|
||||
function TGenerateThread.BusyFrom: Double;
|
||||
begin
|
||||
if (FCurrent >= 0) and (FCurrent <= High(FRequests)) then
|
||||
Result := FRequests[FCurrent].TlStart
|
||||
else
|
||||
Result := 0;
|
||||
end;
|
||||
|
||||
function TGenerateThread.BusyTo: Double;
|
||||
begin
|
||||
if (FCurrent >= 0) and (FCurrent <= High(FRequests)) then
|
||||
Result := FRequests[FCurrent].TlEnd
|
||||
else
|
||||
Result := 0;
|
||||
end;
|
||||
|
||||
procedure TGenerateThread.Publish;
|
||||
begin
|
||||
if Length(FRequests) > 0 then
|
||||
|
|
@ -175,6 +204,12 @@ begin
|
|||
gkImage:
|
||||
FStageText := Format('Делаю клип из картинки %d из %d',
|
||||
[I + 1, Length(FRequests)]);
|
||||
gkSilence:
|
||||
FStageText := Format('Делаю вставку тишины %d из %d',
|
||||
[I + 1, Length(FRequests)]);
|
||||
gkLayers:
|
||||
FStageText := Format('Собираю наложение %d из %d',
|
||||
[I + 1, Length(FRequests)]);
|
||||
end;
|
||||
Publish;
|
||||
|
||||
|
|
@ -191,11 +226,26 @@ begin
|
|||
FMaster.VideoCodec, FMaster.PixFmt, AudioCodec,
|
||||
FMaster.SampleRate, FMaster.Channels);
|
||||
gkImage:
|
||||
{ Фотография попадает в ролик наравне со съёмкой, и жать её
|
||||
сильнее, чем её соседей, незачем. }
|
||||
Args := ArgsImageClip(FRequests[I].SourcePath, FRequests[I].TargetPath,
|
||||
FRequests[I].Duration,
|
||||
FMaster.Width, FMaster.Height, FMaster.FpsNum, FMaster.FpsDen,
|
||||
FMaster.VideoCodec, FMaster.PixFmt, AudioCodec,
|
||||
FMaster.SampleRate, FMaster.Channels, FMaster.VideoBitrate);
|
||||
gkSilence:
|
||||
Args := ArgsSilenceClip(FRequests[I].TargetPath,
|
||||
FRequests[I].Duration, AudioCodec,
|
||||
FMaster.SampleRate, FMaster.Channels);
|
||||
gkLayers:
|
||||
{ Тот же расчёт, что и при сборке ролика: прозрачность —
|
||||
наложением с приглушённым альфа-каналом, переход — фильтром
|
||||
xfade. Разными путями считать одно и то же нельзя, иначе
|
||||
предпросмотр разойдётся с готовым роликом. }
|
||||
Args := ArgsRenderLayers(FRequests[I].Layers,
|
||||
FRequests[I].TargetPath, FRequests[I].Duration,
|
||||
FMaster.Width, FMaster.Height, FMaster.FpsNum, FMaster.FpsDen,
|
||||
FMaster.PixFmt, FMaster.VideoCodec, False, FMaster.VideoBitrate);
|
||||
else
|
||||
Continue;
|
||||
end;
|
||||
|
|
|
|||
488
src/uIconData.pas
Normal file
|
|
@ -0,0 +1,488 @@
|
|||
{
|
||||
uIconData — значки, вшитые в программу.
|
||||
|
||||
Собран из файлов в icons_online командой:
|
||||
|
||||
python3 tools/make_icons.py
|
||||
|
||||
Правится не он, а исходные картинки: этот файл переписывается целиком.
|
||||
|
||||
Значки взяты у Icons8 и требуют указания авторства — оно есть в окне
|
||||
«О программе», вкладка «Библиотеки и авторство». Ссылки на каждый значок
|
||||
лежат в icons_online/attribution.txt.
|
||||
|
||||
Картинки чёрные на прозрачном фоне. Цвет им задаётся при отрисовке:
|
||||
берётся прозрачность точки, а цвет — тот, каким сейчас рисуется кнопка.
|
||||
Поэтому один и тот же значок годится и для тёмной кнопки, и для
|
||||
оранжевой.
|
||||
}
|
||||
unit uIconData;
|
||||
|
||||
{$mode objfpc}{$H+}
|
||||
|
||||
interface
|
||||
|
||||
const
|
||||
{ icons8-магнит-30.png, 24 на 24 точек }
|
||||
IconSnapData: array[0..472] of Byte = (
|
||||
$89, $50, $4E, $47, $0D, $0A, $1A, $0A, $00, $00, $00, $0D, $49, $48, $44, $52,
|
||||
$00, $00, $00, $18, $00, $00, $00, $18, $08, $04, $00, $00, $00, $4A, $7E, $F5,
|
||||
$73, $00, $00, $00, $20, $63, $48, $52, $4D, $00, $00, $7A, $26, $00, $00, $80,
|
||||
$84, $00, $00, $FA, $00, $00, $00, $80, $E8, $00, $00, $75, $30, $00, $00, $EA,
|
||||
$60, $00, $00, $3A, $98, $00, $00, $17, $70, $9C, $BA, $51, $3C, $00, $00, $00,
|
||||
$02, $62, $4B, $47, $44, $00, $00, $AA, $8D, $23, $32, $00, $00, $00, $09, $70,
|
||||
$48, $59, $73, $00, $00, $0B, $13, $00, $00, $0B, $13, $01, $00, $9A, $9C, $18,
|
||||
$00, $00, $00, $07, $74, $49, $4D, $45, $07, $EA, $08, $1F, $07, $2E, $1B, $C7,
|
||||
$2C, $48, $09, $00, $00, $01, $3E, $49, $44, $41, $54, $38, $CB, $BD, $92, $31,
|
||||
$4B, $C3, $40, $14, $C7, $7F, $97, $44, $29, $68, $03, $5A, $45, $EA, $D0, $A5,
|
||||
$6B, $41, $44, $5C, $0A, $0E, $3A, $15, $DC, $1D, $DD, $FB, $51, $74, $71, $29,
|
||||
$F8, $39, $EC, $62, $06, $17, $5D, $EA, $22, $D2, $88, $93, $E0, $22, $68, $A1,
|
||||
$AD, $14, $AA, $A0, $B6, $E6, $39, $24, $17, $AE, $69, $32, $88, $D4, $77, $4B,
|
||||
$EE, $DD, $EF, $7F, $EF, $DD, $FB, $07, $66, $1D, $6A, $6A, $EF, $B2, $4D, $85,
|
||||
$22, $F3, $74, $B9, $E3, $9A, $1E, $92, $2D, $5F, $E0, $80, $0B, $9E, $78, $27,
|
||||
$40, $F8, $E4, $85, $4B, $0E, $71, $B3, $F0, $35, $4E, $78, $45, $12, $6B, $40,
|
||||
$83, $62, $1A, $BE, $44, $83, $AF, $29, $5C, $10, $46, $9C, $B2, $9C, $C4, $1D,
|
||||
$EA, $0C, $53, $71, $41, $78, $A3, $8E, $33, $29, $28, $E1, $67, $E2, $82, $E0,
|
||||
$53, $0A, $41, $2B, $9A, $CD, $1E, $65, $43, $3E, $C0, $C7, $67, $60, $64, $CA,
|
||||
$EC, $86, $13, $D5, $82, $2A, $B9, $F8, $B0, $C7, $11, $35, $6A, $1C, $D3, $8F,
|
||||
$73, $39, $AA, $A6, $05, $16, $9E, $51, $BE, $49, $01, $80, $02, $4D, $23, $EB,
|
||||
$85, $97, $EB, $0A, $E6, $AC, $3B, $D1, $CD, $7D, $3A, $46, $D6, $35, $5B, $9A,
|
||||
$74, $5C, $52, $BE, $62, $C2, $E2, $97, $31, $7B, $81, $F6, $4F, $F1, $41, $1B,
|
||||
$61, $8E, $AD, $04, $71, $C3, $08, $C5, $86, $7E, $83, $16, $D8, $DC, $B3, $C3,
|
||||
$37, $AB, $3C, $18, $03, $50, $0C, $D9, $A7, $8B, $4D, $0B, $FB, $8F, $2D, $8D,
|
||||
$D9, $A4, $85, $E0, $90, $27, $88, $4F, $03, $F2, $78, $8C, $51, $54, $B8, $D5,
|
||||
$AD, $84, $A5, $D7, $59, $C4, $02, $84, $47, $CE, $68, $47, $82, $02, $2B, $11,
|
||||
$F1, $CC, $39, $57, $88, $B6, $43, $A1, $8C, $CE, $83, $D8, $30, $65, $B4, $1C,
|
||||
$FE, $20, $FF, $10, $3F, $90, $74, $7D, $D8, $DF, $AB, $6B, $DD, $00, $00, $00,
|
||||
$00, $49, $45, $4E, $44, $AE, $42, $60, $82
|
||||
);
|
||||
|
||||
{ icons8-merge-vertical-64.png, 24 на 24 точек }
|
||||
IconRippleData: array[0..457] of Byte = (
|
||||
$89, $50, $4E, $47, $0D, $0A, $1A, $0A, $00, $00, $00, $0D, $49, $48, $44, $52,
|
||||
$00, $00, $00, $18, $00, $00, $00, $18, $08, $04, $00, $00, $00, $4A, $7E, $F5,
|
||||
$73, $00, $00, $00, $20, $63, $48, $52, $4D, $00, $00, $7A, $26, $00, $00, $80,
|
||||
$84, $00, $00, $FA, $00, $00, $00, $80, $E8, $00, $00, $75, $30, $00, $00, $EA,
|
||||
$60, $00, $00, $3A, $98, $00, $00, $17, $70, $9C, $BA, $51, $3C, $00, $00, $00,
|
||||
$02, $62, $4B, $47, $44, $00, $00, $AA, $8D, $23, $32, $00, $00, $00, $09, $70,
|
||||
$48, $59, $73, $00, $00, $0B, $13, $00, $00, $0B, $13, $01, $00, $9A, $9C, $18,
|
||||
$00, $00, $00, $07, $74, $49, $4D, $45, $07, $EA, $08, $1F, $07, $2E, $1B, $C7,
|
||||
$2C, $48, $09, $00, $00, $01, $2F, $49, $44, $41, $54, $38, $CB, $CD, $D2, $3D,
|
||||
$2F, $83, $51, $18, $C6, $F1, $DF, $C3, $63, $31, $34, $11, $B5, $35, $D1, $A9,
|
||||
$C1, $E0, $25, $4C, $66, $49, $F9, $16, $24, $96, $C6, $37, $A8, $18, $7D, $02,
|
||||
$31, $98, $18, $99, $25, $44, $F8, $02, $26, $B1, $30, $30, $78, $D9, $90, $18,
|
||||
$24, $35, $F4, $B4, $8F, $A1, $8A, $7A, $5A, $D2, $45, $FC, $C7, $73, $DF, $D7,
|
||||
$7D, $AE, $EB, $3E, $87, $2E, $89, $52, $27, $3D, $7A, $50, $43, $2F, $EA, $EA,
|
||||
$BF, $8D, $28, $3A, $B6, $A9, $5F, $BF, $4D, $C7, $8A, $DF, $CB, $71, $4A, $90,
|
||||
$33, $27, $2B, $C6, $AC, $49, $BB, $69, $03, $5D, $F2, $77, $82, $A8, $CD, $BE,
|
||||
$BE, $F2, $51, $6F, $0A, $46, $2D, $E8, $EB, $D8, $1E, $2B, $1A, $69, $15, $0C,
|
||||
$DB, $B6, $66, $A8, $6D, $7B, $D6, $AA, $1D, $F9, $A6, $B6, $60, $40, $50, $90,
|
||||
$55, $36, $A3, $FC, $CD, $5A, $64, $C2, $BA, $79, $91, $82, $07, $B1, $67, $F6,
|
||||
$54, $BC, $78, $95, $48, $24, $AE, $1D, $49, $9C, $C9, $C8, $38, $93, $38, $74,
|
||||
$F5, $5E, $79, $F5, $A2, $62, $2F, $16, $54, $D5, $BE, $CC, $4C, $3A, $E4, $08,
|
||||
$AA, $EA, $02, $79, $E3, $C6, $94, $D4, $04, $07, $26, $2D, $B7, $DC, $B0, $6C,
|
||||
$C2, $BE, $A0, $A6, $64, $CC, $B8, $7C, $EC, $E6, $3D, $F4, $93, $2D, $1B, $1E,
|
||||
$4D, $B7, $CC, $4D, $9C, $5B, $B2, $A2, $E4, $C6, $45, $23, $74, $83, $5B, $8B,
|
||||
$4E, $54, $DB, $9A, $79, $B2, $EE, $D4, $5D, $73, $4B, $0D, $2E, $5D, $76, $74,
|
||||
$4F, $70, $F4, $F9, $24, $3F, $47, $4D, $AD, $E2, $1F, $FF, $D6, $4F, $AA, $2A,
|
||||
$EE, $05, $C1, $BD, $4A, $87, $BD, $B5, $30, $68, $4A, $4E, $24, $92, $33, $65,
|
||||
$B0, $5B, $07, $29, $DE, $00, $F6, $77, $56, $B3, $E2, $94, $4B, $80, $00, $00,
|
||||
$00, $00, $49, $45, $4E, $44, $AE, $42, $60, $82
|
||||
);
|
||||
|
||||
{ icons8-property-30.png, 24 на 24 точек }
|
||||
IconLogData: array[0..425] of Byte = (
|
||||
$89, $50, $4E, $47, $0D, $0A, $1A, $0A, $00, $00, $00, $0D, $49, $48, $44, $52,
|
||||
$00, $00, $00, $18, $00, $00, $00, $18, $08, $04, $00, $00, $00, $4A, $7E, $F5,
|
||||
$73, $00, $00, $00, $20, $63, $48, $52, $4D, $00, $00, $7A, $26, $00, $00, $80,
|
||||
$84, $00, $00, $FA, $00, $00, $00, $80, $E8, $00, $00, $75, $30, $00, $00, $EA,
|
||||
$60, $00, $00, $3A, $98, $00, $00, $17, $70, $9C, $BA, $51, $3C, $00, $00, $00,
|
||||
$02, $62, $4B, $47, $44, $00, $00, $AA, $8D, $23, $32, $00, $00, $00, $09, $70,
|
||||
$48, $59, $73, $00, $00, $0B, $13, $00, $00, $0B, $13, $01, $00, $9A, $9C, $18,
|
||||
$00, $00, $00, $07, $74, $49, $4D, $45, $07, $EA, $08, $1F, $07, $2E, $1B, $C7,
|
||||
$2C, $48, $09, $00, $00, $01, $0F, $49, $44, $41, $54, $38, $CB, $B5, $D3, $BF,
|
||||
$2E, $83, $51, $18, $C7, $F1, $CF, $5B, $15, $0C, $74, $93, $88, $91, $0B, $E8,
|
||||
$68, $60, $33, $34, $B1, $1A, $F4, $1E, $0C, $66, $8B, $C5, $05, $B8, $01, $89,
|
||||
$2B, $30, $18, $24, $4C, $0D, $8B, $41, $13, $46, $62, $90, $18, $A4, $4B, $55,
|
||||
$3A, $28, $AD, $F7, $18, $AA, $F5, $36, $DA, $B7, $25, $3C, $67, $38, $27, $4F,
|
||||
$9E, $EF, $79, $FE, $9C, $F3, $E3, $BF, $2D, $42, $24, $12, $8D, $10, $1B, $04,
|
||||
$21, $8B, $39, $5B, $96, $65, $84, $D4, $8B, $63, $E7, $F6, $3C, $42, $51, $A3,
|
||||
$4D, $0F, $59, $2F, $36, $C8, $60, $C6, $C4, $48, $E5, $4F, $CA, $B5, $81, $1F,
|
||||
$59, $F6, $73, $7F, $D6, $1C, $12, $39, $2E, $97, $04, $8A, $2E, $BA, $E7, $7E,
|
||||
$D6, $B2, $E4, $38, $09, $D4, $3C, $0D, $C9, $50, $EB, $2D, $09, $F2, $0A, $0E,
|
||||
$B0, $69, $36, $E1, $6D, $3A, $72, $F2, $BD, $07, $98, $B7, $EA, $50, $B0, $66,
|
||||
$B1, $FB, $26, $91, $86, $DB, $41, $C0, $A9, $92, $86, $60, $A5, $67, $76, $C1,
|
||||
$5B, $BF, $29, $B5, $33, $E4, $95, $50, $90, $4B, $64, $68, $B9, $74, $DD, $1F,
|
||||
$C8, $DB, $76, $23, $D8, $B5, $90, $F0, $BE, $DA, $19, $04, $9C, $B9, $73, $8F,
|
||||
$75, $53, $09, $6F, $EC, $61, $50, $49, $55, $55, $70, $95, $36, $DD, $5F, $7F,
|
||||
$8D, $08, $63, $29, $71, $EF, $1D, $C5, $74, $80, $7D, $F5, $54, $11, $05, $D3,
|
||||
$5F, $40, $45, $59, $3C, $44, $73, $91, $BA, $B2, $4A, $47, $A2, $A3, $76, $12,
|
||||
$A7, $AA, $F2, $8F, $EC, $03, $0F, $C8, $51, $5A, $EC, $EA, $63, $7C, $00, $00,
|
||||
$00, $00, $49, $45, $4E, $44, $AE, $42, $60, $82
|
||||
);
|
||||
|
||||
{ icons8-клей-30.png, 24 на 24 точек }
|
||||
IconGlueData: array[0..513] of Byte = (
|
||||
$89, $50, $4E, $47, $0D, $0A, $1A, $0A, $00, $00, $00, $0D, $49, $48, $44, $52,
|
||||
$00, $00, $00, $18, $00, $00, $00, $18, $08, $04, $00, $00, $00, $4A, $7E, $F5,
|
||||
$73, $00, $00, $00, $20, $63, $48, $52, $4D, $00, $00, $7A, $26, $00, $00, $80,
|
||||
$84, $00, $00, $FA, $00, $00, $00, $80, $E8, $00, $00, $75, $30, $00, $00, $EA,
|
||||
$60, $00, $00, $3A, $98, $00, $00, $17, $70, $9C, $BA, $51, $3C, $00, $00, $00,
|
||||
$02, $62, $4B, $47, $44, $00, $00, $AA, $8D, $23, $32, $00, $00, $00, $09, $70,
|
||||
$48, $59, $73, $00, $00, $0B, $13, $00, $00, $0B, $13, $01, $00, $9A, $9C, $18,
|
||||
$00, $00, $00, $07, $74, $49, $4D, $45, $07, $EA, $08, $1F, $07, $2E, $1B, $C7,
|
||||
$2C, $48, $09, $00, $00, $01, $67, $49, $44, $41, $54, $38, $CB, $B5, $D3, $3D,
|
||||
$4F, $93, $61, $14, $C6, $F1, $5F, $AB, $80, $9D, $A8, $A1, $71, $95, $08, $14,
|
||||
$3F, $80, $71, $D2, $8D, $C5, $D8, $A4, $A6, $61, $31, $74, $32, $31, $1A, $99,
|
||||
$89, $B3, $8B, $71, $B0, $7C, $0F, $37, $42, $4B, $07, $07, $D0, $98, $30, $E0,
|
||||
$0E, $62, $59, $1C, $24, $18, $35, $61, $50, $04, $C2, $F3, $DC, $0E, $DE, $9A,
|
||||
$96, $C2, $23, $0B, $67, $3D, $D7, $FF, $3A, $D7, $FD, $72, $38, $EF, $BA, $70,
|
||||
$6A, $67, $D4, $23, $15, $87, $3E, $0B, $67, $31, $1A, $B7, $E8, $97, $D4, $86,
|
||||
$AA, $8B, $FF, $97, $97, $2D, $49, $05, $41, $B0, $A5, $66, $20, $5B, $3E, $69,
|
||||
$51, $12, $E5, $41, $F0, $31, $1B, $29, $47, $79, $DA, $85, $6C, $AA, $9D, $76,
|
||||
$DA, $09, $6D, $89, $23, $0B, $66, $BC, $ED, $42, $3A, $AA, $27, $21, $A3, $9A,
|
||||
$52, $41, $CB, $35, $DC, $F0, $A6, $6B, $CE, $86, $5B, $FD, $C0, $53, $FB, $82,
|
||||
$A0, $A1, $00, $6E, $F6, $20, $CF, $21, $DF, $03, $14, $0D, $81, $FB, $1E, $18,
|
||||
$C4, $7B, $73, $DE, $C5, $DE, $81, $DD, $FE, $09, $B7, $AD, $47, $B7, $1D, $4F,
|
||||
$0C, $21, $A7, $2E, $15, $A4, $9A, $AE, $F6, $8A, $07, $8C, $B9, $A2, $A2, $13,
|
||||
$91, $2F, $66, $15, $8C, $59, $10, $24, $DA, $26, $7A, $E5, $79, $53, $56, $CD,
|
||||
$2B, $A9, $FA, $10, $91, $6D, $F3, $96, $1C, $49, $B4, $4C, $1E, $0F, $33, $6C,
|
||||
$59, $F0, $43, $C3, $88, $7B, $36, $BB, $2E, $34, $D1, $72, $BD, $3F, $7D, $D1,
|
||||
$9A, $20, $D8, $F3, $D2, $88, $EA, $BF, $60, $A9, $76, $BF, $FB, $9F, $48, $75,
|
||||
$3F, $23, $D2, $50, $52, $B1, $2E, $B5, $A7, $79, $3C, $FB, $DF, $9F, $38, $A8,
|
||||
$1C, $5F, $B2, $E0, $B1, $9C, $17, $1E, $BA, $6B, $D7, $2B, $9F, $4E, $F2, $CF,
|
||||
$99, $F6, $4D, $B0, $1F, $A7, $AC, $1A, $97, $59, $25, $2B, $82, $AF, $9E, $A9,
|
||||
$5B, $B3, $6C, $2A, $63, $B1, $C0, $8C, $8E, $15, $35, $97, $E4, $15, $0D, $67,
|
||||
$C9, $73, $E0, $8E, $CB, $5E, $FB, $7E, $B6, $65, $3C, $EF, $FA, $0D, $91, $67,
|
||||
$97, $14, $98, $55, $D3, $54, $00, $00, $00, $00, $49, $45, $4E, $44, $AE, $42,
|
||||
$60, $82
|
||||
);
|
||||
|
||||
{ icons8-цель-30.png, 24 на 24 точек }
|
||||
IconTargetData: array[0..779] of Byte = (
|
||||
$89, $50, $4E, $47, $0D, $0A, $1A, $0A, $00, $00, $00, $0D, $49, $48, $44, $52,
|
||||
$00, $00, $00, $18, $00, $00, $00, $18, $08, $04, $00, $00, $00, $4A, $7E, $F5,
|
||||
$73, $00, $00, $00, $20, $63, $48, $52, $4D, $00, $00, $7A, $26, $00, $00, $80,
|
||||
$84, $00, $00, $FA, $00, $00, $00, $80, $E8, $00, $00, $75, $30, $00, $00, $EA,
|
||||
$60, $00, $00, $3A, $98, $00, $00, $17, $70, $9C, $BA, $51, $3C, $00, $00, $00,
|
||||
$02, $62, $4B, $47, $44, $00, $00, $AA, $8D, $23, $32, $00, $00, $00, $09, $70,
|
||||
$48, $59, $73, $00, $00, $0B, $13, $00, $00, $0B, $13, $01, $00, $9A, $9C, $18,
|
||||
$00, $00, $00, $07, $74, $49, $4D, $45, $07, $EA, $08, $1F, $07, $2E, $1B, $C7,
|
||||
$2C, $48, $09, $00, $00, $02, $71, $49, $44, $41, $54, $38, $CB, $95, $D4, $41,
|
||||
$68, $DB, $65, $18, $06, $F0, $5F, $E2, $DA, $2A, $2E, $59, $B2, $65, $29, $4B,
|
||||
$B4, $A7, $C9, $B0, $DB, $A0, $8C, $49, $63, $55, $50, $0F, $56, $31, $ED, $68,
|
||||
$BA, $CB, $70, $3B, $C8, $F0, $24, $08, $0A, $1E, $44, $99, $4E, $AA, $B2, $5D,
|
||||
$44, $86, $D9, $4D, $9B, $0E, $3C, $4D, $58, $87, $14, $75, $22, $C8, $DC, $9C,
|
||||
$2B, $78, $4A, $D9, $84, $04, $6B, $93, $B5, $34, $15, $BB, $FE, $6B, $65, $5D,
|
||||
$B5, $F1, $F0, $67, $AC, $DD, $40, $D8, $73, $FB, $E0, $7B, $78, $DF, $E7, $7D,
|
||||
$DE, $E7, $E5, $1E, $11, $B9, $EB, $9D, $B0, $43, $46, $1C, $81, $69, $57, $2D,
|
||||
$68, $AE, $FD, $B0, $01, $3C, $67, $97, $1F, $3C, $E1, $8C, $59, $AF, $3A, $20,
|
||||
$23, $26, $22, $30, $E3, $8A, $AF, $9D, $31, $77, $9B, $70, $1F, $D8, $E7, $4D,
|
||||
$3D, $0E, $DA, $62, $DC, $92, $55, $C3, $4E, $18, $71, $C1, $8A, $27, $E5, $ED,
|
||||
$35, $A9, $BE, $9E, $70, $53, $8F, $9C, $16, $BB, $B4, $29, $B9, $EC, $2F, $AB,
|
||||
$96, $D5, $7C, $67, $C4, $8A, $17, $F4, $FA, $5D, $65, $6D, $6B, $AF, $59, $D2,
|
||||
$D4, $D4, $54, $13, $D7, $6F, $D2, $8A, $9B, $AA, $4E, $D9, $2F, $29, $AF, $6C,
|
||||
$42, $4F, $A8, $37, $AC, $30, $23, $65, $B7, $28, $56, $7C, $26, $EA, $5F, $A3,
|
||||
$CE, $F9, $D3, $D3, $06, $6D, $74, $DA, $55, $03, $1E, $F1, $AD, $BF, $43, $42,
|
||||
$C4, $0D, $E3, $5A, $65, $FD, $E3, $41, $71, $A3, $C6, $FC, $EC, $8A, $F3, $C6,
|
||||
$A4, $1C, $D2, $EA, $0B, $6D, $06, $5C, $F3, $4B, $D8, $50, $D2, $3B, $9E, $F1,
|
||||
$80, $B8, $AC, $93, $16, $14, $6D, $B5, $DB, $59, $25, $DD, $D2, $4A, $1A, $06,
|
||||
$75, $A8, $38, $2D, $11, $56, $E8, $F2, $B6, $55, $97, $3D, $25, $6A, $D4, $66,
|
||||
$07, $6C, $31, $66, $D2, $CB, $FA, $9D, $F7, $A3, $7D, $36, $3A, $27, $E3, $31,
|
||||
$17, $5C, $83, $82, $79, $07, $ED, $F4, $9B, $8F, $B4, $48, $F9, $D4, $BC, $A2,
|
||||
$94, $01, $93, $86, $B5, $2B, $AA, $EA, $74, $C8, $BC, $42, $68, $5C, $4C, $4C,
|
||||
$5D, $9B, $AC, $39, $4D, $DB, $BC, $2F, $E2, $25, $81, $0F, $8C, $7B, $D6, $B2,
|
||||
$8A, $C3, $EE, $57, $17, $13, $BB, $E5, $74, $B8, $22, $4D, $4D, $31, $43, $4E,
|
||||
$38, $22, $B0, $24, $22, $72, $F7, $F2, $6C, $C0, $A2, $45, $59, $B3, $EA, $B6,
|
||||
$6B, $71, $DD, $C7, $8E, $18, $42, $C1, $5E, $DF, $6B, $B5, $5D, $CD, $0D, $3B,
|
||||
$2D, $5A, $24, $8A, $69, $33, $72, $16, $5C, $D4, $2B, $A3, $88, $4F, $7C, $6E,
|
||||
$D8, $90, $C0, $49, $DB, $F4, $BA, $E8, $BA, $9C, $E9, $50, $32, $09, $5F, $AA,
|
||||
$78, $D8, $7E, $73, $4A, $D2, $BA, $95, $D4, $D4, $0C, $EB, $96, $36, $A2, $A1,
|
||||
$A0, $43, $35, $1C, $6B, $88, $57, $34, $BC, $2B, $E9, $B8, $40, $49, $97, $B4,
|
||||
$84, $A4, $B4, $2E, $25, $81, $63, $12, $8E, $9A, $75, $F8, $B6, $92, $AD, $BE,
|
||||
$32, $25, $AF, $DD, $71, $0D, $55, $45, $AF, $7B, $43, $51, $55, $C3, $31, $ED,
|
||||
$FA, $4C, $39, $2B, $B5, $36, $36, $3D, $26, $94, $F5, $49, $1A, $74, $4A, $C5,
|
||||
$B2, $65, $15, $23, $0A, $12, $FA, $94, $95, $3D, $BE, $7E, $5E, $51, $79, $13,
|
||||
$A6, $BC, $A7, $43, $46, $A7, $3D, $F6, $E8, $94, $D1, $E1, $A8, $9A, $B2, $17,
|
||||
$45, $EF, $8C, $68, $44, $CE, $5B, $72, $02, $DF, $B8, $A4, $8E, $87, $E4, $3C,
|
||||
$6F, $93, $9F, $7C, $E8, $D2, $AD, $34, $AC, $B7, $65, $B3, $7E, $79, $8F, $CA,
|
||||
$88, $B1, $26, $A2, $7F, $FC, $DF, $11, $D8, $64, $87, $AC, $B8, $A6, $C0, $B4,
|
||||
$5F, $EF, $3C, $02, $F7, $8C, $FF, $00, $D3, $AE, $CE, $9E, $6E, $47, $CD, $DC,
|
||||
$00, $00, $00, $00, $49, $45, $4E, $44, $AE, $42, $60, $82
|
||||
);
|
||||
|
||||
{ icons8-показ-видеокадров-30.png, значок окна, 32 на 32 точек, формат ico }
|
||||
IconAppData: array[0..4285] of Byte = (
|
||||
$00, $00, $01, $00, $01, $00, $20, $20, $00, $00, $01, $00, $20, $00, $A8, $10,
|
||||
$00, $00, $16, $00, $00, $00, $28, $00, $00, $00, $20, $00, $00, $00, $40, $00,
|
||||
$00, $00, $01, $00, $20, $00, $00, $00, $00, $00, $00, $10, $00, $00, $13, $0B,
|
||||
$00, $00, $13, $0B, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $37, $00, $00, $00, $A1, $00, $00, $00, $BB, $00, $00,
|
||||
$00, $BA, $00, $00, $00, $BA, $00, $00, $00, $BA, $00, $00, $00, $BA, $00, $00,
|
||||
$00, $BA, $00, $00, $00, $BA, $00, $00, $00, $BA, $00, $00, $00, $BA, $00, $00,
|
||||
$00, $BB, $00, $00, $00, $A1, $00, $00, $00, $37, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $51, $00, $00, $00, $E5, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $E5, $00, $00, $00, $51, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $1E, $00, $00, $00, $58, $00, $00, $00, $66, $00, $00,
|
||||
$00, $66, $00, $00, $00, $66, $00, $00, $00, $66, $00, $00, $00, $66, $00, $00,
|
||||
$00, $66, $00, $00, $00, $66, $00, $00, $00, $66, $00, $00, $00, $66, $00, $00,
|
||||
$00, $66, $00, $00, $00, $58, $00, $00, $00, $1E, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $15, $00, $00,
|
||||
$00, $54, $00, $00, $00, $74, $00, $00, $00, $77, $00, $00, $00, $77, $00, $00,
|
||||
$00, $77, $00, $00, $00, $77, $00, $00, $00, $77, $00, $00, $00, $77, $00, $00,
|
||||
$00, $77, $00, $00, $00, $77, $00, $00, $00, $77, $00, $00, $00, $77, $00, $00,
|
||||
$00, $77, $00, $00, $00, $77, $00, $00, $00, $77, $00, $00, $00, $77, $00, $00,
|
||||
$00, $77, $00, $00, $00, $77, $00, $00, $00, $77, $00, $00, $00, $77, $00, $00,
|
||||
$00, $77, $00, $00, $00, $77, $00, $00, $00, $74, $00, $00, $00, $54, $00, $00,
|
||||
$00, $15, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $69, $00, $00,
|
||||
$00, $DB, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $DA, $00, $00,
|
||||
$00, $68, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $BE, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $C7, $00, $00, $00, $A4, $00, $00, $00, $D7, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $D7, $00, $00, $00, $A4, $00, $00, $00, $C7, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $BD, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $C9, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $55, $00, $00, $00, $00, $00, $00, $00, $88, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $88, $00, $00, $00, $00, $00, $00, $00, $55, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $C9, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $C9, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $79, $00, $00, $00, $29, $00, $00, $00, $A1, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $A1, $00, $00, $00, $29, $00, $00, $00, $79, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $C9, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $C9, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $C9, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $C9, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $EC, $00, $00, $00, $E1, $00, $00, $00, $F2, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $F2, $00, $00, $00, $E1, $00, $00, $00, $EC, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $C9, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $C9, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $5C, $00, $00, $00, $00, $00, $00, $00, $8D, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $8D, $00, $00, $00, $00, $00, $00, $00, $5C, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $C9, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $C9, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $5C, $00, $00, $00, $00, $00, $00, $00, $8D, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $8D, $00, $00, $00, $00, $00, $00, $00, $5C, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $C9, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $C9, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $EC, $00, $00, $00, $E1, $00, $00, $00, $F2, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $F2, $00, $00, $00, $E1, $00, $00, $00, $EC, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $C9, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $C9, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $C9, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $C9, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $79, $00, $00, $00, $29, $00, $00, $00, $A1, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $A1, $00, $00, $00, $29, $00, $00, $00, $79, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $C9, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $C9, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $55, $00, $00, $00, $00, $00, $00, $00, $88, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $88, $00, $00, $00, $00, $00, $00, $00, $55, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $C9, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $C9, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $C6, $00, $00, $00, $A4, $00, $00, $00, $D7, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $D7, $00, $00, $00, $A4, $00, $00, $00, $C6, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $C9, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $C9, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $C9, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $C9, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $A4, $00, $00, $00, $6E, $00, $00, $00, $C0, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $C0, $00, $00, $00, $6E, $00, $00, $00, $A4, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $C9, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $C9, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $55, $00, $00, $00, $00, $00, $00, $00, $88, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $88, $00, $00, $00, $00, $00, $00, $00, $55, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $C9, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $C3, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $99, $00, $00, $00, $5C, $00, $00, $00, $B8, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $B8, $00, $00, $00, $5C, $00, $00, $00, $99, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $C3, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $8A, $00, $00,
|
||||
$00, $EA, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00,
|
||||
$00, $FF, $00, $00, $00, $FF, $00, $00, $00, $FF, $00, $00, $00, $EA, $00, $00,
|
||||
$00, $89, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $26, $00, $00,
|
||||
$00, $87, $00, $00, $00, $B6, $00, $00, $00, $BA, $00, $00, $00, $BA, $00, $00,
|
||||
$00, $BA, $00, $00, $00, $BA, $00, $00, $00, $BA, $00, $00, $00, $BA, $00, $00,
|
||||
$00, $BA, $00, $00, $00, $BA, $00, $00, $00, $BA, $00, $00, $00, $BA, $00, $00,
|
||||
$00, $BA, $00, $00, $00, $BA, $00, $00, $00, $BA, $00, $00, $00, $BA, $00, $00,
|
||||
$00, $BA, $00, $00, $00, $BA, $00, $00, $00, $BA, $00, $00, $00, $BA, $00, $00,
|
||||
$00, $BA, $00, $00, $00, $BA, $00, $00, $00, $B6, $00, $00, $00, $86, $00, $00,
|
||||
$00, $24, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00,
|
||||
$00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $FF, $FF,
|
||||
$FF, $FF, $FF, $FF, $FF, $FF, $FF, $FF, $FF, $FF, $FF, $FF, $FF, $FF, $FF, $80,
|
||||
$01, $FF, $FF, $80, $01, $FF, $FF, $80, $01, $FF, $FF, $FF, $FF, $FF, $E0, $00,
|
||||
$00, $07, $E0, $00, $00, $07, $E0, $00, $00, $07, $E2, $00, $00, $47, $E0, $00,
|
||||
$00, $07, $E0, $00, $00, $07, $E0, $00, $00, $07, $E2, $00, $00, $47, $E2, $00,
|
||||
$00, $47, $E0, $00, $00, $07, $E0, $00, $00, $07, $E0, $00, $00, $07, $E2, $00,
|
||||
$00, $47, $E0, $00, $00, $07, $E0, $00, $00, $07, $E0, $00, $00, $07, $E2, $00,
|
||||
$00, $47, $E0, $00, $00, $07, $E0, $00, $00, $07, $E0, $00, $00, $07, $FF, $FF,
|
||||
$FF, $FF, $FF, $FF, $FF, $FF, $FF, $FF, $FF, $FF, $FF, $FF, $FF, $FF
|
||||
);
|
||||
|
||||
implementation
|
||||
|
||||
end.
|
||||
|
|
@ -13,19 +13,10 @@ object MainForm: TMainForm
|
|||
OnKeyDown = FormKeyDown
|
||||
OnShow = FormShow
|
||||
LCLVersion = '4.6'
|
||||
object pnlMenu: TPanel
|
||||
Left = 0
|
||||
Height = 26
|
||||
Top = 0
|
||||
Width = 1600
|
||||
Align = alTop
|
||||
BevelOuter = bvNone
|
||||
TabOrder = 4
|
||||
end
|
||||
object memoLog: TLogView
|
||||
Left = 0
|
||||
Height = 130
|
||||
Top = 770
|
||||
Top = 730
|
||||
Width = 1600
|
||||
Align = alBottom
|
||||
Font.Height = -11
|
||||
|
|
@ -36,7 +27,7 @@ object MainForm: TMainForm
|
|||
object pnlBottom: TPanel
|
||||
Left = 0
|
||||
Height = 40
|
||||
Top = 730
|
||||
Top = 860
|
||||
Width = 1600
|
||||
Align = alBottom
|
||||
BevelOuter = bvNone
|
||||
|
|
@ -58,34 +49,59 @@ object MainForm: TMainForm
|
|||
Left = 290
|
||||
Height = 28
|
||||
Top = 6
|
||||
Width = 170
|
||||
Width = 28
|
||||
Down = True
|
||||
Caption = 'Прилипание: вкл'
|
||||
Icon = tiSnap
|
||||
OnClick = btnSnapClick
|
||||
ShowHint = True
|
||||
TabStop = False
|
||||
end
|
||||
object btnGlue: TThemedButton
|
||||
Left = 320
|
||||
Height = 28
|
||||
Top = 6
|
||||
Width = 28
|
||||
Down = True
|
||||
Icon = tiGlue
|
||||
OnClick = btnGlueClick
|
||||
ShowHint = True
|
||||
TabStop = False
|
||||
end
|
||||
object btnCenter: TThemedButton
|
||||
Left = 350
|
||||
Height = 28
|
||||
Top = 6
|
||||
Width = 28
|
||||
Down = True
|
||||
Icon = tiTarget
|
||||
OnClick = btnCenterClick
|
||||
ShowHint = True
|
||||
TabStop = False
|
||||
end
|
||||
object btnRipple: TThemedButton
|
||||
Left = 470
|
||||
Left = 386
|
||||
Height = 28
|
||||
Top = 6
|
||||
Width = 280
|
||||
Caption = 'Схлопывание: текущая дорожка'
|
||||
Width = 28
|
||||
Icon = tiRipple
|
||||
OnClick = btnRippleClick
|
||||
ShowHint = True
|
||||
TabStop = False
|
||||
end
|
||||
object btnRippleMenu: TThemedButton
|
||||
Left = 752
|
||||
Left = 416
|
||||
Height = 28
|
||||
Top = 6
|
||||
Width = 26
|
||||
Width = 22
|
||||
Arrow = True
|
||||
OnClick = btnRippleMenuClick
|
||||
TabStop = False
|
||||
end
|
||||
object lblStatus: TLabel
|
||||
Left = 790
|
||||
Left = 488
|
||||
Height = 20
|
||||
Top = 10
|
||||
Width = 840
|
||||
Width = 780
|
||||
Anchors = [akTop, akLeft, akRight]
|
||||
AutoSize = False
|
||||
Caption = 'Готов.'
|
||||
|
|
@ -110,6 +126,7 @@ object MainForm: TMainForm
|
|||
Align = alClient
|
||||
ItemHeight = 100
|
||||
OnClick = lstPoolClick
|
||||
OnMouseDown = lstPoolMouseDown
|
||||
OnDrawItem = lstPoolDrawItem
|
||||
Style = lbOwnerDrawFixed
|
||||
TabOrder = 0
|
||||
|
|
|
|||
1983
src/uMainForm.pas
|
|
@ -104,6 +104,10 @@ var
|
|||
mpv_create: function: Pmpv_handle; cdecl;
|
||||
mpv_initialize: function(ctx: Pmpv_handle): cint; cdecl;
|
||||
mpv_terminate_destroy: procedure(ctx: Pmpv_handle); cdecl;
|
||||
{ Необязательная: отсоединяет клиента, не дожидаясь, пока ядро добьёт
|
||||
свои потоки. Есть в libmpv с давних версий, но если вдруг не найдётся,
|
||||
работаем без неё. }
|
||||
mpv_destroy: procedure(ctx: Pmpv_handle); cdecl;
|
||||
mpv_set_option_string: function(ctx: Pmpv_handle; name, data: PAnsiChar): cint; cdecl;
|
||||
mpv_set_property: function(ctx: Pmpv_handle; name: PAnsiChar; format: cint;
|
||||
data: Pointer): cint; cdecl;
|
||||
|
|
@ -279,6 +283,8 @@ begin
|
|||
Need('mpv_request_log_messages', Pointer(mpv_request_log_messages));
|
||||
Need('mpv_wait_event', Pointer(mpv_wait_event));
|
||||
Need('mpv_event_name', Pointer(mpv_event_name));
|
||||
{ Без Need: её отсутствие не повод отказываться от предпросмотра. }
|
||||
Bind('mpv_destroy', Pointer(mpv_destroy));
|
||||
|
||||
if Missing <> '' then
|
||||
begin
|
||||
|
|
|
|||
|
|
@ -57,6 +57,23 @@ type
|
|||
FInitialized: Boolean;
|
||||
FVideoFilter: string;
|
||||
FHwdec: string;
|
||||
FAudioSource: string;
|
||||
FAudioSources: array of string;
|
||||
{ Наложение: список кусков оверлея и окна, когда и куда его класть. }
|
||||
FOverlayUrl: string;
|
||||
FOverlayWindows: TOverlayWindows;
|
||||
{ Номера видеодорожек: своя (основа) и подключённая для наложения.
|
||||
Пусто — наложения нет. }
|
||||
FBaseVid: string;
|
||||
FOverlayVid: string;
|
||||
FTrackVolumes: array of Double;
|
||||
{ Номера подключённых звуковых дорожек, в порядке подключения. }
|
||||
FAudioIds: array of string;
|
||||
FLastGraph: string;
|
||||
FSubtitlePath: string;
|
||||
{ Что произошло с выбором звуковой дорожки. Читается окном для
|
||||
журнала: без этого понять, откуда взялся звук, нечем. }
|
||||
FAudioTrackNote: string;
|
||||
{ Работаем ли под Wine. Там свои правила вывода картинки, и узнать об
|
||||
этом надо до создания плеера. }
|
||||
FUnderWine: Boolean;
|
||||
|
|
@ -80,6 +97,23 @@ type
|
|||
procedure PumpEvents(Sender: TObject);
|
||||
procedure HandlePropertyChange(AProp: Pmpv_event_property);
|
||||
procedure HandleLogMessage(AMsg: Pmpv_event_log_message);
|
||||
{ Выбирает внешнюю звуковую дорожку, если она есть.
|
||||
|
||||
Без этого mpv играет звук, встроенный в саму картинку: внешний файл
|
||||
он лишь добавляет ещё одной дорожкой, а выбирает по своему
|
||||
усмотрению. Получалось, что звук слышен там, где на монтаже его нет
|
||||
вовсе — он приходил из видеофайла, а не с звуковой дорожки. }
|
||||
procedure SelectExternalAudio;
|
||||
{ Подключает оверлей второй видеодорожкой и запоминает её номер. }
|
||||
procedure AttachOverlay;
|
||||
{ Часть цепочки, которая кладёт оверлей на основу. Пусто — нечего
|
||||
класть. }
|
||||
function OverlayGraph: string;
|
||||
{ Цепочка смешивания звуковых дорожек с их громкостями. }
|
||||
{ Собственная запись в журнал программы. Идёт тем же путём, что и
|
||||
сообщения самого mpv: у окна для них уже есть обработчик. }
|
||||
procedure Note(const AText: string);
|
||||
procedure BuildGraph;
|
||||
function SetOption(const AName, AValue: string): Integer;
|
||||
public
|
||||
constructor Create(AVideoParent: TWinControl);
|
||||
|
|
@ -135,6 +169,27 @@ type
|
|||
когда фильтр действительно нужен. }
|
||||
procedure SetHwdec(const AMode: string);
|
||||
|
||||
{ Внешняя звуковая дорожка: список кусков звука, отдельный от
|
||||
картинки. Нужен потому, что звук можно двигать отдельно, и у одного
|
||||
файла звук всегда свой.
|
||||
|
||||
Пустая строка снимает внешний звук. }
|
||||
procedure SetAudioSource(const AUrl: string);
|
||||
|
||||
{ Несколько звуковых дорожек монтажа разом. Плеер играет только одну,
|
||||
поэтому смесь собирается его же средствами — см. AttachAudio. }
|
||||
procedure SetAudioSources(const AUrls: array of string);
|
||||
{ Что класть поверх картинки. Пустой список кусков — ничего.
|
||||
|
||||
Наложение делает сам плеер, ничего заранее не собирается: две
|
||||
дорожки играются одна поверх другой. }
|
||||
procedure SetVideoOverlay(const AUrl: string;
|
||||
const AWindows: TOverlayWindows);
|
||||
|
||||
{ Громкость каждой звуковой дорожки монтажа. Подставляется в цепочку
|
||||
смешивания: одной общей громкостью тут не обойтись. }
|
||||
procedure SetTrackVolumes(const AVolumes: array of Double);
|
||||
|
||||
function GetPropString(const AName: string): string;
|
||||
function GetPropDouble(const AName: string; out AValue: Double): Boolean;
|
||||
|
||||
|
|
@ -148,6 +203,7 @@ type
|
|||
property HasTimePos: Boolean read FHasTimePos;
|
||||
property Initialized: Boolean read FInitialized;
|
||||
property UnderWine: Boolean read FUnderWine;
|
||||
property AudioTrackNote: string read FAudioTrackNote;
|
||||
property TimePos: Double read FTimePos;
|
||||
property Duration: Double read FDuration;
|
||||
property Paused: Boolean read FPaused;
|
||||
|
|
@ -155,6 +211,17 @@ type
|
|||
property OnDuration: TNotifyEvent read FOnDuration write FOnDuration;
|
||||
property OnPauseChange: TNotifyEvent read FOnPauseChange write FOnPauseChange;
|
||||
property OnLog: TMpvLogEvent read FOnLog write FOnLog;
|
||||
{ Цепочка смешивания, отданная mpv. Нужна журналу: по ней видно, какая
|
||||
дорожка какую громкость получила на самом деле. }
|
||||
property AudioGraph: string read FLastGraph;
|
||||
{ Файл субтитров поверх картинки. Пустая строка снимает его.
|
||||
|
||||
Держится отдельно от списка кусков: текст правится чаще, чем
|
||||
монтаж, и перезагружать из-за него картинку незачем. }
|
||||
{ AForce — перечитать файл, даже если имя прежнее. Нужно всегда, когда
|
||||
разметка переписана: имя у неё одно на весь проект, и по имени
|
||||
изменение не видно. }
|
||||
procedure SetSubtitleFile(const APath: string; AForce: Boolean = False);
|
||||
end;
|
||||
|
||||
implementation
|
||||
|
|
@ -340,16 +407,50 @@ begin
|
|||
FPump.OnTimer := @PumpEvents;
|
||||
end;
|
||||
|
||||
procedure TMpvPlayer.Note(const AText: string);
|
||||
begin
|
||||
if Assigned(FOnLog) then
|
||||
FOnLog(Self, 'nlve', 'info', AText);
|
||||
end;
|
||||
|
||||
{ Закрытие плеера.
|
||||
|
||||
Порядок здесь не случайный, и стоил зависания под Wine.
|
||||
|
||||
mpv_terminate_destroy не просто отпускает клиента — она ждёт, пока ядро
|
||||
mpv свернёт всю свою работу и добьёт свои потоки. Под Wine это ожидание
|
||||
не кончалось никогда: журнал обрывался на «закрываю плеер», и дальше
|
||||
ничего. Под Linux та же функция отрабатывает мгновенно.
|
||||
|
||||
Поэтому отсоединяемся, не дожидаясь ядра: mpv_destroy отпускает клиента
|
||||
и возвращает управление сразу, а ядро без клиентов сворачивается само.
|
||||
Программа в этот момент и так закрывается — ждать её незачем.
|
||||
|
||||
Команду «завершиться» перед этим не посылаем намеренно: она тоже
|
||||
выполняется ядром и под Wine могла бы встать точно так же. Отсоединение
|
||||
ни от чего не зависит.
|
||||
|
||||
Окно, в которое рисовал mpv, убирается только после этого: в обратном
|
||||
порядке он успел бы обратиться к уже несуществующему окну. }
|
||||
destructor TMpvPlayer.Destroy;
|
||||
begin
|
||||
FPump.Enabled := False;
|
||||
FreeAndNil(FPump);
|
||||
{ Сначала гасим mpv, и только потом убираем окно, в которое он рисовал:
|
||||
в обратном порядке он успел бы обратиться к уже несуществующему окну. }
|
||||
if FHandle <> nil then
|
||||
begin
|
||||
mpv_terminate_destroy(FHandle);
|
||||
if Assigned(mpv_destroy) then
|
||||
begin
|
||||
Note('плеер: отсоединяюсь от mpv');
|
||||
mpv_destroy(FHandle);
|
||||
end
|
||||
else
|
||||
begin
|
||||
{ Старая libmpv без mpv_destroy: другого способа нет, ждём. }
|
||||
Note('плеер: закрываю mpv с ожиданием');
|
||||
mpv_terminate_destroy(FHandle);
|
||||
end;
|
||||
FHandle := nil;
|
||||
Note('плеер: закрыт');
|
||||
end;
|
||||
{$IFDEF LCLGTK2}
|
||||
if (FVideoWin <> 0) and (FDisplay <> nil) then
|
||||
|
|
@ -546,6 +647,17 @@ begin
|
|||
Гадать по номеру версии не нужно: те же самые настройки можно задать
|
||||
свойствами перед загрузкой. Свойства называются одинаково во всех
|
||||
версиях, и разбирать строку приходится нам, а не mpv. }
|
||||
{ Цепочка смешивания снимается ДО загрузки.
|
||||
|
||||
Она ссылается на дорожки по номерам. Оставшаяся от прошлого раза
|
||||
цепочка не даёт открыть новый файл вовсе: проверено на живом mpv —
|
||||
после такой загрузки дорожек ноль, кадра нет, не играет ничего.
|
||||
Снимать её после загрузки поздно. }
|
||||
FLastGraph := '';
|
||||
FAudioIds := nil;
|
||||
FOverlayVid := '';
|
||||
mpv_set_property_string(FHandle, 'lavfi-complex', '');
|
||||
|
||||
Rest := AOptions;
|
||||
while Rest <> '' do
|
||||
begin
|
||||
|
|
@ -637,6 +749,53 @@ begin
|
|||
mpv_set_property_string(FHandle, 'vf', PAnsiChar(Value));
|
||||
end;
|
||||
|
||||
procedure TMpvPlayer.SetAudioSource(const AUrl: string);
|
||||
begin
|
||||
SetAudioSources([AUrl]);
|
||||
end;
|
||||
|
||||
procedure TMpvPlayer.SetAudioSources(const AUrls: array of string);
|
||||
var
|
||||
I, N: Integer;
|
||||
begin
|
||||
{ Здесь только запоминаем. Подключается звук после загрузки картинки,
|
||||
командой audio-add — см. SelectExternalAudio. }
|
||||
SetLength(FAudioSources, Length(AUrls));
|
||||
N := 0;
|
||||
for I := 0 to High(AUrls) do
|
||||
if AUrls[I] <> '' then
|
||||
begin
|
||||
FAudioSources[N] := AUrls[I];
|
||||
Inc(N);
|
||||
end;
|
||||
SetLength(FAudioSources, N);
|
||||
if N > 0 then
|
||||
FAudioSource := FAudioSources[0]
|
||||
else
|
||||
FAudioSource := '';
|
||||
end;
|
||||
|
||||
procedure TMpvPlayer.SetVideoOverlay(const AUrl: string;
|
||||
const AWindows: TOverlayWindows);
|
||||
var
|
||||
I: Integer;
|
||||
begin
|
||||
FOverlayUrl := AUrl;
|
||||
SetLength(FOverlayWindows, Length(AWindows));
|
||||
for I := 0 to High(AWindows) do
|
||||
FOverlayWindows[I] := AWindows[I];
|
||||
end;
|
||||
|
||||
procedure TMpvPlayer.SetTrackVolumes(const AVolumes: array of Double);
|
||||
var
|
||||
I: Integer;
|
||||
begin
|
||||
SetLength(FTrackVolumes, Length(AVolumes));
|
||||
for I := 0 to High(AVolumes) do
|
||||
FTrackVolumes[I] := AVolumes[I];
|
||||
BuildGraph;
|
||||
end;
|
||||
|
||||
procedure TMpvPlayer.SetHwdec(const AMode: string);
|
||||
begin
|
||||
if FHandle = nil then
|
||||
|
|
@ -717,6 +876,314 @@ begin
|
|||
end;
|
||||
end;
|
||||
|
||||
{ Наложение оверлея на основу.
|
||||
|
||||
Оверлей — обычная видеодорожка, подключённая рядом с основной. Класть её
|
||||
поверх умеет сам плеер, поэтому ничего не собирается заранее: правка
|
||||
видна сразу, а видеокарта не тратится на пересборку всего ролика.
|
||||
|
||||
Окна с одинаковым размером и местом объединяются в одну ветку: масштаб
|
||||
во времени не меняется, а вот показывать и прятать можно по времени —
|
||||
это и делает enable. }
|
||||
function TMpvPlayer.OverlayGraph: string;
|
||||
var
|
||||
I, J, N, Count: Integer;
|
||||
Filters: array of string;
|
||||
Xs, Ys: array of Integer;
|
||||
Enables: array of string;
|
||||
Src, Prev, Next, Fit: string;
|
||||
|
||||
{ Номер ветки для окна: у одинаковых по виду и месту она общая. }
|
||||
function GroupOf(const AWin: TOverlayWindow): Integer;
|
||||
var
|
||||
K: Integer;
|
||||
begin
|
||||
for K := 0 to Count - 1 do
|
||||
if (Filters[K] = AWin.Filter) and (Xs[K] = AWin.X) and
|
||||
(Ys[K] = AWin.Y) then
|
||||
Exit(K);
|
||||
SetLength(Filters, Count + 1);
|
||||
SetLength(Xs, Count + 1);
|
||||
SetLength(Ys, Count + 1);
|
||||
SetLength(Enables, Count + 1);
|
||||
Filters[Count] := AWin.Filter;
|
||||
Xs[Count] := AWin.X;
|
||||
Ys[Count] := AWin.Y;
|
||||
Enables[Count] := '';
|
||||
Result := Count;
|
||||
Inc(Count);
|
||||
end;
|
||||
|
||||
begin
|
||||
Result := '';
|
||||
if (FOverlayVid = '') or (FBaseVid = '') or
|
||||
(Length(FOverlayWindows) = 0) then
|
||||
Exit;
|
||||
|
||||
Count := 0;
|
||||
for I := 0 to High(FOverlayWindows) do
|
||||
begin
|
||||
J := GroupOf(FOverlayWindows[I]);
|
||||
if Enables[J] <> '' then
|
||||
Enables[J] := Enables[J] + '+';
|
||||
Enables[J] := Enables[J] + Format('between(t,%s,%s)',
|
||||
[SecondsToStr(FOverlayWindows[I].TlStart),
|
||||
SecondsToStr(FOverlayWindows[I].TlEnd)]);
|
||||
end;
|
||||
|
||||
{ Одна ветка берёт оверлей напрямую, несколько — через split. }
|
||||
if Count = 1 then
|
||||
Result := Format('[vid%s]', [FOverlayVid])
|
||||
else
|
||||
begin
|
||||
Result := Format('[vid%s]split=%d', [FOverlayVid, Count]);
|
||||
for I := 0 to Count - 1 do
|
||||
Result := Result + Format('[q%d]', [I]);
|
||||
Result := Result + ';';
|
||||
end;
|
||||
|
||||
for I := 0 to Count - 1 do
|
||||
begin
|
||||
if Count = 1 then
|
||||
Src := ''
|
||||
else
|
||||
Src := Format('[q%d]', [I]);
|
||||
Fit := Filters[I];
|
||||
if Fit = '' then
|
||||
Fit := 'null';
|
||||
Result := Result + Format('%s%s[ov%d];', [Src, Fit, I]);
|
||||
end;
|
||||
|
||||
Prev := Format('[vid%s]', [FBaseVid]);
|
||||
for I := 0 to Count - 1 do
|
||||
begin
|
||||
if I = Count - 1 then
|
||||
Next := '[vo]'
|
||||
else
|
||||
Next := Format('[m%d]', [I]);
|
||||
{ eof_action=pass и repeatlast=0: оверлей короче основы — обычное
|
||||
дело, и после его конца картинка должна идти дальше как ни в чём
|
||||
не бывало. }
|
||||
Result := Result + Format('%s[ov%d]overlay=%d:%d:enable=''%s''' +
|
||||
':eof_action=pass:repeatlast=0%s;',
|
||||
[Prev, I, Xs[I], Ys[I], Enables[I], Next]);
|
||||
Prev := Next;
|
||||
end;
|
||||
end;
|
||||
|
||||
procedure TMpvPlayer.BuildGraph;
|
||||
var
|
||||
I: Integer;
|
||||
Graph, Vol, Video: string;
|
||||
NeedAudioPart: Boolean;
|
||||
begin
|
||||
if FHandle = nil then
|
||||
Exit;
|
||||
|
||||
Video := OverlayGraph;
|
||||
|
||||
{ Цепочка отбирает дорожки: всё, что в ней не упомянуто, mpv снимает с
|
||||
выбора. Поэтому при наложении звук приходится вести через неё же,
|
||||
даже когда дорожка одна, — иначе он просто пропадёт. }
|
||||
NeedAudioPart := (Length(FAudioIds) > 1) or
|
||||
((Video <> '') and (Length(FAudioIds) > 0));
|
||||
|
||||
if Length(FAudioIds) = 0 then
|
||||
begin
|
||||
{ Дорожек нет — снимаем приглушение, иначе оно пережило бы смену
|
||||
монтажа и заглушило следующий. }
|
||||
SetVolume(100);
|
||||
Graph := Video;
|
||||
if Graph <> FLastGraph then
|
||||
begin
|
||||
FLastGraph := Graph;
|
||||
mpv_set_property_string(FHandle, 'lavfi-complex', PAnsiChar(Graph));
|
||||
end;
|
||||
Exit;
|
||||
end;
|
||||
|
||||
{ Одна дорожка без наложения играется как есть: цепочка тут только
|
||||
мешала бы, да и громкость задаётся обычным способом. }
|
||||
if not NeedAudioPart then
|
||||
begin
|
||||
Graph := '';
|
||||
if (Length(FTrackVolumes) > 0) and (Abs(FTrackVolumes[0] - 1) > 0.001) then
|
||||
SetVolume(FTrackVolumes[0] * 100)
|
||||
else
|
||||
SetVolume(100);
|
||||
end
|
||||
else if Length(FAudioIds) = 1 then
|
||||
begin
|
||||
SetVolume(100);
|
||||
if Length(FTrackVolumes) > 0 then
|
||||
Vol := SecondsToStr(FTrackVolumes[0])
|
||||
else
|
||||
Vol := '1';
|
||||
Graph := Format('[aid%s]volume=%s[ao];', [FAudioIds[0], Vol]);
|
||||
end
|
||||
else
|
||||
begin
|
||||
{ Общая громкость возвращается на место обязательно.
|
||||
|
||||
Ветка выше выставляет её по громкости единственной дорожки, и ноль
|
||||
там — обычное дело: клип с выключенным звуком. Раньше здесь она не
|
||||
трогалась, и плеер, однажды заглушённый, оставался немым до конца
|
||||
работы — на всём монтаже сразу, сколько бы дорожек потом ни
|
||||
появилось. Здесь громкость каждой дорожки задана в самой цепочке,
|
||||
поэтому общая всегда полная. }
|
||||
SetVolume(100);
|
||||
Graph := '';
|
||||
for I := 0 to High(FAudioIds) do
|
||||
begin
|
||||
if I <= High(FTrackVolumes) then
|
||||
Vol := SecondsToStr(FTrackVolumes[I])
|
||||
else
|
||||
Vol := '1';
|
||||
Graph := Graph + Format('[aid%s]volume=%s[t%d];', [FAudioIds[I], Vol, I]);
|
||||
end;
|
||||
for I := 0 to High(FAudioIds) do
|
||||
Graph := Graph + Format('[t%d]', [I]);
|
||||
Graph := Graph + Format('amix=inputs=%d:normalize=0[ao];',
|
||||
[Length(FAudioIds)]);
|
||||
end;
|
||||
|
||||
Graph := Video + Graph;
|
||||
if Graph = FLastGraph then
|
||||
Exit;
|
||||
FLastGraph := Graph;
|
||||
mpv_set_property_string(FHandle, 'lavfi-complex', PAnsiChar(Graph));
|
||||
end;
|
||||
|
||||
{ Оверлей подключается второй видеодорожкой.
|
||||
|
||||
Как и со звуком, через свойство это не делается: нужна команда
|
||||
video-add. Дорожка добавляется без выбора — выбирать её нельзя, иначе
|
||||
она заменила бы картинку. Её дело — быть входом цепочки. }
|
||||
procedure TMpvPlayer.AttachOverlay;
|
||||
var
|
||||
Count, I: Integer;
|
||||
Kind, Ext, Id: string;
|
||||
begin
|
||||
if FHandle = nil then
|
||||
Exit;
|
||||
|
||||
FBaseVid := '';
|
||||
FOverlayVid := '';
|
||||
|
||||
if (FOverlayUrl <> '') and (Length(FOverlayWindows) > 0) then
|
||||
Command(['video-add', FOverlayUrl, 'auto']);
|
||||
|
||||
Count := StrToIntDef(GetPropString('track-list/count'), 0);
|
||||
for I := 0 to Count - 1 do
|
||||
begin
|
||||
Kind := GetPropString(Format('track-list/%d/type', [I]));
|
||||
if Kind <> 'video' then
|
||||
Continue;
|
||||
Id := GetPropString(Format('track-list/%d/id', [I]));
|
||||
if Id = '' then
|
||||
Continue;
|
||||
Ext := GetPropString(Format('track-list/%d/external', [I]));
|
||||
if (Ext = 'yes') or (Ext = 'true') then
|
||||
begin
|
||||
if FOverlayVid = '' then
|
||||
FOverlayVid := Id;
|
||||
end
|
||||
else if FBaseVid = '' then
|
||||
FBaseVid := Id;
|
||||
end;
|
||||
end;
|
||||
|
||||
{ Субтитры подключаются двумя путями, и оба нужны.
|
||||
|
||||
Здесь — когда файл поменялся, а картинка та же: снимаем прежние и
|
||||
подключаем новые, не трогая воспроизведение. При загрузке нового списка
|
||||
кусков mpv забывает все внешние дорожки, поэтому там подключение
|
||||
повторяется — см. SelectExternalAudio. }
|
||||
procedure TMpvPlayer.SetSubtitleFile(const APath: string; AForce: Boolean);
|
||||
begin
|
||||
if (FSubtitlePath = APath) and (not AForce) then
|
||||
Exit;
|
||||
FSubtitlePath := APath;
|
||||
if FHandle = nil then
|
||||
Exit;
|
||||
|
||||
Command(['sub-remove']);
|
||||
if APath <> '' then
|
||||
Command(['sub-add', APath, 'select']);
|
||||
end;
|
||||
|
||||
procedure TMpvPlayer.SelectExternalAudio;
|
||||
var
|
||||
Count, I, Found: Integer;
|
||||
Kind, Ext, Id: string;
|
||||
begin
|
||||
if FHandle = nil then
|
||||
Exit;
|
||||
|
||||
{ Своего звука нет — пусть играет как есть. }
|
||||
if FAudioSource = '' then
|
||||
Exit;
|
||||
|
||||
{ Подключаем каждую звуковую дорожку монтажа отдельным списком кусков.
|
||||
|
||||
Через свойство audio-files это не работает: проверено на живом mpv —
|
||||
список дорожек после загрузки остаётся прежним, внешний файл не
|
||||
подключается вовсе, и играет звук, встроенный в саму картинку. Из-за
|
||||
этого звук был слышен там, где на монтаже его нет, а после попытки
|
||||
заглушить — пропал совсем.
|
||||
|
||||
Команда audio-add с признаком select делает ровно то, что нужно:
|
||||
добавляет дорожку, выбирает её и снимает выбор с внутренней. }
|
||||
for I := 0 to High(FAudioSources) do
|
||||
if I = 0 then
|
||||
Command(['audio-add', FAudioSources[I], 'select'])
|
||||
else
|
||||
Command(['audio-add', FAudioSources[I], 'auto']);
|
||||
|
||||
{ Собираем номера всех подключённых звуковых дорожек. }
|
||||
FAudioIds := nil;
|
||||
Found := 0;
|
||||
Count := StrToIntDef(GetPropString('track-list/count'), 0);
|
||||
for I := 0 to Count - 1 do
|
||||
begin
|
||||
Kind := GetPropString(Format('track-list/%d/type', [I]));
|
||||
if Kind <> 'audio' then
|
||||
Continue;
|
||||
Ext := GetPropString(Format('track-list/%d/external', [I]));
|
||||
if (Ext <> 'yes') and (Ext <> 'true') then
|
||||
Continue;
|
||||
Id := GetPropString(Format('track-list/%d/id', [I]));
|
||||
if Id = '' then
|
||||
Continue;
|
||||
if Found = 0 then
|
||||
mpv_set_property_string(FHandle, 'aid', PAnsiChar(Id));
|
||||
SetLength(FAudioIds, Found + 1);
|
||||
FAudioIds[Found] := Id;
|
||||
Inc(Found);
|
||||
end;
|
||||
|
||||
if Found > 0 then
|
||||
begin
|
||||
FAudioTrackNote := 'Звук: дорожек монтажа ' + IntToStr(Found) + '.';
|
||||
{ Цепочка строится отдельно: она зависит ещё и от громкостей, а те
|
||||
меняются по ходу воспроизведения. }
|
||||
BuildGraph;
|
||||
Exit;
|
||||
end;
|
||||
|
||||
{ Внешней дорожки не нашлось.
|
||||
|
||||
Глушить звук здесь нельзя, и это проверено дорого: такая попытка
|
||||
привела к тому, что звука не стало вовсе. Перебор дорожек может не
|
||||
сработать по причинам, до которых отсюда не дознаться, а mpv и сам
|
||||
предпочитает внешний список, когда тот открылся.
|
||||
|
||||
Поэтому оставляем выбор ему, но записываем в журнал: если звук вдруг
|
||||
окажется не тот, будет с чего начинать. }
|
||||
FAudioTrackNote := 'ВНИМАНИЕ: внешняя звуковая дорожка не подключилась ' +
|
||||
'(дорожек: ' + IntToStr(Count) + '). Звук может идти не с монтажа.';
|
||||
end;
|
||||
|
||||
procedure TMpvPlayer.HandleLogMessage(AMsg: Pmpv_event_log_message);
|
||||
begin
|
||||
if Assigned(FOnLog) then
|
||||
|
|
@ -746,6 +1213,20 @@ begin
|
|||
HandlePropertyChange(Pmpv_event_property(Ev^.data));
|
||||
MPV_EVENT_LOG_MESSAGE:
|
||||
HandleLogMessage(Pmpv_event_log_message(Ev^.data));
|
||||
MPV_EVENT_FILE_LOADED:
|
||||
begin
|
||||
{ Оверлей подключается до звука: цепочка строится один раз, и в
|
||||
ней должно быть сразу всё. }
|
||||
AttachOverlay;
|
||||
SelectExternalAudio;
|
||||
{ Звуковых дорожек может не оказаться вовсе, а наложение при
|
||||
этом есть — тогда цепочку никто больше не соберёт. }
|
||||
BuildGraph;
|
||||
{ Загрузка нового списка кусков сбрасывает все внешние дорожки,
|
||||
в том числе субтитры. Подключаем заново. }
|
||||
if FSubtitlePath <> '' then
|
||||
Command(['sub-add', FSubtitlePath, 'select']);
|
||||
end;
|
||||
MPV_EVENT_SHUTDOWN:
|
||||
begin
|
||||
FPump.Enabled := False;
|
||||
|
|
|
|||
|
|
@ -1,24 +1,24 @@
|
|||
object PanCropForm: TPanCropForm
|
||||
Left = 380
|
||||
Height = 560
|
||||
Height = 620
|
||||
Top = 220
|
||||
Width = 940
|
||||
Width = 1260
|
||||
BorderIcons = [biSystemMenu]
|
||||
Caption = 'Кадрирование и поворот'
|
||||
ClientHeight = 560
|
||||
ClientWidth = 940
|
||||
ClientHeight = 620
|
||||
ClientWidth = 1260
|
||||
OnCreate = FormCreate
|
||||
OnDestroy = FormDestroy
|
||||
Position = poOwnerFormCenter
|
||||
LCLVersion = '4.6'
|
||||
object pnlLeft: TPanel
|
||||
Left = 0
|
||||
Height = 560
|
||||
Height = 620
|
||||
Top = 0
|
||||
Width = 380
|
||||
Align = alLeft
|
||||
BevelOuter = bvNone
|
||||
ClientHeight = 560
|
||||
ClientHeight = 620
|
||||
ClientWidth = 380
|
||||
TabOrder = 0
|
||||
object lblSource: TLabel
|
||||
|
|
@ -179,13 +179,33 @@ object PanCropForm: TPanCropForm
|
|||
OnClick = btnCancelClick
|
||||
TabOrder = 9
|
||||
end
|
||||
object btnKey: TThemedButton
|
||||
Left = 12
|
||||
Height = 30
|
||||
Top = 534
|
||||
Width = 356
|
||||
Caption = 'Цветовой ключ...'
|
||||
OnClick = btnKeyClick
|
||||
TabOrder = 10
|
||||
end
|
||||
end
|
||||
object pbSource: TPaintBox
|
||||
Left = 380
|
||||
Height = 620
|
||||
Top = 0
|
||||
Width = 460
|
||||
Align = alClient
|
||||
OnMouseDown = pbSourceMouseDown
|
||||
OnMouseMove = pbSourceMouseMove
|
||||
OnMouseUp = pbSourceMouseUp
|
||||
OnPaint = pbSourcePaint
|
||||
end
|
||||
object imgPreview: TImage
|
||||
Left = 380
|
||||
Height = 560
|
||||
Left = 840
|
||||
Height = 620
|
||||
Top = 0
|
||||
Width = 560
|
||||
Align = alClient
|
||||
Width = 420
|
||||
Align = alRight
|
||||
Center = True
|
||||
Proportional = True
|
||||
Stretch = True
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ interface
|
|||
|
||||
uses
|
||||
Classes, SysUtils, Forms, Controls, Graphics, ExtCtrls, StdCtrls, Spin,
|
||||
ComCtrls, uTransform, uMediaProbe, uTheme;
|
||||
ComCtrls, uTransform, uMediaProbe, uProject, uTheme;
|
||||
|
||||
type
|
||||
|
||||
|
|
@ -29,6 +29,7 @@ type
|
|||
|
||||
TPanCropForm = class(TForm)
|
||||
btnCancel: TThemedButton;
|
||||
btnKey: TThemedButton;
|
||||
btnOk: TThemedButton;
|
||||
btnReset: TThemedButton;
|
||||
cbFit: TComboBox;
|
||||
|
|
@ -36,6 +37,7 @@ type
|
|||
chkFlipH: TCheckBox;
|
||||
chkFlipV: TCheckBox;
|
||||
imgPreview: TImage;
|
||||
pbSource: TPaintBox;
|
||||
lblFilter: TLabel;
|
||||
lblFit: TLabel;
|
||||
lblOffsetX: TLabel;
|
||||
|
|
@ -48,20 +50,58 @@ type
|
|||
tbOffsetY: TTrackBar;
|
||||
tbZoom: TTrackBar;
|
||||
procedure btnCancelClick(Sender: TObject);
|
||||
procedure btnKeyClick(Sender: TObject);
|
||||
procedure btnOkClick(Sender: TObject);
|
||||
procedure btnResetClick(Sender: TObject);
|
||||
procedure ControlChanged(Sender: TObject);
|
||||
procedure FormCreate(Sender: TObject);
|
||||
procedure FormDestroy(Sender: TObject);
|
||||
procedure pbSourcePaint(Sender: TObject);
|
||||
procedure pbSourceMouseDown(Sender: TObject; Button: TMouseButton;
|
||||
Shift: TShiftState; X, Y: Integer);
|
||||
procedure pbSourceMouseMove(Sender: TObject; Shift: TShiftState;
|
||||
X, Y: Integer);
|
||||
procedure pbSourceMouseUp(Sender: TObject; Button: TMouseButton;
|
||||
Shift: TShiftState; X, Y: Integer);
|
||||
private
|
||||
FTransform: TMediaTransform;
|
||||
FSourcePath: string;
|
||||
{ Цветовой ключ правится тут же, но принадлежит не окну, а клипу:
|
||||
окно лишь показывает его хозяину. Пусто — правит нечего (так бывает
|
||||
у заготовки для файла, у которой клипа ещё нет), и кнопки не
|
||||
будет. }
|
||||
FKey: PColorKey;
|
||||
FSourceInfo: TMediaInfo;
|
||||
FDstWidth: Integer;
|
||||
FDstHeight: Integer;
|
||||
FPreviewFile: string;
|
||||
FRefreshTimer: TTimer;
|
||||
FUpdating: Boolean;
|
||||
{ Кадр источника без всякой обработки — по нему тянут рамку. }
|
||||
FSourceBmp: TBitmap;
|
||||
{ Размеры кадра после поворота: рамка живёт в этих координатах. }
|
||||
FRotW: Integer;
|
||||
FRotH: Integer;
|
||||
{ Рамка в координатах повёрнутого источника. }
|
||||
FSel: TRect;
|
||||
FDragMode: Integer;
|
||||
FDragX: Integer;
|
||||
FDragY: Integer;
|
||||
FDragSel: TRect;
|
||||
procedure LoadSourceFrame;
|
||||
procedure UpdateRotatedSize;
|
||||
{ Прямоугольник, в который вписан кадр источника на панели. }
|
||||
function SourceRect: TRect;
|
||||
function SelToScreen(const ASel: TRect): TRect;
|
||||
function ScreenToSourceX(AX: Integer): Integer;
|
||||
function ScreenToSourceY(AY: Integer): Integer;
|
||||
{ Пересчёт между рамкой и настройкой. }
|
||||
procedure SelFromTransform;
|
||||
procedure TransformFromSel;
|
||||
{ Загоняет настройку в те пределы, которые умеют ползунки. Иначе
|
||||
рамка и ползунки расходятся: рамка от неограниченного значения,
|
||||
фильтр от обрезанного. }
|
||||
procedure ClampTransform;
|
||||
procedure ReadControls;
|
||||
procedure WriteControls;
|
||||
procedure ScheduleRefresh;
|
||||
|
|
@ -70,21 +110,28 @@ type
|
|||
{ Готовит окно. ADstWidth и ADstHeight — кадр проекта. }
|
||||
procedure Setup(const APath: string; const AInfo: TMediaInfo;
|
||||
ADstWidth, ADstHeight: Integer; const ATransform: TMediaTransform;
|
||||
const APreviewFile: string);
|
||||
const APreviewFile: string; AKey: PColorKey = nil);
|
||||
property Transform: TMediaTransform read FTransform;
|
||||
end;
|
||||
|
||||
{ Показывает окно и возвращает True, если настройки приняты. }
|
||||
{ AKey — цветовой ключ клипа. Нужен, чтобы правку ключа можно было
|
||||
открыть отсюда: человек ищет её там же, где кадрирование, — обе
|
||||
настройки про картинку одного клипа. Правка ключа сама себя
|
||||
подтверждает и «Отменой» этого окна не отменяется.
|
||||
|
||||
nil — ключа у этой настройки нет, кнопка не показывается. }
|
||||
function EditTransform(AOwner: TComponent; const APath: string;
|
||||
const AInfo: TMediaInfo; ADstWidth, ADstHeight: Integer;
|
||||
var ATransform: TMediaTransform; const APreviewFile: string): Boolean;
|
||||
var ATransform: TMediaTransform; const APreviewFile: string;
|
||||
AKey: PColorKey = nil): Boolean;
|
||||
|
||||
implementation
|
||||
|
||||
{$R *.lfm}
|
||||
|
||||
uses
|
||||
Math, uAppTypes, uFfmpegCmd, uProcRunner;
|
||||
Math, Types, LCLIntf, uAppTypes, uFfmpegCmd, uProcRunner, uColorKeyForm;
|
||||
|
||||
const
|
||||
{ Ширина показываемого кадра. Больше незачем: тут важны пропорции и то,
|
||||
|
|
@ -95,9 +142,21 @@ const
|
|||
каждое движение бессмысленно. }
|
||||
RefreshDelayMs = 300;
|
||||
|
||||
{ Что тянут мышью по кадру источника. }
|
||||
dmNone = 0;
|
||||
dmMove = 1;
|
||||
dmResize = 2;
|
||||
|
||||
{ Сколько пикселей у угла считается «за угол». }
|
||||
CornerGrip = 12;
|
||||
|
||||
ZoomMin = 50;
|
||||
ZoomMax = 400;
|
||||
OffsetRange = 50;
|
||||
{ Предел смещения. При сильном увеличении кадр гуляет далеко за половину
|
||||
своей ширины, и полсотни процентов не хватало: рамку можно было
|
||||
утащить туда, куда ползунок не дотягивается, и они расходились —
|
||||
рамка показывала одно, а получалось другое. }
|
||||
OffsetRange = 200;
|
||||
|
||||
procedure TPanCropForm.FormCreate(Sender: TObject);
|
||||
begin
|
||||
|
|
@ -144,6 +203,7 @@ end;
|
|||
|
||||
procedure TPanCropForm.FormDestroy(Sender: TObject);
|
||||
begin
|
||||
FreeAndNil(FSourceBmp);
|
||||
FRefreshTimer.Enabled := False;
|
||||
if (FPreviewFile <> '') and FileExists(FPreviewFile) then
|
||||
DeleteFile(FPreviewFile);
|
||||
|
|
@ -151,8 +211,10 @@ end;
|
|||
|
||||
procedure TPanCropForm.Setup(const APath: string; const AInfo: TMediaInfo;
|
||||
ADstWidth, ADstHeight: Integer; const ATransform: TMediaTransform;
|
||||
const APreviewFile: string);
|
||||
const APreviewFile: string; AKey: PColorKey);
|
||||
begin
|
||||
FKey := AKey;
|
||||
btnKey.Visible := AKey <> nil;
|
||||
FSourcePath := APath;
|
||||
FSourceInfo := AInfo;
|
||||
FDstWidth := ADstWidth;
|
||||
|
|
@ -164,6 +226,8 @@ begin
|
|||
[ExtractFileName(APath), AInfo.Width, AInfo.Height, ADstWidth, ADstHeight]);
|
||||
|
||||
WriteControls;
|
||||
LoadSourceFrame;
|
||||
SelFromTransform;
|
||||
RefreshPreview(nil);
|
||||
end;
|
||||
|
||||
|
|
@ -233,6 +297,9 @@ begin
|
|||
if FUpdating then
|
||||
Exit;
|
||||
ReadControls;
|
||||
{ Рамка следует за ползунками: иначе они разошлись бы с картинкой. }
|
||||
SelFromTransform;
|
||||
pbSource.Invalidate;
|
||||
ScheduleRefresh;
|
||||
end;
|
||||
|
||||
|
|
@ -244,6 +311,23 @@ begin
|
|||
ScheduleRefresh;
|
||||
end;
|
||||
|
||||
{ Правка цветового ключа того же клипа.
|
||||
|
||||
Кадр для выбора цвета окно ключа достаёт само — тот же, что и здесь:
|
||||
первый кадр часто чёрный, и тыкать в него не во что. }
|
||||
procedure TPanCropForm.btnKeyClick(Sender: TObject);
|
||||
var
|
||||
AtTime: Double;
|
||||
begin
|
||||
if FKey = nil then
|
||||
Exit;
|
||||
if FSourceInfo.Kind = mkImage then
|
||||
AtTime := 0
|
||||
else
|
||||
AtTime := Min(5, Max(0, FSourceInfo.Duration * 0.1));
|
||||
EditColorKey(FKey^, FSourcePath, AtTime, FPreviewFile + '.key.png');
|
||||
end;
|
||||
|
||||
procedure TPanCropForm.ScheduleRefresh;
|
||||
begin
|
||||
{ Вызов может прийти во время сборки окна, когда таймера ещё нет. }
|
||||
|
|
@ -253,6 +337,323 @@ begin
|
|||
FRefreshTimer.Enabled := True;
|
||||
end;
|
||||
|
||||
|
||||
{ --- Рамка поверх кадра источника --- }
|
||||
|
||||
{ Размеры кадра после поворота: рамка живёт именно в них, иначе после
|
||||
поворота на 90 она оказалась бы поперёк картинки. }
|
||||
procedure TPanCropForm.UpdateRotatedSize;
|
||||
begin
|
||||
{ Рамка живёт в координатах исходника как он есть. Поворот на неё не
|
||||
влияет: он применяется к уже вырезанному куску, а не к кадру целиком —
|
||||
иначе рамка показывала бы не то, что останется. }
|
||||
FRotW := FSourceInfo.Width;
|
||||
FRotH := FSourceInfo.Height;
|
||||
end;
|
||||
|
||||
{ Кадр источника, вписанный в панель с сохранением пропорций. }
|
||||
function TPanCropForm.SourceRect: TRect;
|
||||
var
|
||||
K: Double;
|
||||
W, H: Integer;
|
||||
begin
|
||||
Result := Rect(0, 0, 0, 0);
|
||||
if (FRotW <= 0) or (FRotH <= 0) then
|
||||
Exit;
|
||||
K := Min((pbSource.Width - 20) / FRotW, (pbSource.Height - 20) / FRotH);
|
||||
if K <= 0 then
|
||||
Exit;
|
||||
W := Round(FRotW * K);
|
||||
H := Round(FRotH * K);
|
||||
Result := Rect((pbSource.Width - W) div 2, (pbSource.Height - H) div 2,
|
||||
(pbSource.Width - W) div 2 + W, (pbSource.Height - H) div 2 + H);
|
||||
end;
|
||||
|
||||
function TPanCropForm.SelToScreen(const ASel: TRect): TRect;
|
||||
var
|
||||
R: TRect;
|
||||
KX, KY: Double;
|
||||
begin
|
||||
R := SourceRect;
|
||||
if (FRotW <= 0) or (FRotH <= 0) or (R.Right <= R.Left) then
|
||||
Exit(Rect(0, 0, 0, 0));
|
||||
KX := (R.Right - R.Left) / FRotW;
|
||||
KY := (R.Bottom - R.Top) / FRotH;
|
||||
Result := Rect(
|
||||
R.Left + Round(ASel.Left * KX), R.Top + Round(ASel.Top * KY),
|
||||
R.Left + Round(ASel.Right * KX), R.Top + Round(ASel.Bottom * KY));
|
||||
end;
|
||||
|
||||
function TPanCropForm.ScreenToSourceX(AX: Integer): Integer;
|
||||
var
|
||||
R: TRect;
|
||||
begin
|
||||
R := SourceRect;
|
||||
if R.Right <= R.Left then
|
||||
Exit(0);
|
||||
Result := Round((AX - R.Left) * FRotW / (R.Right - R.Left));
|
||||
end;
|
||||
|
||||
function TPanCropForm.ScreenToSourceY(AY: Integer): Integer;
|
||||
var
|
||||
R: TRect;
|
||||
begin
|
||||
R := SourceRect;
|
||||
if R.Bottom <= R.Top then
|
||||
Exit(0);
|
||||
Result := Round((AY - R.Top) * FRotH / (R.Bottom - R.Top));
|
||||
end;
|
||||
|
||||
{ Рамка по настройке. Обратное к TransformFromSel: там же и объяснение,
|
||||
откуда берутся числа. }
|
||||
procedure TPanCropForm.SelFromTransform;
|
||||
var
|
||||
KFit, KFill, K: Double;
|
||||
SW, SH, CW, CH, CX, CY: Double;
|
||||
W, H: Double;
|
||||
begin
|
||||
UpdateRotatedSize;
|
||||
if (FRotW <= 0) or (FRotH <= 0) or (FDstWidth <= 0) or (FDstHeight <= 0) then
|
||||
Exit;
|
||||
|
||||
KFit := Min(FDstWidth / FRotW, FDstHeight / FRotH);
|
||||
KFill := Max(FDstWidth / FRotW, FDstHeight / FRotH);
|
||||
if FTransform.Fit = fmFill then
|
||||
K := KFill
|
||||
else
|
||||
K := KFit;
|
||||
if FTransform.Fit = fmStretch then
|
||||
begin
|
||||
{ Растяжение рамкой не описывается: берётся весь кадр. }
|
||||
FSel := Rect(0, 0, FRotW, FRotH);
|
||||
Exit;
|
||||
end;
|
||||
if FTransform.Zoom > 0 then
|
||||
K := K * FTransform.Zoom;
|
||||
if K <= 0 then
|
||||
Exit;
|
||||
|
||||
SW := FRotW * K;
|
||||
SH := FRotH * K;
|
||||
CW := Min(SW, FDstWidth);
|
||||
CH := Min(SH, FDstHeight);
|
||||
CX := (SW - CW) / 2 + FTransform.OffsetX * FDstWidth;
|
||||
CY := (SH - CH) / 2 + FTransform.OffsetY * FDstHeight;
|
||||
CX := Max(0, Min(SW - CW, CX));
|
||||
CY := Max(0, Min(SH - CH, CY));
|
||||
|
||||
W := CW / K;
|
||||
H := CH / K;
|
||||
FSel := Rect(Round(CX / K), Round(CY / K),
|
||||
Round(CX / K + W), Round(CY / K + H));
|
||||
end;
|
||||
|
||||
{ Настройка по рамке.
|
||||
|
||||
Рамка говорит, какая часть источника займёт кадр проекта. Отсюда
|
||||
увеличение — во сколько раз её надо растянуть, а смещение — насколько
|
||||
она сдвинута относительно середины. }
|
||||
procedure TPanCropForm.TransformFromSel;
|
||||
var
|
||||
KFit, KFill, KBase, K: Double;
|
||||
SW, SH, CW, CH: Double;
|
||||
begin
|
||||
if (FRotW <= 0) or (FRotH <= 0) or (FDstWidth <= 0) or (FDstHeight <= 0) then
|
||||
Exit;
|
||||
if (FSel.Right - FSel.Left < 4) or (FSel.Bottom - FSel.Top < 4) then
|
||||
Exit;
|
||||
|
||||
KFit := Min(FDstWidth / FRotW, FDstHeight / FRotH);
|
||||
KFill := Max(FDstWidth / FRotW, FDstHeight / FRotH);
|
||||
if FTransform.Fit = fmFill then
|
||||
KBase := KFill
|
||||
else
|
||||
KBase := KFit;
|
||||
if KBase <= 0 then
|
||||
Exit;
|
||||
|
||||
K := FDstWidth / (FSel.Right - FSel.Left);
|
||||
FTransform.Zoom := K / KBase;
|
||||
|
||||
SW := FRotW * K;
|
||||
SH := FRotH * K;
|
||||
CW := Min(SW, FDstWidth);
|
||||
CH := Min(SH, FDstHeight);
|
||||
{ Смещение пересчитывается по рамке только там, где рамка его и
|
||||
задаёт, — то есть где картинка шире или выше кадра. Где она меньше,
|
||||
рамка охватывает её целиком и о смещении ничего не знает; посчитанный
|
||||
по ней ноль вернул бы картинку на середину и отменил бы то, что
|
||||
выставлено ползунком. }
|
||||
if SW > FDstWidth then
|
||||
FTransform.OffsetX := (FSel.Left * K - (SW - CW) / 2) / FDstWidth;
|
||||
if SH > FDstHeight then
|
||||
FTransform.OffsetY := (FSel.Top * K - (SH - CH) / 2) / FDstHeight;
|
||||
end;
|
||||
|
||||
procedure TPanCropForm.ClampTransform;
|
||||
begin
|
||||
if FTransform.Zoom < ZoomMin / 100 then
|
||||
FTransform.Zoom := ZoomMin / 100;
|
||||
if FTransform.Zoom > ZoomMax / 100 then
|
||||
FTransform.Zoom := ZoomMax / 100;
|
||||
FTransform.OffsetX := Max(-OffsetRange / 100, Min(OffsetRange / 100,
|
||||
FTransform.OffsetX));
|
||||
FTransform.OffsetY := Max(-OffsetRange / 100, Min(OffsetRange / 100,
|
||||
FTransform.OffsetY));
|
||||
end;
|
||||
|
||||
{ Кадр источника без обработки: по нему тянут рамку. }
|
||||
procedure TPanCropForm.LoadSourceFrame;
|
||||
var
|
||||
Res: TProcResult;
|
||||
Png: TPortableNetworkGraphic;
|
||||
AtTime: Double;
|
||||
Path: string;
|
||||
begin
|
||||
FreeAndNil(FSourceBmp);
|
||||
if FSourcePath = '' then
|
||||
Exit;
|
||||
|
||||
Path := FPreviewFile + '.src.png';
|
||||
if FSourceInfo.Kind = mkImage then
|
||||
AtTime := 0
|
||||
else
|
||||
AtTime := Min(5, Max(0, FSourceInfo.Duration * 0.1));
|
||||
|
||||
{ Пустая цепочка фильтров: нужен именно исходный кадр, без вписывания и
|
||||
полей — иначе рамку не по чему тянуть. }
|
||||
Res := RunProcess(FfmpegExe,
|
||||
ArgsPreviewFrame(FSourcePath, Path, AtTime, ''), nil, nil, nil);
|
||||
if (not Res.Started) or (Res.ExitCode <> 0) or (not FileExists(Path)) then
|
||||
Exit;
|
||||
|
||||
Png := TPortableNetworkGraphic.Create;
|
||||
try
|
||||
try
|
||||
Png.LoadFromFile(Path);
|
||||
FSourceBmp := TBitmap.Create;
|
||||
FSourceBmp.Assign(Png);
|
||||
except
|
||||
FreeAndNil(FSourceBmp);
|
||||
end;
|
||||
finally
|
||||
Png.Free;
|
||||
end;
|
||||
SysUtils.DeleteFile(Path);
|
||||
end;
|
||||
|
||||
procedure TPanCropForm.pbSourcePaint(Sender: TObject);
|
||||
var
|
||||
R, S: TRect;
|
||||
begin
|
||||
pbSource.Canvas.Brush.Style := bsSolid;
|
||||
pbSource.Canvas.Brush.Color := ThemeWindow;
|
||||
pbSource.Canvas.FillRect(pbSource.ClientRect);
|
||||
|
||||
R := SourceRect;
|
||||
if R.Right <= R.Left then
|
||||
Exit;
|
||||
|
||||
if FSourceBmp <> nil then
|
||||
pbSource.Canvas.StretchDraw(R, FSourceBmp)
|
||||
else
|
||||
begin
|
||||
pbSource.Canvas.Brush.Color := ThemeSurface;
|
||||
pbSource.Canvas.FillRect(R);
|
||||
end;
|
||||
|
||||
{ Затемнять то, что вне рамки, нечем без полупрозрачности, поэтому
|
||||
просто рисуем саму рамку и её углы. }
|
||||
S := SelToScreen(FSel);
|
||||
pbSource.Canvas.Brush.Style := bsClear;
|
||||
pbSource.Canvas.Pen.Color := ThemeAccent;
|
||||
pbSource.Canvas.Pen.Width := 2;
|
||||
pbSource.Canvas.Rectangle(S);
|
||||
pbSource.Canvas.Pen.Width := 1;
|
||||
|
||||
pbSource.Canvas.Brush.Style := bsSolid;
|
||||
pbSource.Canvas.Brush.Color := ThemeAccent;
|
||||
pbSource.Canvas.FillRect(S.Right - 8, S.Bottom - 8, S.Right, S.Bottom);
|
||||
end;
|
||||
|
||||
procedure TPanCropForm.pbSourceMouseDown(Sender: TObject; Button: TMouseButton;
|
||||
Shift: TShiftState; X, Y: Integer);
|
||||
var
|
||||
S: TRect;
|
||||
begin
|
||||
if Button <> mbLeft then
|
||||
Exit;
|
||||
S := SelToScreen(FSel);
|
||||
FDragSel := FSel;
|
||||
FDragX := X;
|
||||
FDragY := Y;
|
||||
|
||||
if (Abs(X - S.Right) <= CornerGrip) and (Abs(Y - S.Bottom) <= CornerGrip) then
|
||||
FDragMode := dmResize
|
||||
else if PtInRect(S, Point(X, Y)) then
|
||||
FDragMode := dmMove
|
||||
else
|
||||
FDragMode := dmNone;
|
||||
end;
|
||||
|
||||
procedure TPanCropForm.pbSourceMouseMove(Sender: TObject; Shift: TShiftState;
|
||||
X, Y: Integer);
|
||||
var
|
||||
DX, DY, W, H: Integer;
|
||||
begin
|
||||
if FDragMode = dmNone then
|
||||
Exit;
|
||||
|
||||
DX := ScreenToSourceX(X) - ScreenToSourceX(FDragX);
|
||||
DY := ScreenToSourceY(Y) - ScreenToSourceY(FDragY);
|
||||
|
||||
if FDragMode = dmMove then
|
||||
begin
|
||||
W := FDragSel.Right - FDragSel.Left;
|
||||
H := FDragSel.Bottom - FDragSel.Top;
|
||||
FSel.Left := Max(0, Min(FRotW - W, FDragSel.Left + DX));
|
||||
FSel.Top := Max(0, Min(FRotH - H, FDragSel.Top + DY));
|
||||
FSel.Right := FSel.Left + W;
|
||||
FSel.Bottom := FSel.Top + H;
|
||||
end
|
||||
else
|
||||
begin
|
||||
{ Ширину меняем мышью, высоту считаем: рамка держит пропорции кадра
|
||||
проекта, иначе картинка поехала бы. }
|
||||
W := Max(16, FDragSel.Right - FDragSel.Left + DX);
|
||||
W := Min(W, FRotW - FSel.Left);
|
||||
H := Round(W * FDstHeight / FDstWidth);
|
||||
if FSel.Top + H > FRotH then
|
||||
begin
|
||||
H := FRotH - FSel.Top;
|
||||
W := Round(H * FDstWidth / FDstHeight);
|
||||
end;
|
||||
FSel.Right := FSel.Left + W;
|
||||
FSel.Bottom := FSel.Top + H;
|
||||
end;
|
||||
|
||||
TransformFromSel;
|
||||
ClampTransform;
|
||||
{ Рамка пересчитывается обратно из настройки. Так она всегда показывает
|
||||
ровно то, что получится: если настройка упёрлась в предел, это сразу
|
||||
видно, а не расходится молча. }
|
||||
SelFromTransform;
|
||||
FUpdating := True;
|
||||
try
|
||||
WriteControls;
|
||||
finally
|
||||
FUpdating := False;
|
||||
end;
|
||||
pbSource.Invalidate;
|
||||
ScheduleRefresh;
|
||||
end;
|
||||
|
||||
procedure TPanCropForm.pbSourceMouseUp(Sender: TObject; Button: TMouseButton;
|
||||
Shift: TShiftState; X, Y: Integer);
|
||||
begin
|
||||
FDragMode := dmNone;
|
||||
end;
|
||||
|
||||
procedure TPanCropForm.RefreshPreview(Sender: TObject);
|
||||
var
|
||||
PrevW, PrevH: Integer;
|
||||
|
|
@ -318,13 +719,15 @@ end;
|
|||
|
||||
function EditTransform(AOwner: TComponent; const APath: string;
|
||||
const AInfo: TMediaInfo; ADstWidth, ADstHeight: Integer;
|
||||
var ATransform: TMediaTransform; const APreviewFile: string): Boolean;
|
||||
var ATransform: TMediaTransform; const APreviewFile: string;
|
||||
AKey: PColorKey): Boolean;
|
||||
var
|
||||
Form: TPanCropForm;
|
||||
begin
|
||||
Form := TPanCropForm.Create(AOwner);
|
||||
try
|
||||
Form.Setup(APath, AInfo, ADstWidth, ADstHeight, ATransform, APreviewFile);
|
||||
Form.Setup(APath, AInfo, ADstWidth, ADstHeight, ATransform, APreviewFile,
|
||||
AKey);
|
||||
Result := Form.ShowModal = mrOK;
|
||||
if Result then
|
||||
ATransform := Form.Transform;
|
||||
|
|
|
|||
|
|
@ -38,6 +38,15 @@ object ProgressForm: TProgressForm
|
|||
Anchors = [akTop, akLeft, akRight]
|
||||
TabOrder = 0
|
||||
end
|
||||
object lblEta: TLabel
|
||||
Left = 12
|
||||
Height = 20
|
||||
Top = 74
|
||||
Width = 500
|
||||
Anchors = [akTop, akLeft, akRight]
|
||||
Caption = ''
|
||||
ParentColor = False
|
||||
end
|
||||
object btnAction: TThemedButton
|
||||
Left = 648
|
||||
Height = 28
|
||||
|
|
@ -49,7 +58,7 @@ object ProgressForm: TProgressForm
|
|||
TabOrder = 1
|
||||
end
|
||||
end
|
||||
object memoLog: TMemo
|
||||
object memoLog: TLogView
|
||||
Left = 0
|
||||
Height = 310
|
||||
Top = 110
|
||||
|
|
@ -58,9 +67,6 @@ object ProgressForm: TProgressForm
|
|||
Font.Height = -12
|
||||
Font.Name = 'Monospace'
|
||||
ParentFont = False
|
||||
ReadOnly = True
|
||||
ScrollBars = ssAutoBoth
|
||||
TabOrder = 1
|
||||
WordWrap = False
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -16,7 +16,8 @@ unit uProgressForm;
|
|||
interface
|
||||
|
||||
uses
|
||||
Classes, SysUtils, Forms, Controls, Graphics, ComCtrls, StdCtrls, ExtCtrls, uTheme;
|
||||
Classes, SysUtils, Forms, Controls, Graphics, ComCtrls, StdCtrls, ExtCtrls,
|
||||
uTheme, uLogView;
|
||||
|
||||
type
|
||||
|
||||
|
|
@ -25,7 +26,8 @@ type
|
|||
TProgressForm = class(TForm)
|
||||
btnAction: TThemedButton;
|
||||
lblStage: TLabel;
|
||||
memoLog: TMemo;
|
||||
lblEta: TLabel;
|
||||
memoLog: TLogView;
|
||||
pbBar: TProgressBar;
|
||||
pnlTop: TPanel;
|
||||
procedure btnActionClick(Sender: TObject);
|
||||
|
|
@ -35,6 +37,11 @@ type
|
|||
FFinished: Boolean;
|
||||
FCancelRequested: Boolean;
|
||||
FOnCancelRequest: TNotifyEvent;
|
||||
{ Когда началась работа и какую долю показывали в прошлый раз: по ним
|
||||
считается, сколько осталось. }
|
||||
FStartedAt: TDateTime;
|
||||
FLastFraction: Double;
|
||||
function FormatSpan(const ASeconds: Double): string;
|
||||
public
|
||||
procedure SetStage(const AText: string);
|
||||
procedure SetFraction(AValue: Double);
|
||||
|
|
@ -69,6 +76,9 @@ begin
|
|||
pbBar.Position := 0;
|
||||
memoLog.Clear;
|
||||
btnAction.Caption := 'Отмена';
|
||||
FStartedAt := Now;
|
||||
FLastFraction := 0;
|
||||
lblEta.Caption := '';
|
||||
end;
|
||||
|
||||
procedure TProgressForm.SetStage(const AText: string);
|
||||
|
|
@ -76,24 +86,62 @@ begin
|
|||
lblStage.Caption := AText;
|
||||
end;
|
||||
|
||||
{ Время в виде «3 мин 20 с»: секунды сотнями человек не читает. }
|
||||
function TProgressForm.FormatSpan(const ASeconds: Double): string;
|
||||
var
|
||||
Total, M, S: Integer;
|
||||
begin
|
||||
Total := Round(ASeconds);
|
||||
if Total < 0 then
|
||||
Total := 0;
|
||||
M := Total div 60;
|
||||
S := Total mod 60;
|
||||
if M >= 60 then
|
||||
Result := Format('%d ч %d мин', [M div 60, M mod 60])
|
||||
else if M > 0 then
|
||||
Result := Format('%d мин %d с', [M, S])
|
||||
else
|
||||
Result := Format('%d с', [S]);
|
||||
end;
|
||||
|
||||
{ Сколько прошло и сколько осталось.
|
||||
|
||||
Осталось считается по простому правилу: если четверть пути заняла
|
||||
минуту, весь путь займёт четыре. На деле скорость меняется — куски,
|
||||
которые копируются, идут мгновенно, а те, что пересобираются,
|
||||
медленно, — поэтому число называется «примерно» и пересчитывается на
|
||||
каждом шаге. Даже такая оценка отвечает на главный вопрос: успею ли я
|
||||
сходить за чаем. }
|
||||
procedure TProgressForm.SetFraction(AValue: Double);
|
||||
var
|
||||
{ Не Left: так называется свойство формы. }
|
||||
Elapsed, Remain: Double;
|
||||
begin
|
||||
if AValue < 0 then
|
||||
AValue := 0;
|
||||
if AValue > 1 then
|
||||
AValue := 1;
|
||||
pbBar.Position := Round(AValue * BarScale);
|
||||
FLastFraction := AValue;
|
||||
|
||||
Elapsed := (Now - FStartedAt) * SecsPerDay;
|
||||
{ Пока сделано меньше сотой доли, оценка была бы гаданием: первые
|
||||
секунды уходят на запуск ffmpeg, а не на работу. }
|
||||
if (AValue > 0.01) and (Elapsed > 1) then
|
||||
begin
|
||||
Remain := Elapsed * (1 - AValue) / AValue;
|
||||
lblEta.Caption := Format('Прошло %s, осталось примерно %s (%d%%)',
|
||||
[FormatSpan(Elapsed), FormatSpan(Remain), Round(AValue * 100)]);
|
||||
end
|
||||
else
|
||||
lblEta.Caption := 'Прошло ' + FormatSpan(Elapsed);
|
||||
end;
|
||||
|
||||
procedure TProgressForm.AddLog(const AText: string);
|
||||
begin
|
||||
{ Журнал одной операции не должен расти без предела: ffmpeg на битом
|
||||
файле способен выдать тысячи одинаковых предупреждений. }
|
||||
if memoLog.Lines.Count > 1000 then
|
||||
memoLog.Lines.Delete(0);
|
||||
memoLog.Lines.Add(AText);
|
||||
{ Держим видимой последнюю строку. }
|
||||
memoLog.SelStart := Length(memoLog.Text);
|
||||
{ Свой журнал сам держится конца, пока пользователь не отмотал назад, и
|
||||
сам обрезает старые строки. }
|
||||
memoLog.Add(AText);
|
||||
end;
|
||||
|
||||
procedure TProgressForm.Finish(ASuccess: Boolean; const AMessage: string);
|
||||
|
|
@ -101,6 +149,8 @@ begin
|
|||
FFinished := True;
|
||||
btnAction.Caption := 'Закрыть';
|
||||
lblStage.Caption := AMessage;
|
||||
{ На месте оценки — итог: сколько всё это заняло. }
|
||||
lblEta.Caption := 'Заняло ' + FormatSpan((Now - FStartedAt) * SecsPerDay);
|
||||
if ASuccess then
|
||||
lblStage.Font.Color := clDefault
|
||||
else
|
||||
|
|
|
|||
780
src/uProject.pas
|
|
@ -34,7 +34,60 @@ type
|
|||
кадра придётся перекодировать. }
|
||||
TEdgeMode = (edSnap, edPrecise);
|
||||
|
||||
TClipKind = (ckVideo, ckImage, ckBlack);
|
||||
{ ckAudio — звуковой клип: картинку из него не берут.
|
||||
|
||||
Отдельный вид, а не признак дорожки, потому что решение принимается по
|
||||
клипу: сборка спрашивает у куска, что из него доставать. }
|
||||
TClipKind = (ckVideo, ckImage, ckBlack, ckAudio, ckText);
|
||||
|
||||
{ Положение текста в кадре: девять клеток, как на цифровой клавиатуре.
|
||||
Числа совпадают с полем Alignment в ASS — переводить не приходится. }
|
||||
TTextPlace = (
|
||||
tpBottomLeft = 1, tpBottomCenter = 2, tpBottomRight = 3,
|
||||
tpMiddleLeft = 4, tpMiddleCenter = 5, tpMiddleRight = 6,
|
||||
tpTopLeft = 7, tpTopCenter = 8, tpTopRight = 9
|
||||
);
|
||||
|
||||
{ Цветовой ключ: цвет, который у клипа становится прозрачным.
|
||||
|
||||
Нужен для накладок — плиток, значков, рамок, снятых на однотонном
|
||||
фоне. Пурпурный берут потому, что в природе он почти не встречается:
|
||||
ошибиться и вырезать кусок картинки труднее, чем с зелёным.
|
||||
|
||||
Клип с ключом собрать копированием нельзя: участок пересобирается
|
||||
вместе с тем, что под ним, как и полупрозрачный. }
|
||||
TColorKey = record
|
||||
Enabled: Boolean;
|
||||
Color: TRgb;
|
||||
{ Насколько далеко от указанного цвета считать «тем же цветом», в
|
||||
процентах. Ноль — только точное совпадение. }
|
||||
Similarity: Integer;
|
||||
{ Смягчение края, в процентах. Ноль — резкая граница. }
|
||||
Blend: Integer;
|
||||
end;
|
||||
PColorKey = ^TColorKey;
|
||||
|
||||
{ Оформление текстового клипа.
|
||||
|
||||
Цвета хранятся как TColor, а в ASS переводятся: там свой порядок
|
||||
составляющих и своя запись прозрачности. }
|
||||
TTextStyle = record
|
||||
FontName: string;
|
||||
{ Размер в точках кадра проекта, а не экрана. Он же верхняя граница:
|
||||
если текст не влезает в отведённую полосу, размер уменьшается. }
|
||||
FontSize: Integer;
|
||||
Color: TRgb;
|
||||
OutlineColor: TRgb;
|
||||
{ Толщина обводки и смещение тени в точках. Ноль — нет вовсе. }
|
||||
Outline: Integer;
|
||||
Shadow: Integer;
|
||||
ShadowColor: TRgb;
|
||||
Place: TTextPlace;
|
||||
{ Какую долю высоты кадра тексту позволено занять. 0.2 — пятую часть. }
|
||||
MaxHeight: Double;
|
||||
Bold: Boolean;
|
||||
Italic: Boolean;
|
||||
end;
|
||||
|
||||
{ На какие дорожки действует схлопывание.
|
||||
|
||||
|
|
@ -80,8 +133,37 @@ type
|
|||
FKind: TClipKind;
|
||||
FSelected: Boolean;
|
||||
FVolume: Double;
|
||||
FTransform: TMediaTransform;
|
||||
FGroupId: string;
|
||||
FOpacity: Double;
|
||||
FFadeIn: Double;
|
||||
FFadeOut: Double;
|
||||
FText: string;
|
||||
FTextStyle: TTextStyle;
|
||||
FColorKey: TColorKey;
|
||||
public
|
||||
constructor Create;
|
||||
{ Переносит на этот клип всё, что настроено у другого: громкость,
|
||||
кадрирование, прозрачность, ключ, затухания, текст.
|
||||
|
||||
Не переносится то, что делает клип этим клипом: имя, место на
|
||||
монтаже, кусок исходника, связка и выделение.
|
||||
|
||||
Зачем метод, а не переписывание полей на месте: копия клипа нужна в
|
||||
нескольких местах (рез — самое частое), и каждое новое свойство
|
||||
иначе приходится вспоминать в каждом из них. Один раз не вспомнили —
|
||||
и после реза половина клипа теряла цветовой ключ, а вместе с ним и
|
||||
прозрачность: она переставала быть наложением и закрывала собой всё,
|
||||
что ниже. }
|
||||
procedure CopySettingsFrom(ASource: TClip);
|
||||
{ Переносит только оформление: кадрирование, поворот, увеличение,
|
||||
смещение, прозрачность, ключ, затухания, громкость.
|
||||
|
||||
От CopySettingsFrom отличается тем, что не трогает вид клипа и его
|
||||
текст: свойства переносят между разными клипами, и превращать
|
||||
картинку в надпись при этом незачем. Оформление надписи переносится
|
||||
только между надписями. }
|
||||
procedure CopyLookFrom(ASource: TClip);
|
||||
function TlEnd: Double;
|
||||
function SrcOut: Double;
|
||||
{ Попадает ли момент внутрь клипа. Правый край не включается: там уже
|
||||
|
|
@ -99,14 +181,49 @@ type
|
|||
громче. Хранится множителем, а не в децибелах, чтобы не переводить
|
||||
туда-сюда при каждой отрисовке; в интерфейсе показывается процентами. }
|
||||
property Volume: Double read FVolume write FVolume;
|
||||
{ Как кадр этого клипа ложится в кадр проекта.
|
||||
|
||||
Настройка принадлежит клипу, а не файлу: два куска одного файла
|
||||
бывает нужно кадрировать по-разному. У файла остаётся такая же
|
||||
настройка, но она лишь заготовка — её берёт новый клип при
|
||||
добавлении. }
|
||||
property Transform: TMediaTransform read FTransform write FTransform;
|
||||
{ Непрозрачность клипа картинки: 1 — обычный, 0.5 — сквозь него видно
|
||||
то, что ниже.
|
||||
|
||||
Стоит понимать цену: как только клип перестаёт быть непрозрачным,
|
||||
его нельзя скопировать — участок приходится пересобирать вместе со
|
||||
всем, что под ним. Поэтому по умолчанию единица, и программа
|
||||
предупреждает перед сборкой, сколько секунд это займёт. }
|
||||
property Opacity: Double read FOpacity write FOpacity;
|
||||
{ Плавное появление и уход, в секундах от краёв клипа. Для картинки
|
||||
это затемнение, для звука — нарастание громкости. }
|
||||
property FadeIn: Double read FFadeIn write FFadeIn;
|
||||
property FadeOut: Double read FFadeOut write FFadeOut;
|
||||
{ Связка. Клипы с одинаковым непустым значением двигаются, режутся и
|
||||
удаляются вместе. По умолчанию так связаны картинка и её звук;
|
||||
Ctrl+G связывает и развязывает что угодно. }
|
||||
property GroupId: string read FGroupId write FGroupId;
|
||||
{ Цветовой ключ клипа. Выключен — клип обычный. }
|
||||
property ColorKey: TColorKey read FColorKey write FColorKey;
|
||||
{ Текст и его оформление. Имеют смысл только у клипа вида ckText;
|
||||
у остальных лежат пустыми и в файл проекта не пишутся. }
|
||||
property Text: string read FText write FText;
|
||||
property TextStyle: TTextStyle read FTextStyle write FTextStyle;
|
||||
{ Выделение живёт только в памяти и в файл проекта не пишется. }
|
||||
property Selected: Boolean read FSelected write FSelected;
|
||||
end;
|
||||
|
||||
{ Что живёт на дорожке. Картинка и звук разделены, потому что их можно
|
||||
двигать порознь: звук из одного места монтажа под картинкой из
|
||||
другого — обычное дело. }
|
||||
TTrackKind = (tkVideo, tkAudio, tkText);
|
||||
|
||||
TTrack = class
|
||||
private
|
||||
FId: string;
|
||||
FName: string;
|
||||
FKind: TTrackKind;
|
||||
FMuted: Boolean;
|
||||
FSolo: Boolean;
|
||||
FClips: TObjectList;
|
||||
|
|
@ -126,6 +243,7 @@ type
|
|||
property Count: Integer read GetCount;
|
||||
property Id: string read FId write FId;
|
||||
property Name: string read FName write FName;
|
||||
property Kind: TTrackKind read FKind write FKind;
|
||||
property Muted: Boolean read FMuted write FMuted;
|
||||
{ Когда хоть у одной дорожки включено «соло», в результат идут только
|
||||
такие дорожки. Удобно, чтобы послушать одну, не выключая остальные
|
||||
|
|
@ -139,6 +257,9 @@ type
|
|||
FTracks: TObjectList;
|
||||
FMaster: TMediaInfo;
|
||||
FRippleMode: TRippleMode;
|
||||
{ Отмеченный участок монтажа: от и до. Пока не отмечен, оба нуля. }
|
||||
FRegionStart: Double;
|
||||
FRegionEnd: Double;
|
||||
FRippleEnabled: Boolean;
|
||||
FFileName: string;
|
||||
FModified: Boolean;
|
||||
|
|
@ -149,10 +270,12 @@ type
|
|||
function GetPoolCount: Integer;
|
||||
function GetTrack(AIndex: Integer): TTrack;
|
||||
function GetTrackCount: Integer;
|
||||
function NewId(const APrefix: string): string;
|
||||
function Serialize: string;
|
||||
procedure Deserialize(const AText: string);
|
||||
public
|
||||
{ Неповторимый номер для нового клипа или дорожки. Публичный: клипы
|
||||
заводит не только сам проект — например, надпись создаёт окно. }
|
||||
function NewId(const APrefix: string): string;
|
||||
constructor Create;
|
||||
destructor Destroy; override;
|
||||
|
||||
|
|
@ -164,11 +287,46 @@ type
|
|||
function FindMediaByPath(const APath: string): TMediaItem;
|
||||
procedure RemoveMedia(AItem: TMediaItem);
|
||||
|
||||
{ Номер новой связки. Отдельным именем, чтобы не пришлось помнить
|
||||
префикс в каждом месте, где связка заводится. }
|
||||
function NewGroupId: string;
|
||||
{ Имя, которого ещё нет ни у одной дорожки: V1, V2, ... Считать
|
||||
дорожки нужного вида и прибавлять единицу нельзя — после удаления
|
||||
средней счёт совпадёт с уже занятым именем, и в окне окажутся две
|
||||
дорожки Т2. }
|
||||
function UniqueTrackName(const APrefix: string): string;
|
||||
{ Разводит одинаковые имена дорожек, доставшиеся от старых проектов.
|
||||
Имя — только подпись, но две дорожки Т2 в окне сбивают с толку. }
|
||||
procedure EnsureUniqueTrackNames;
|
||||
function AddTrack(const AName: string): TTrack;
|
||||
function AddTrackOfKind(const AName: string; AKind: TTrackKind): TTrack;
|
||||
function FindTrack(const AId: string): TTrack;
|
||||
procedure RemoveTrack(AIndex: Integer);
|
||||
procedure MoveTrack(AFrom, ATo: Integer);
|
||||
|
||||
{ Сколько дорожек каждого вида и как найти парную.
|
||||
|
||||
Пара определяется порядковым номером: у первой дорожки картинки
|
||||
первая дорожка звука. Так связка «картинка плюс её звук» ложится
|
||||
напротив друг друга, и монтаж читается взглядом. }
|
||||
function TrackCountOf(AKind: TTrackKind): Integer;
|
||||
function TrackOfKind(AKind: TTrackKind; AIndex: Integer): TTrack;
|
||||
function IndexAmongKind(ATrack: TTrack): Integer;
|
||||
{ Заводит недостающие звуковые дорожки, чтобы у каждой дорожки
|
||||
картинки была парная. }
|
||||
procedure EnsureAudioTracks;
|
||||
{ Перенос проектов, записанных до разделения звука и картинки: там
|
||||
звук шёл вместе с клипом картинки, и без пары он бы просто пропал. }
|
||||
procedure MigrateAudio;
|
||||
{ Клип на звуковой дорожке обязан быть звуковым. В проектах, записанных
|
||||
до того, как вид клипа научился сохраняться, он остался «видео» — и
|
||||
тогда у клипа пропадает форма звука, появляется лишняя кнопка
|
||||
кадрирования, а ползунок правит прозрачность вместо громкости. }
|
||||
procedure FixClipKinds;
|
||||
{ Кладёт клип и, если в файле есть звук, парный звуковой клип на
|
||||
соответствующую звуковую дорожку. Возвращает клип картинки. }
|
||||
function AddClipPair(ATrack: TTrack; AMedia: TMediaItem;
|
||||
const ASrcIn, ADuration, ATlStart: Double): TClip;
|
||||
function AddClip(ATrack: TTrack; AMedia: TMediaItem;
|
||||
const ASrcIn, ADuration, ATlStart: Double): TClip;
|
||||
function FindClipTrack(AClip: TClip): TTrack;
|
||||
|
|
@ -222,6 +380,38 @@ type
|
|||
{ Режим действует всегда, даже когда автосхлопывание выключено:
|
||||
он определяет, что сделает Ctrl+Shift+R. }
|
||||
property RippleMode: TRippleMode read FRippleMode write FRippleMode;
|
||||
{ Отмеченный участок: собирать можно только его. Нужен, чтобы
|
||||
проверить кусок монтажа, не собирая весь ролик — на длинном
|
||||
материале это разница между минутой и часом.
|
||||
|
||||
Отметок две, и обе живут в проекте, а не в настройках программы:
|
||||
участок относится к этому монтажу, а не к привычкам человека. }
|
||||
property RegionStart: Double read FRegionStart write FRegionStart;
|
||||
property RegionEnd: Double read FRegionEnd write FRegionEnd;
|
||||
function HasRegion: Boolean;
|
||||
{ Наибольший битрейт среди видеофайлов проекта.
|
||||
|
||||
Им целятся при пересборке кусков. Брать средний или битрейт первого
|
||||
файла нельзя: пересобранный кусок стоит рядом со скопированными, и
|
||||
если он окажется хуже их, разница бросится в глаза именно на нём.
|
||||
Лучше потратить место, чем показать шакала посреди чистой съёмки. }
|
||||
function MaxVideoBitrate: Int64;
|
||||
{ Какая дорожка нужна клипу этого вида. Правило одно на всю
|
||||
программу: картинке — дорожка картинки, звуку — звуковая, надписи —
|
||||
текстовая. }
|
||||
class function TrackKindFor(AClipKind: TClipKind): TTrackKind;
|
||||
{ Сколько клипов лежит на дорожках не своего вида.
|
||||
|
||||
Такой клип не попадает никуда: уплощение обходит дорожки по видам,
|
||||
и клип с чужой дорожки не видит ни сборка, ни предпросмотр. Пропажа
|
||||
при этом тихая — на монтаже он нарисован, а в ролике его нет. }
|
||||
function MisplacedClipCount: Integer;
|
||||
{ Переносит их на подходящие дорожки. Возвращает, сколько перенесено.
|
||||
|
||||
Ищется первая дорожка нужного вида, где это время свободно; если
|
||||
такой нет — заводится новая. Время клипа не меняется: человек ставил
|
||||
его именно туда. }
|
||||
function FixMisplacedClips: Integer;
|
||||
{ Автоматическое схлопывание при перетаскивании клипов. }
|
||||
property RippleEnabled: Boolean read FRippleEnabled write FRippleEnabled;
|
||||
property FileName: string read FFileName write FFileName;
|
||||
|
|
@ -232,6 +422,23 @@ const
|
|||
{ Сколько длится картинка, пока её не растянули. }
|
||||
DefaultImageDuration = 5.0;
|
||||
|
||||
{ Цветовой ключ по умолчанию. }
|
||||
function DefaultColorKey: TColorKey;
|
||||
|
||||
{ Одинаковы ли два ключа. Записи в Паскале сравнивать оператором нельзя,
|
||||
а знать, изменилось ли что-нибудь, нужно: иначе в истории отмены
|
||||
появится пустой шаг. }
|
||||
function SameColorKey(const A, B: TColorKey): Boolean;
|
||||
|
||||
{ Оформление текста по умолчанию: белые буквы с тёмной обводкой внизу по
|
||||
центру — то, что читается на любой картинке. }
|
||||
function DefaultTextStyle: TTextStyle;
|
||||
|
||||
{ Шрифт по умолчанию. Разный по системам: одинаковых имён у них нет. }
|
||||
const
|
||||
DefaultTextFont =
|
||||
{$IFDEF WINDOWS}'Arial'{$ELSE}'DejaVu Sans'{$ENDIF};
|
||||
|
||||
implementation
|
||||
|
||||
uses
|
||||
|
|
@ -259,11 +466,44 @@ end;
|
|||
|
||||
{ --- TClip --- }
|
||||
|
||||
procedure TClip.CopySettingsFrom(ASource: TClip);
|
||||
begin
|
||||
if ASource = nil then
|
||||
Exit;
|
||||
FKind := ASource.Kind;
|
||||
FVolume := ASource.Volume;
|
||||
FTransform := ASource.Transform;
|
||||
FOpacity := ASource.Opacity;
|
||||
FColorKey := ASource.ColorKey;
|
||||
FFadeIn := ASource.FadeIn;
|
||||
FFadeOut := ASource.FadeOut;
|
||||
FText := ASource.Text;
|
||||
FTextStyle := ASource.TextStyle;
|
||||
end;
|
||||
|
||||
procedure TClip.CopyLookFrom(ASource: TClip);
|
||||
begin
|
||||
if ASource = nil then
|
||||
Exit;
|
||||
FTransform := ASource.Transform;
|
||||
FOpacity := ASource.Opacity;
|
||||
FColorKey := ASource.ColorKey;
|
||||
FFadeIn := ASource.FadeIn;
|
||||
FFadeOut := ASource.FadeOut;
|
||||
FVolume := ASource.Volume;
|
||||
if (FKind = ckText) and (ASource.Kind = ckText) then
|
||||
FTextStyle := ASource.TextStyle;
|
||||
end;
|
||||
|
||||
constructor TClip.Create;
|
||||
begin
|
||||
inherited Create;
|
||||
{ Без явного значения множитель был бы нулём, то есть тишиной. }
|
||||
FVolume := 1;
|
||||
FOpacity := 1;
|
||||
FTransform := DefaultTransform;
|
||||
FTextStyle := DefaultTextStyle;
|
||||
FColorKey := DefaultColorKey;
|
||||
end;
|
||||
|
||||
function TClip.TlEnd: Double;
|
||||
|
|
@ -401,6 +641,8 @@ begin
|
|||
FMaster := Default(TMediaInfo);
|
||||
FRippleMode := rmCurrentTrack;
|
||||
FRippleEnabled := False;
|
||||
FRegionStart := 0;
|
||||
FRegionEnd := 0;
|
||||
FFileName := '';
|
||||
FModified := False;
|
||||
FNextId := 1;
|
||||
|
|
@ -411,8 +653,12 @@ begin
|
|||
Clear;
|
||||
{ Две дорожки с самого начала: одна пустая дорожка не показывает главного —
|
||||
что верхняя перекрывает нижнюю. }
|
||||
{ Две дорожки картинки и две звука. Звук снизу: так принято везде, и
|
||||
так он не мешает смотреть на картинку. }
|
||||
AddTrack('V1');
|
||||
AddTrack('V2');
|
||||
AddTrackOfKind('A1', tkAudio);
|
||||
AddTrackOfKind('A2', tkAudio);
|
||||
FModified := False;
|
||||
end;
|
||||
|
||||
|
|
@ -436,6 +682,11 @@ begin
|
|||
Result := FTracks.Count;
|
||||
end;
|
||||
|
||||
function TProject.NewGroupId: string;
|
||||
begin
|
||||
Result := NewId('g');
|
||||
end;
|
||||
|
||||
function TProject.NewId(const APrefix: string): string;
|
||||
begin
|
||||
Result := APrefix + IntToStr(FNextId);
|
||||
|
|
@ -496,12 +747,90 @@ begin
|
|||
FModified := True;
|
||||
end;
|
||||
|
||||
function TProject.UniqueTrackName(const APrefix: string): string;
|
||||
var
|
||||
N, I: Integer;
|
||||
Taken: Boolean;
|
||||
begin
|
||||
N := 1;
|
||||
repeat
|
||||
Result := APrefix + IntToStr(N);
|
||||
Taken := False;
|
||||
for I := 0 to FTracks.Count - 1 do
|
||||
if TTrack(FTracks[I]).Name = Result then
|
||||
begin
|
||||
Taken := True;
|
||||
Break;
|
||||
end;
|
||||
Inc(N);
|
||||
until not Taken;
|
||||
end;
|
||||
|
||||
procedure TProject.EnsureUniqueTrackNames;
|
||||
var
|
||||
I, J: Integer;
|
||||
Prefix: string;
|
||||
begin
|
||||
for I := 0 to FTracks.Count - 1 do
|
||||
for J := 0 to I - 1 do
|
||||
if TTrack(FTracks[J]).Name = TTrack(FTracks[I]).Name then
|
||||
begin
|
||||
case TTrack(FTracks[I]).Kind of
|
||||
tkAudio: Prefix := 'A';
|
||||
tkText: Prefix := 'Т';
|
||||
else
|
||||
Prefix := 'V';
|
||||
end;
|
||||
TTrack(FTracks[I]).Name := UniqueTrackName(Prefix);
|
||||
Break;
|
||||
end;
|
||||
end;
|
||||
|
||||
function TProject.AddTrack(const AName: string): TTrack;
|
||||
begin
|
||||
Result := AddTrackOfKind(AName, tkVideo);
|
||||
end;
|
||||
|
||||
function TProject.AddTrackOfKind(const AName: string;
|
||||
AKind: TTrackKind): TTrack;
|
||||
var
|
||||
I, Pos: Integer;
|
||||
begin
|
||||
Result := TTrack.Create;
|
||||
Result.Id := NewId('t');
|
||||
Result.Name := AName;
|
||||
FTracks.Add(Result);
|
||||
Result.Kind := AKind;
|
||||
|
||||
{ Порядок дорожек не только украшение: он задаёт, кто побеждает при
|
||||
перекрытии. Картинка всегда выше звука, поэтому новая дорожка
|
||||
картинки встаёт перед первой звуковой, а не в самый конец. }
|
||||
if AKind = tkVideo then
|
||||
begin
|
||||
Pos := FTracks.Count;
|
||||
for I := 0 to FTracks.Count - 1 do
|
||||
if TTrack(FTracks[I]).Kind in [tkAudio, tkText] then
|
||||
begin
|
||||
Pos := I;
|
||||
Break;
|
||||
end;
|
||||
FTracks.Insert(Pos, Result);
|
||||
end
|
||||
else if AKind = tkText then
|
||||
begin
|
||||
{ Текст выше звука, но ниже картинки: на монтаже он читается как
|
||||
надстройка над видеорядом, а не как ещё одна дорожка звука. }
|
||||
Pos := FTracks.Count;
|
||||
for I := 0 to FTracks.Count - 1 do
|
||||
if TTrack(FTracks[I]).Kind = tkAudio then
|
||||
begin
|
||||
Pos := I;
|
||||
Break;
|
||||
end;
|
||||
FTracks.Insert(Pos, Result);
|
||||
end
|
||||
else
|
||||
FTracks.Add(Result);
|
||||
|
||||
FModified := True;
|
||||
end;
|
||||
|
||||
|
|
@ -533,6 +862,168 @@ begin
|
|||
FModified := True;
|
||||
end;
|
||||
|
||||
function TProject.TrackCountOf(AKind: TTrackKind): Integer;
|
||||
var
|
||||
I: Integer;
|
||||
begin
|
||||
Result := 0;
|
||||
for I := 0 to FTracks.Count - 1 do
|
||||
if TTrack(FTracks[I]).Kind = AKind then
|
||||
Inc(Result);
|
||||
end;
|
||||
|
||||
function TProject.TrackOfKind(AKind: TTrackKind; AIndex: Integer): TTrack;
|
||||
var
|
||||
I, N: Integer;
|
||||
begin
|
||||
Result := nil;
|
||||
N := 0;
|
||||
for I := 0 to FTracks.Count - 1 do
|
||||
if TTrack(FTracks[I]).Kind = AKind then
|
||||
begin
|
||||
if N = AIndex then
|
||||
Exit(TTrack(FTracks[I]));
|
||||
Inc(N);
|
||||
end;
|
||||
end;
|
||||
|
||||
function TProject.IndexAmongKind(ATrack: TTrack): Integer;
|
||||
var
|
||||
I, N: Integer;
|
||||
begin
|
||||
Result := -1;
|
||||
N := 0;
|
||||
for I := 0 to FTracks.Count - 1 do
|
||||
if TTrack(FTracks[I]).Kind = ATrack.Kind then
|
||||
begin
|
||||
if TTrack(FTracks[I]) = ATrack then
|
||||
Exit(N);
|
||||
Inc(N);
|
||||
end;
|
||||
end;
|
||||
|
||||
procedure TProject.MigrateAudio;
|
||||
var
|
||||
T, C: Integer;
|
||||
Track, Dst: TTrack;
|
||||
Clip, Snd: TClip;
|
||||
Item: TMediaItem;
|
||||
Group: string;
|
||||
Sources: TFPList;
|
||||
begin
|
||||
{ Если звуковые дорожки уже есть, проект новый и переносить нечего. }
|
||||
if TrackCountOf(tkAudio) > 0 then
|
||||
Exit;
|
||||
if TrackCountOf(tkVideo) = 0 then
|
||||
Exit;
|
||||
|
||||
EnsureAudioTracks;
|
||||
|
||||
{ Клипы собираются заранее: добавление в дорожку меняет её список, и
|
||||
перебирать его на ходу нельзя. }
|
||||
Sources := TFPList.Create;
|
||||
try
|
||||
for T := 0 to FTracks.Count - 1 do
|
||||
begin
|
||||
Track := TTrack(FTracks[T]);
|
||||
if Track.Kind <> tkVideo then
|
||||
Continue;
|
||||
for C := 0 to Track.Count - 1 do
|
||||
Sources.Add(Track[C]);
|
||||
end;
|
||||
|
||||
for C := 0 to Sources.Count - 1 do
|
||||
begin
|
||||
Clip := TClip(Sources[C]);
|
||||
if Clip.Kind = ckBlack then
|
||||
Continue;
|
||||
Item := FindMedia(Clip.MediaId);
|
||||
if (Item = nil) or (not Item.Info.HasAudio) then
|
||||
Continue;
|
||||
|
||||
Dst := nil;
|
||||
for T := 0 to FTracks.Count - 1 do
|
||||
if TTrack(FTracks[T]).IndexOf(Clip) >= 0 then
|
||||
begin
|
||||
Dst := TrackOfKind(tkAudio, IndexAmongKind(TTrack(FTracks[T])));
|
||||
Break;
|
||||
end;
|
||||
if Dst = nil then
|
||||
Continue;
|
||||
|
||||
Group := NewId('g');
|
||||
Clip.GroupId := Group;
|
||||
Snd := AddClip(Dst, Item, Clip.SrcIn, Clip.Duration, Clip.TlStart);
|
||||
Snd.GroupId := Group;
|
||||
Snd.Kind := ckAudio;
|
||||
Snd.Volume := Clip.Volume;
|
||||
end;
|
||||
finally
|
||||
Sources.Free;
|
||||
end;
|
||||
|
||||
for T := 0 to FTracks.Count - 1 do
|
||||
TTrack(FTracks[T]).Sort;
|
||||
end;
|
||||
|
||||
procedure TProject.FixClipKinds;
|
||||
var
|
||||
T, C: Integer;
|
||||
Track: TTrack;
|
||||
begin
|
||||
for T := 0 to FTracks.Count - 1 do
|
||||
begin
|
||||
Track := TTrack(FTracks[T]);
|
||||
for C := 0 to Track.Count - 1 do
|
||||
if Track.Kind = tkAudio then
|
||||
begin
|
||||
if Track[C].Kind <> ckBlack then
|
||||
Track[C].Kind := ckAudio;
|
||||
end
|
||||
else if Track[C].Kind = ckAudio then
|
||||
Track[C].Kind := ckVideo;
|
||||
end;
|
||||
end;
|
||||
|
||||
procedure TProject.EnsureAudioTracks;
|
||||
var
|
||||
Need, Have, I: Integer;
|
||||
Track: TTrack;
|
||||
begin
|
||||
Need := TrackCountOf(tkVideo);
|
||||
Have := TrackCountOf(tkAudio);
|
||||
for I := Have to Need - 1 do
|
||||
begin
|
||||
Track := AddTrackOfKind('A' + IntToStr(I + 1), tkAudio);
|
||||
end;
|
||||
end;
|
||||
|
||||
function TProject.AddClipPair(ATrack: TTrack; AMedia: TMediaItem;
|
||||
const ASrcIn, ADuration, ATlStart: Double): TClip;
|
||||
var
|
||||
ATrackAudio: TTrack;
|
||||
Snd: TClip;
|
||||
Group: string;
|
||||
begin
|
||||
Result := AddClip(ATrack, AMedia, ASrcIn, ADuration, ATlStart);
|
||||
if (ATrack.Kind <> tkVideo) or (not AMedia.Info.HasAudio) then
|
||||
Exit;
|
||||
|
||||
EnsureAudioTracks;
|
||||
ATrackAudio := TrackOfKind(tkAudio, IndexAmongKind(ATrack));
|
||||
if ATrackAudio = nil then
|
||||
Exit;
|
||||
|
||||
Group := NewId('g');
|
||||
Result.GroupId := Group;
|
||||
Snd := AddClip(ATrackAudio, AMedia, ASrcIn, ADuration, ATlStart);
|
||||
Snd.GroupId := Group;
|
||||
{ Клип звука знает, что он звук: иначе сборка попыталась бы взять из
|
||||
него картинку. }
|
||||
Snd.Kind := ckAudio;
|
||||
Snd.Transform := DefaultTransform;
|
||||
end;
|
||||
|
||||
function TProject.AddClip(ATrack: TTrack; AMedia: TMediaItem;
|
||||
const ASrcIn, ADuration, ATlStart: Double): TClip;
|
||||
begin
|
||||
|
|
@ -548,6 +1039,9 @@ begin
|
|||
Result.Kind := ckImage
|
||||
else
|
||||
Result.Kind := ckVideo;
|
||||
{ Настройка файла — заготовка: новый клип берёт её при добавлении и
|
||||
дальше живёт своей. }
|
||||
Result.Transform := AMedia.Transform;
|
||||
ATrack.Add(Result);
|
||||
FModified := True;
|
||||
end;
|
||||
|
|
@ -574,6 +1068,122 @@ begin
|
|||
end;
|
||||
end;
|
||||
|
||||
{ Участок считается отмеченным, только если он не пустой: две отметки в
|
||||
одной точке — это отсутствие участка, а не участок нулевой длины. }
|
||||
function TProject.HasRegion: Boolean;
|
||||
begin
|
||||
Result := FRegionEnd > FRegionStart + 0.001;
|
||||
end;
|
||||
|
||||
class function TProject.TrackKindFor(AClipKind: TClipKind): TTrackKind;
|
||||
begin
|
||||
case AClipKind of
|
||||
ckAudio: Result := tkAudio;
|
||||
ckText: Result := tkText;
|
||||
else
|
||||
Result := tkVideo;
|
||||
end;
|
||||
end;
|
||||
|
||||
function TProject.MisplacedClipCount: Integer;
|
||||
var
|
||||
T, C: Integer;
|
||||
Track: TTrack;
|
||||
begin
|
||||
Result := 0;
|
||||
for T := 0 to TrackCount - 1 do
|
||||
begin
|
||||
Track := Tracks[T];
|
||||
for C := 0 to Track.Count - 1 do
|
||||
if TrackKindFor(Track[C].Kind) <> Track.Kind then
|
||||
Inc(Result);
|
||||
end;
|
||||
end;
|
||||
|
||||
function TProject.FixMisplacedClips: Integer;
|
||||
var
|
||||
T, C, K: Integer;
|
||||
Track, Target: TTrack;
|
||||
Clip: TClip;
|
||||
Need: TTrackKind;
|
||||
Moved: TFPList;
|
||||
|
||||
{ Свободна ли дорожка в это время. }
|
||||
function Fits(ATrack: TTrack; AClip: TClip): Boolean;
|
||||
var
|
||||
I: Integer;
|
||||
begin
|
||||
Result := True;
|
||||
for I := 0 to ATrack.Count - 1 do
|
||||
if (AClip.TlStart < ATrack[I].TlEnd - 0.001) and
|
||||
(AClip.TlEnd > ATrack[I].TlStart + 0.001) then
|
||||
Exit(False);
|
||||
end;
|
||||
|
||||
begin
|
||||
Result := 0;
|
||||
Moved := TFPList.Create;
|
||||
try
|
||||
{ Сперва собираем список: переносить прямо во время обхода нельзя —
|
||||
состав дорожек меняется под ногами. }
|
||||
for T := 0 to TrackCount - 1 do
|
||||
begin
|
||||
Track := Tracks[T];
|
||||
for C := 0 to Track.Count - 1 do
|
||||
if TrackKindFor(Track[C].Kind) <> Track.Kind then
|
||||
Moved.Add(Track[C]);
|
||||
end;
|
||||
|
||||
for C := 0 to Moved.Count - 1 do
|
||||
begin
|
||||
Clip := TClip(Moved[C]);
|
||||
Track := FindClipTrack(Clip);
|
||||
if Track = nil then
|
||||
Continue;
|
||||
Need := TrackKindFor(Clip.Kind);
|
||||
|
||||
Target := nil;
|
||||
for K := 0 to TrackCount - 1 do
|
||||
if (Tracks[K].Kind = Need) and Fits(Tracks[K], Clip) then
|
||||
begin
|
||||
Target := Tracks[K];
|
||||
Break;
|
||||
end;
|
||||
|
||||
if Target = nil then
|
||||
case Need of
|
||||
tkAudio: Target := AddTrackOfKind(UniqueTrackName('A'), tkAudio);
|
||||
tkText: Target := AddTrackOfKind(UniqueTrackName('Т'), tkText);
|
||||
else
|
||||
Target := AddTrackOfKind(UniqueTrackName('V'), tkVideo);
|
||||
end;
|
||||
|
||||
Track.Extract(Clip);
|
||||
Target.Add(Clip);
|
||||
Target.Sort;
|
||||
Inc(Result);
|
||||
end;
|
||||
finally
|
||||
Moved.Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
function TProject.MaxVideoBitrate: Int64;
|
||||
var
|
||||
I: Integer;
|
||||
Item: TMediaItem;
|
||||
begin
|
||||
Result := FMaster.VideoBitrate;
|
||||
for I := 0 to FPool.Count - 1 do
|
||||
begin
|
||||
Item := TMediaItem(FPool[I]);
|
||||
if not Item.Info.HasVideo then
|
||||
Continue;
|
||||
if Item.Info.VideoBitrate > Result then
|
||||
Result := Item.Info.VideoBitrate;
|
||||
end;
|
||||
end;
|
||||
|
||||
procedure TProject.ClearSelection;
|
||||
var
|
||||
T, C: Integer;
|
||||
|
|
@ -644,7 +1254,7 @@ begin
|
|||
if FFileName = '' then
|
||||
{ Проект ещё не сохранён — держим кэш рядом с программой, чтобы не
|
||||
разбрасывать файлы по системным каталогам. }
|
||||
Result := IncludeTrailingPathDelimiter(ExtractFilePath(ParamStr(0))) +
|
||||
Result := ProgramDir +
|
||||
'unsaved.nlve-cache' + PathDelim
|
||||
else
|
||||
Result := IncludeTrailingPathDelimiter(ExtractFilePath(FFileName)) +
|
||||
|
|
@ -785,11 +1395,98 @@ begin
|
|||
Result := edSnap;
|
||||
end;
|
||||
|
||||
function SameColorKey(const A, B: TColorKey): Boolean;
|
||||
begin
|
||||
Result := (A.Enabled = B.Enabled) and (A.Color = B.Color) and
|
||||
(A.Similarity = B.Similarity) and (A.Blend = B.Blend);
|
||||
end;
|
||||
|
||||
{ Цветовой ключ по умолчанию: выключен, пурпурный, с небольшим запасом по
|
||||
сходству — сжатие видео размывает чистый цвет, и точное совпадение
|
||||
оставило бы кайму. }
|
||||
function DefaultColorKey: TColorKey;
|
||||
begin
|
||||
Result.Enabled := False;
|
||||
Result.Color := TRgb($FF00FF);
|
||||
Result.Similarity := 15;
|
||||
Result.Blend := 5;
|
||||
end;
|
||||
|
||||
{ Оформление текста по умолчанию: белые буквы с тёмной обводкой внизу по
|
||||
центру — то, что читается на любой картинке. }
|
||||
function DefaultTextStyle: TTextStyle;
|
||||
begin
|
||||
Result.FontName := DefaultTextFont;
|
||||
Result.FontSize := 48;
|
||||
Result.Color := TRgb($FFFFFF);
|
||||
Result.OutlineColor := TRgb($202020);
|
||||
Result.Outline := 3;
|
||||
Result.Shadow := 2;
|
||||
Result.ShadowColor := TRgb($000000);
|
||||
Result.Place := tpBottomCenter;
|
||||
Result.MaxHeight := 0.2;
|
||||
Result.Bold := False;
|
||||
Result.Italic := False;
|
||||
end;
|
||||
|
||||
function JsonToColorKey(AObj: TJSONObject): TColorKey;
|
||||
begin
|
||||
Result := DefaultColorKey;
|
||||
if AObj = nil then
|
||||
Exit;
|
||||
Result.Enabled := AObj.Get('enabled', Result.Enabled);
|
||||
Result.Color := TRgb(AObj.Get('color', Integer(Result.Color)));
|
||||
Result.Similarity := AObj.Get('similarity', Result.Similarity);
|
||||
Result.Blend := AObj.Get('blend', Result.Blend);
|
||||
end;
|
||||
|
||||
function JsonToTextStyle(AObj: TJSONObject): TTextStyle;
|
||||
begin
|
||||
Result := DefaultTextStyle;
|
||||
if AObj = nil then
|
||||
Exit;
|
||||
Result.FontName := AObj.Get('font', Result.FontName);
|
||||
Result.FontSize := AObj.Get('size', Result.FontSize);
|
||||
Result.Color := TRgb(AObj.Get('color', Integer(Result.Color)));
|
||||
Result.OutlineColor := TRgb(AObj.Get('outline_color',
|
||||
Integer(Result.OutlineColor)));
|
||||
Result.Outline := AObj.Get('outline', Result.Outline);
|
||||
Result.Shadow := AObj.Get('shadow', Result.Shadow);
|
||||
Result.ShadowColor := TRgb(AObj.Get('shadow_color',
|
||||
Integer(Result.ShadowColor)));
|
||||
Result.Place := TTextPlace(AObj.Get('place', Integer(Ord(Result.Place))));
|
||||
Result.MaxHeight := AObj.Get('max_height', Result.MaxHeight);
|
||||
Result.Bold := AObj.Get('bold', Result.Bold);
|
||||
Result.Italic := AObj.Get('italic', Result.Italic);
|
||||
end;
|
||||
|
||||
function TrackKindToStr(AKind: TTrackKind): string;
|
||||
begin
|
||||
case AKind of
|
||||
tkAudio: Result := 'audio';
|
||||
tkText: Result := 'text';
|
||||
else
|
||||
Result := 'video';
|
||||
end;
|
||||
end;
|
||||
|
||||
function StrToTrackKind(const AValue: string): TTrackKind;
|
||||
begin
|
||||
if AValue = 'audio' then
|
||||
Result := tkAudio
|
||||
else if AValue = 'text' then
|
||||
Result := tkText
|
||||
else
|
||||
Result := tkVideo;
|
||||
end;
|
||||
|
||||
function KindToStr(AKind: TClipKind): string;
|
||||
begin
|
||||
case AKind of
|
||||
ckImage: Result := 'image';
|
||||
ckBlack: Result := 'black';
|
||||
ckAudio: Result := 'audio';
|
||||
ckText: Result := 'text';
|
||||
else
|
||||
Result := 'video';
|
||||
end;
|
||||
|
|
@ -801,6 +1498,10 @@ begin
|
|||
Result := ckImage
|
||||
else if AValue = 'black' then
|
||||
Result := ckBlack
|
||||
else if AValue = 'audio' then
|
||||
Result := ckAudio
|
||||
else if AValue = 'text' then
|
||||
Result := ckText
|
||||
else
|
||||
Result := ckVideo;
|
||||
end;
|
||||
|
|
@ -921,6 +1622,7 @@ begin
|
|||
Obj := TJSONObject.Create;
|
||||
Obj.Add('id', Track.Id);
|
||||
Obj.Add('name', Track.Name);
|
||||
Obj.Add('kind', TrackKindToStr(Track.Kind));
|
||||
Obj.Add('muted', Track.Muted);
|
||||
Obj.Add('solo', Track.Solo);
|
||||
|
||||
|
|
@ -937,7 +1639,42 @@ begin
|
|||
'edge_in', EdgeToStr(Clip.EdgeIn),
|
||||
'edge_out', EdgeToStr(Clip.EdgeOut),
|
||||
'kind', KindToStr(Clip.Kind),
|
||||
'volume', Clip.Volume
|
||||
'group', Clip.GroupId,
|
||||
'volume', Clip.Volume,
|
||||
'opacity', Clip.Opacity,
|
||||
'fade_in', Clip.FadeIn,
|
||||
'fade_out', Clip.FadeOut,
|
||||
'transform', TJSONObject.Create([
|
||||
'fit', FitModeToStr(Clip.Transform.Fit),
|
||||
'rotation', Clip.Transform.Rotation,
|
||||
'flip_h', Clip.Transform.FlipH,
|
||||
'flip_v', Clip.Transform.FlipV,
|
||||
'zoom', Clip.Transform.Zoom,
|
||||
'offset_x', Clip.Transform.OffsetX,
|
||||
'offset_y', Clip.Transform.OffsetY
|
||||
]),
|
||||
{ Оформление пишется только у текстовых клипов: у остальных оно
|
||||
пустое, и таскать его в файле незачем. }
|
||||
'color_key', TJSONObject.Create([
|
||||
'enabled', Clip.ColorKey.Enabled,
|
||||
'color', Integer(Clip.ColorKey.Color),
|
||||
'similarity', Clip.ColorKey.Similarity,
|
||||
'blend', Clip.ColorKey.Blend
|
||||
]),
|
||||
'text', Clip.Text,
|
||||
'text_style', TJSONObject.Create([
|
||||
'font', Clip.TextStyle.FontName,
|
||||
'size', Clip.TextStyle.FontSize,
|
||||
'color', Integer(Clip.TextStyle.Color),
|
||||
'outline_color', Integer(Clip.TextStyle.OutlineColor),
|
||||
'outline', Clip.TextStyle.Outline,
|
||||
'shadow', Clip.TextStyle.Shadow,
|
||||
'shadow_color', Integer(Clip.TextStyle.ShadowColor),
|
||||
'place', Integer(Ord(Clip.TextStyle.Place)),
|
||||
'max_height', Clip.TextStyle.MaxHeight,
|
||||
'bold', Clip.TextStyle.Bold,
|
||||
'italic', Clip.TextStyle.Italic
|
||||
])
|
||||
]));
|
||||
end;
|
||||
Obj.Add('clips', ClipsArr);
|
||||
|
|
@ -947,7 +1684,9 @@ begin
|
|||
|
||||
Root.Add('settings', TJSONObject.Create([
|
||||
'ripple_mode', RippleModeToStr(FRippleMode),
|
||||
'ripple_enabled', FRippleEnabled
|
||||
'ripple_enabled', FRippleEnabled,
|
||||
'region_start', FRegionStart,
|
||||
'region_end', FRegionEnd
|
||||
]));
|
||||
|
||||
Result := Root.FormatJSON;
|
||||
|
|
@ -963,7 +1702,7 @@ var
|
|||
Obj, ItemObj, TrackObj, ClipObj, Settings: TJSONObject;
|
||||
Arr, ClipsArr: TJSONArray;
|
||||
I, J: Integer;
|
||||
Item: TMediaItem;
|
||||
Item, Media: TMediaItem;
|
||||
Track: TTrack;
|
||||
Clip: TClip;
|
||||
begin
|
||||
|
|
@ -1015,6 +1754,7 @@ begin
|
|||
Track := TTrack.Create;
|
||||
Track.Id := TrackObj.Get('id', '');
|
||||
Track.Name := TrackObj.Get('name', '');
|
||||
Track.Kind := StrToTrackKind(TrackObj.Get('kind', 'video'));
|
||||
Track.Muted := TrackObj.Get('muted', False);
|
||||
Track.Solo := TrackObj.Get('solo', False);
|
||||
FTracks.Add(Track);
|
||||
|
|
@ -1036,16 +1776,42 @@ begin
|
|||
Clip.EdgeOut := StrToEdge(ClipObj.Get('edge_out', 'snap'));
|
||||
Clip.Kind := StrToKind(ClipObj.Get('kind', 'video'));
|
||||
Clip.Volume := ClipObj.Get('volume', Double(1));
|
||||
Clip.GroupId := ClipObj.Get('group', '');
|
||||
Clip.Opacity := ClipObj.Get('opacity', Double(1));
|
||||
Clip.FadeIn := ClipObj.Get('fade_in', Double(0));
|
||||
Clip.FadeOut := ClipObj.Get('fade_out', Double(0));
|
||||
{ Проекты, записанные до появления кадрирования у клипа,
|
||||
берут его у файла — там оно и хранилось раньше. }
|
||||
if ClipObj.Find('transform') <> nil then
|
||||
Clip.Transform := JsonToTransform(
|
||||
ClipObj.Get('transform', TJSONObject(nil)))
|
||||
else
|
||||
begin
|
||||
Media := FindMedia(Clip.MediaId);
|
||||
if Media <> nil then
|
||||
Clip.Transform := Media.Transform;
|
||||
end;
|
||||
Clip.ColorKey := JsonToColorKey(
|
||||
ClipObj.Get('color_key', TJSONObject(nil)));
|
||||
Clip.Text := ClipObj.Get('text', '');
|
||||
Clip.TextStyle := JsonToTextStyle(
|
||||
ClipObj.Get('text_style', TJSONObject(nil)));
|
||||
Track.Add(Clip);
|
||||
end;
|
||||
Track.Sort;
|
||||
end;
|
||||
|
||||
MigrateAudio;
|
||||
FixClipKinds;
|
||||
EnsureUniqueTrackNames;
|
||||
|
||||
Settings := Obj.Find('settings', jtObject) as TJSONObject;
|
||||
if Settings <> nil then
|
||||
begin
|
||||
FRippleMode := StrToRippleMode(Settings.Get('ripple_mode', 'current'));
|
||||
FRippleEnabled := Settings.Get('ripple_enabled', False);
|
||||
FRegionStart := Settings.Get('region_start', Double(0));
|
||||
FRegionEnd := Settings.Get('region_end', Double(0));
|
||||
{ Старые проекты хранили один флаг «все дорожки». }
|
||||
if Settings.Get('ripple_all_tracks', False) then
|
||||
FRippleMode := rmAllTracks;
|
||||
|
|
|
|||
|
|
@ -33,7 +33,14 @@ type
|
|||
FRippleAllTracks: Boolean;
|
||||
FRippleEnabled: Boolean;
|
||||
FTrackHeight: Integer;
|
||||
FWaveGain: Double;
|
||||
FVideoPanelHeight: Integer;
|
||||
FLogVisible: Boolean;
|
||||
FPlayheadSnap: Boolean;
|
||||
FPreviewKey: Boolean;
|
||||
FCenterPlayhead: Boolean;
|
||||
FKeepParts: Boolean;
|
||||
FDepsReminder: Boolean;
|
||||
FRecentProjects: TStringList;
|
||||
function FileName: string;
|
||||
public
|
||||
|
|
@ -58,7 +65,25 @@ type
|
|||
property RippleAllTracks: Boolean read FRippleAllTracks write FRippleAllTracks;
|
||||
property RippleEnabled: Boolean read FRippleEnabled write FRippleEnabled;
|
||||
property TrackHeight: Integer read FTrackHeight write FTrackHeight;
|
||||
{ Во сколько раз растянута форма звука на клипах. Только рисунок. }
|
||||
property WaveGain: Double read FWaveGain write FWaveGain;
|
||||
property VideoPanelHeight: Integer read FVideoPanelHeight write FVideoPanelHeight;
|
||||
{ Видна ли нижняя полоса с журналом. По умолчанию видна: пока
|
||||
программа молодая, её сообщения нужнее, чем лишние строки монтажа. }
|
||||
property LogVisible: Boolean read FLogVisible write FLogVisible;
|
||||
{ Прилипание курсора воспроизведения к границам клипов. }
|
||||
property PlayheadSnap: Boolean read FPlayheadSnap write FPlayheadSnap;
|
||||
{ Применять ли ключ и прозрачность прямо в предпросмотре. }
|
||||
property PreviewKey: Boolean read FPreviewKey write FPreviewKey;
|
||||
{ Держать ли курсор посередине монтажа при воспроизведении. }
|
||||
property CenterPlayhead: Boolean read FCenterPlayhead write FCenterPlayhead;
|
||||
{ Оставлять ли временные куски после удачной сборки. По умолчанию нет:
|
||||
они весят столько же, сколько готовый ролик, а нужны только чтобы
|
||||
продолжить прерванную сборку. }
|
||||
property KeepParts: Boolean read FKeepParts write FKeepParts;
|
||||
{ Напоминать ли о недостающих зависимостях при запуске. Гасится
|
||||
кнопкой «Не напоминать» в самом напоминании. }
|
||||
property DepsReminder: Boolean read FDepsReminder write FDepsReminder;
|
||||
end;
|
||||
|
||||
implementation
|
||||
|
|
@ -70,6 +95,7 @@ const
|
|||
DefaultWidth = 1600;
|
||||
DefaultHeight = 900;
|
||||
DefaultTrackHeight = 64;
|
||||
DefaultWaveGain = 1.0;
|
||||
DefaultVideoHeight = 400;
|
||||
|
||||
{ Больше десятка в меню не помещается по-человечески, да и не нужно. }
|
||||
|
|
@ -84,7 +110,14 @@ begin
|
|||
FWindowWidth := DefaultWidth;
|
||||
FWindowHeight := DefaultHeight;
|
||||
FTrackHeight := DefaultTrackHeight;
|
||||
FWaveGain := DefaultWaveGain;
|
||||
FVideoPanelHeight := DefaultVideoHeight;
|
||||
FLogVisible := True;
|
||||
FPlayheadSnap := True;
|
||||
FPreviewKey := True;
|
||||
FCenterPlayhead := True;
|
||||
FKeepParts := False;
|
||||
FDepsReminder := True;
|
||||
end;
|
||||
|
||||
destructor TSettings.Destroy;
|
||||
|
|
@ -161,7 +194,14 @@ begin
|
|||
FRippleAllTracks := Obj.Get('ripple_all_tracks', FRippleAllTracks);
|
||||
FRippleEnabled := Obj.Get('ripple_enabled', FRippleEnabled);
|
||||
FTrackHeight := Obj.Get('track_height', FTrackHeight);
|
||||
FWaveGain := Obj.Get('wave_gain', FWaveGain);
|
||||
FVideoPanelHeight := Obj.Get('video_panel_height', FVideoPanelHeight);
|
||||
FLogVisible := Obj.Get('log_visible', FLogVisible);
|
||||
FPlayheadSnap := Obj.Get('playhead_snap', FPlayheadSnap);
|
||||
FPreviewKey := Obj.Get('preview_key', FPreviewKey);
|
||||
FCenterPlayhead := Obj.Get('center_playhead', FCenterPlayhead);
|
||||
FKeepParts := Obj.Get('keep_parts', FKeepParts);
|
||||
FDepsReminder := Obj.Get('deps_reminder', FDepsReminder);
|
||||
|
||||
Arr := Obj.Find('recent_projects', jtArray) as TJSONArray;
|
||||
FRecentProjects.Clear;
|
||||
|
|
@ -181,6 +221,8 @@ begin
|
|||
FWindowWidth := DefaultWidth;
|
||||
if FWindowHeight < 480 then
|
||||
FWindowHeight := DefaultHeight;
|
||||
if (FWaveGain < 1) or (FWaveGain > 64) then
|
||||
FWaveGain := DefaultWaveGain;
|
||||
if (FTrackHeight < 24) or (FTrackHeight > 300) then
|
||||
FTrackHeight := DefaultTrackHeight;
|
||||
if FVideoPanelHeight < 100 then
|
||||
|
|
@ -207,7 +249,14 @@ begin
|
|||
Root.Add('ripple_all_tracks', FRippleAllTracks);
|
||||
Root.Add('ripple_enabled', FRippleEnabled);
|
||||
Root.Add('track_height', FTrackHeight);
|
||||
Root.Add('wave_gain', FWaveGain);
|
||||
Root.Add('video_panel_height', FVideoPanelHeight);
|
||||
Root.Add('log_visible', FLogVisible);
|
||||
Root.Add('playhead_snap', FPlayheadSnap);
|
||||
Root.Add('preview_key', FPreviewKey);
|
||||
Root.Add('center_playhead', FCenterPlayhead);
|
||||
Root.Add('keep_parts', FKeepParts);
|
||||
Root.Add('deps_reminder', FDepsReminder);
|
||||
|
||||
Arr := TJSONArray.Create;
|
||||
for I := 0 to FRecentProjects.Count - 1 do
|
||||
|
|
|
|||
538
src/uSubtitles.pas
Normal file
|
|
@ -0,0 +1,538 @@
|
|||
{
|
||||
uSubtitles — текст поверх картинки.
|
||||
|
||||
Рисует не эта программа, а libass: он есть и внутри mpv, и внутри
|
||||
ffmpeg, поэтому предпросмотр и готовый ролик показывают одно и то же.
|
||||
Наше дело — собрать для него файл ASS.
|
||||
|
||||
Почему переносы считаем сами. libass умеет переносить, но тогда
|
||||
неизвестно, сколько выйдет строк, и подобрать размер под отведённую
|
||||
полосу нечем. Поэтому меряем и переносим здесь, а в файл отдаём готовые
|
||||
переводы строк: что померили, то и нарисуется.
|
||||
|
||||
Чем меряем. Тем же шрифтом, что уйдёт в libass, через холст LCL — за
|
||||
обоими стоит FreeType, и ширины сходятся. Небольшой запас всё равно
|
||||
берём: настройки сглаживания у них разные.
|
||||
|
||||
Замер, на котором держится подбор размера (DejaVu Sans, кадр 1280x720):
|
||||
шаг строк почти равен размеру шрифта, высота блока из N строк ≈
|
||||
размер × (N − 0.16). В полосу 20% высоты влезает три строки размером до
|
||||
50, четыре до 37, пять до 30.
|
||||
}
|
||||
unit uSubtitles;
|
||||
|
||||
{$mode objfpc}{$H+}
|
||||
|
||||
interface
|
||||
|
||||
uses
|
||||
Classes, SysUtils, uAppTypes, uProject, uFlatten;
|
||||
|
||||
type
|
||||
{ Как померить ширину строки. Возвращает ширину в точках кадра. }
|
||||
TTextMeasure = function(const AText: string; const AStyle: TTextStyle;
|
||||
ASize: Integer): Integer;
|
||||
|
||||
var
|
||||
{ Замерщик ширины текста.
|
||||
|
||||
Подключается снаружи: точный замер требует холста, а холст —
|
||||
работающего оконного набора. Служебной программе без экрана он
|
||||
недоступен, и она обходится оценкой (см. EstimateWidth) — для
|
||||
проверки самого файла ASS этого хватает.
|
||||
|
||||
Окно ставит сюда замер тем же шрифтом, каким libass будет рисовать. }
|
||||
TextMeasure: TTextMeasure = nil;
|
||||
|
||||
{ Собирает ASS из всех текстовых клипов проекта.
|
||||
|
||||
Возвращает пустую строку, если текста нет вовсе — тогда и файл писать
|
||||
незачем. }
|
||||
function BuildAss(AProject: TProject): string;
|
||||
|
||||
{ Пишет ASS в файл. False — писать было нечего или не вышло. }
|
||||
function WriteAssFile(AProject: TProject; const APath: string): Boolean;
|
||||
|
||||
{ Есть ли в проекте хоть один текстовый клип. }
|
||||
function HasText(AProject: TProject): Boolean;
|
||||
|
||||
{ Подбор размера и переносов — для окна редактора: оно показывает, во что
|
||||
превратится надпись, ещё до сборки разметки. Тот же код, что и в самой
|
||||
сборке, поэтому расхождения быть не может. }
|
||||
function FitTextForPreview(const AText: string; const AStyle: TTextStyle;
|
||||
AFrameWidth, AFrameHeight: Integer; ALines: TStringList): Integer;
|
||||
|
||||
{ Занят ли текстом этот отрезок монтажа. Нужно сборке: вжигать текст
|
||||
приходится только в те куски, которые он покрывает. }
|
||||
function TextCovers(AProject: TProject; const AFrom, ATo: Double): Boolean;
|
||||
|
||||
{ Разметка для одного куска монтажа: события сдвинуты к его началу и
|
||||
обрезаны по его длине.
|
||||
|
||||
Нужно вжиганию. Фильтр subtitles рисует по времени того файла, который
|
||||
ему дали, а кусок начинается со своего нуля — общая разметка легла бы
|
||||
не туда. }
|
||||
function WriteAssForRange(AProject: TProject; const AFrom, ATo: Double;
|
||||
const APath: string): Boolean;
|
||||
|
||||
{ Готовит куски к вжиганию: тем, которые задевает текст, ставит признак
|
||||
пересборки и подкладывает свою разметку.
|
||||
|
||||
Возвращает, сколько секунд придётся пересобрать сверх того, что и так
|
||||
пересобиралось: человеку это говорят до начала.
|
||||
|
||||
AStartTime — время монтажа, которому соответствует начало списка.
|
||||
Нужен, когда собирают не весь ролик, а отмеченный участок: иначе текст
|
||||
брался бы с начала монтажа и лёг бы не на те куски. }
|
||||
function BurnTextIntoSegments(AProject: TProject;
|
||||
var ASegments: TFlatSegmentArray; const ACacheDir: string;
|
||||
const AStartTime: Double = 0): Double;
|
||||
|
||||
implementation
|
||||
|
||||
uses
|
||||
Math, LazUTF8;
|
||||
|
||||
const
|
||||
{ Запас по ширине: наши мерки и мерки libass расходятся на проценты. }
|
||||
WidthSafety = 0.95;
|
||||
{ Поля от краёв кадра в долях ширины и высоты. }
|
||||
MarginShare = 0.05;
|
||||
{ Ниже этого размер не опускаем: дальше текст всё равно не прочесть,
|
||||
и лучше показать его крупным с выходом за полосу, чем нечитаемым. }
|
||||
MinFontSize = 14;
|
||||
|
||||
{ Время в записи ASS: часы:минуты:секунды.сотые. }
|
||||
function AssTime(const ASeconds: Double): string;
|
||||
var
|
||||
T: Double;
|
||||
H, M: Integer;
|
||||
S: Double;
|
||||
begin
|
||||
T := Max(0, ASeconds);
|
||||
H := Trunc(T) div 3600;
|
||||
M := (Trunc(T) mod 3600) div 60;
|
||||
S := T - H * 3600 - M * 60;
|
||||
{ Дробную часть добиваем нулём вручную: %05.2f у Паскаля дополняет
|
||||
пробелом, а ASS с пробелом внутри времени не читается. }
|
||||
Result := Format('%d:%.2d:%.2d.%.2d',
|
||||
[H, M, Trunc(S), Round(Frac(S) * 100)]);
|
||||
end;
|
||||
|
||||
{ Цвет в записи ASS: &HAABBGGRR. Порядок обратный привычному, а первым
|
||||
идёт не прозрачность, а её отсутствие — ноль значит «непрозрачно». }
|
||||
function AssColor(AColor: TRgb; ATransparent: Boolean = False): string;
|
||||
var
|
||||
Alpha: Integer;
|
||||
begin
|
||||
if ATransparent then
|
||||
Alpha := $FF
|
||||
else
|
||||
Alpha := 0;
|
||||
Result := Format('&H%.2X%.2X%.2X%.2X',
|
||||
[Alpha, (AColor shr 16) and $FF, (AColor shr 8) and $FF, AColor and $FF]);
|
||||
end;
|
||||
|
||||
{ Внутри строки ASS перевод строки записывается как \N, а фигурные скобки
|
||||
начинают команду — их надо обезвредить. }
|
||||
function AssEscape(const AText: string): string;
|
||||
begin
|
||||
Result := StringReplace(AText, '\', '\\', [rfReplaceAll]);
|
||||
Result := StringReplace(Result, '{', '(', [rfReplaceAll]);
|
||||
Result := StringReplace(Result, '}', ')', [rfReplaceAll]);
|
||||
Result := StringReplace(Result, LineEnding, '\N', [rfReplaceAll]);
|
||||
Result := StringReplace(Result, #10, '\N', [rfReplaceAll]);
|
||||
Result := StringReplace(Result, #13, '\N', [rfReplaceAll]);
|
||||
end;
|
||||
|
||||
{ Оценка ширины, когда точного замера нет.
|
||||
|
||||
Множитель взят из замера: DejaVu Sans, размер 54, строка из двадцати
|
||||
знаков кириллицы заняла 530 точек — это 0.49 размера на знак. Оценка
|
||||
грубая, но для проверки самой разметки годится, а окно всегда ставит
|
||||
точный замер. }
|
||||
function EstimateWidth(const AText: string; ASize: Integer): Integer;
|
||||
begin
|
||||
Result := Round(UTF8Length(AText) * ASize * 0.49);
|
||||
end;
|
||||
|
||||
function MeasureWidth(const AText: string; const AStyle: TTextStyle;
|
||||
ASize: Integer): Integer;
|
||||
begin
|
||||
if Assigned(TextMeasure) then
|
||||
Result := TextMeasure(AText, AStyle, ASize)
|
||||
else
|
||||
Result := EstimateWidth(AText, ASize);
|
||||
end;
|
||||
|
||||
{ Перенос по словам под заданную ширину. Возвращает число строк, сами
|
||||
строки складывает в ALines. }
|
||||
function WrapText(const AText: string; const AStyle: TTextStyle;
|
||||
ASize, AMaxWidth: Integer; ALines: TStringList): Integer;
|
||||
var
|
||||
Src: TStringList;
|
||||
I: Integer;
|
||||
Rest, Word, Line: string;
|
||||
Space: Integer;
|
||||
begin
|
||||
ALines.Clear;
|
||||
Src := TStringList.Create;
|
||||
try
|
||||
{ Свои переводы строк человек ставил не зря — они остаются. }
|
||||
Src.Text := AText;
|
||||
for I := 0 to Src.Count - 1 do
|
||||
begin
|
||||
Rest := Src[I];
|
||||
if Trim(Rest) = '' then
|
||||
begin
|
||||
ALines.Add('');
|
||||
Continue;
|
||||
end;
|
||||
|
||||
Line := '';
|
||||
while Rest <> '' do
|
||||
begin
|
||||
Space := Pos(' ', Rest);
|
||||
if Space = 0 then
|
||||
begin
|
||||
Word := Rest;
|
||||
Rest := '';
|
||||
end
|
||||
else
|
||||
begin
|
||||
Word := Copy(Rest, 1, Space - 1);
|
||||
Rest := Copy(Rest, Space + 1, Length(Rest));
|
||||
end;
|
||||
|
||||
if Line = '' then
|
||||
Line := Word
|
||||
else if MeasureWidth(Line + ' ' + Word, AStyle, ASize) <= AMaxWidth then
|
||||
Line := Line + ' ' + Word
|
||||
else
|
||||
begin
|
||||
ALines.Add(Line);
|
||||
Line := Word;
|
||||
end;
|
||||
end;
|
||||
if Line <> '' then
|
||||
ALines.Add(Line);
|
||||
end;
|
||||
finally
|
||||
Src.Free;
|
||||
end;
|
||||
Result := ALines.Count;
|
||||
end;
|
||||
|
||||
{ Подбор размера и переносов под отведённую полосу.
|
||||
|
||||
Идём от заданного размера вниз: он же и верхняя граница. Уменьшаем,
|
||||
пока блок не влезет по высоте — по ширине перенос справляется сам. }
|
||||
function FitText(const AText: string; const AStyle: TTextStyle;
|
||||
AFrameWidth, AFrameHeight: Integer; ALines: TStringList): Integer;
|
||||
var
|
||||
MaxWidth, MaxHeight, Size, BlockHeight: Integer;
|
||||
Lo, Hi, Mid, Best, Count: Integer;
|
||||
begin
|
||||
MaxWidth := Round(AFrameWidth * (1 - MarginShare * 2) * WidthSafety);
|
||||
MaxHeight := Round(AFrameHeight * AStyle.MaxHeight);
|
||||
Size := AStyle.FontSize;
|
||||
if Size < MinFontSize then
|
||||
Size := MinFontSize;
|
||||
|
||||
{ Ищем наибольший размер, при котором блок влезает, делением отрезка
|
||||
пополам.
|
||||
|
||||
Прикидка «уменьшить во столько же раз, во сколько не влезли» даёт
|
||||
слишком мелкий шрифт: после переноса строк становится меньше, и
|
||||
места остаётся вдоволь. Проверено на длинной надписи — вместо 29
|
||||
помещался 40. Десятка кругов хватает на любой разумный размер. }
|
||||
Lo := MinFontSize;
|
||||
Hi := Size;
|
||||
Best := MinFontSize;
|
||||
|
||||
while Lo <= Hi do
|
||||
begin
|
||||
Mid := (Lo + Hi) div 2;
|
||||
Count := WrapText(AText, AStyle, Mid, MaxWidth, ALines);
|
||||
if Count < 1 then
|
||||
Count := 1;
|
||||
{ Высота блока по замеру: шаг строк равен размеру, последняя строка
|
||||
занимает чуть меньше. }
|
||||
BlockHeight := Round(Mid * (Count - 0.16));
|
||||
if BlockHeight <= MaxHeight then
|
||||
begin
|
||||
Best := Mid;
|
||||
Lo := Mid + 1;
|
||||
end
|
||||
else
|
||||
Hi := Mid - 1;
|
||||
end;
|
||||
|
||||
{ Последний перенос должен соответствовать выбранному размеру: во время
|
||||
поиска он мог остаться от неподошедшей примерки. }
|
||||
WrapText(AText, AStyle, Best, MaxWidth, ALines);
|
||||
Result := Best;
|
||||
end;
|
||||
|
||||
function FitTextForPreview(const AText: string; const AStyle: TTextStyle;
|
||||
AFrameWidth, AFrameHeight: Integer; ALines: TStringList): Integer;
|
||||
begin
|
||||
Result := FitText(AText, AStyle, AFrameWidth, AFrameHeight, ALines);
|
||||
end;
|
||||
|
||||
function HasText(AProject: TProject): Boolean;
|
||||
var
|
||||
T, C: Integer;
|
||||
begin
|
||||
Result := False;
|
||||
if AProject = nil then
|
||||
Exit;
|
||||
for T := 0 to AProject.TrackCount - 1 do
|
||||
begin
|
||||
if AProject.Tracks[T].Kind <> tkText then
|
||||
Continue;
|
||||
if not AProject.TrackAudible(AProject.Tracks[T]) then
|
||||
Continue;
|
||||
for C := 0 to AProject.Tracks[T].Count - 1 do
|
||||
if Trim(AProject.Tracks[T][C].Text) <> '' then
|
||||
Exit(True);
|
||||
end;
|
||||
end;
|
||||
|
||||
function TextCovers(AProject: TProject; const AFrom, ATo: Double): Boolean;
|
||||
var
|
||||
T, C: Integer;
|
||||
Clip: TClip;
|
||||
begin
|
||||
Result := False;
|
||||
if AProject = nil then
|
||||
Exit;
|
||||
for T := 0 to AProject.TrackCount - 1 do
|
||||
begin
|
||||
if AProject.Tracks[T].Kind <> tkText then
|
||||
Continue;
|
||||
if not AProject.TrackAudible(AProject.Tracks[T]) then
|
||||
Continue;
|
||||
for C := 0 to AProject.Tracks[T].Count - 1 do
|
||||
begin
|
||||
Clip := AProject.Tracks[T][C];
|
||||
if Trim(Clip.Text) = '' then
|
||||
Continue;
|
||||
{ Пересечение отрезков, а не попадание точки: кусок может быть
|
||||
длиннее надписи и наоборот. }
|
||||
if (Clip.TlStart < ATo - 0.0005) and (Clip.TlEnd > AFrom + 0.0005) then
|
||||
Exit(True);
|
||||
end;
|
||||
end;
|
||||
end;
|
||||
|
||||
{ Общая сборка. AFrom и ATo ограничивают выборку; ATo меньше нуля значит
|
||||
«до конца монтажа». При ненулевом AFrom времена сдвигаются к нулю — так
|
||||
разметка годится для отдельного куска. }
|
||||
function BuildAssRange(AProject: TProject; const AFrom, ATo: Double): string;
|
||||
var
|
||||
Lines: TStringList;
|
||||
Styles, Events: TStringList;
|
||||
T, C, I, Size: Integer;
|
||||
Clip: TClip;
|
||||
Track: TTrack;
|
||||
Name, Body: string;
|
||||
W, H, MarginH, MarginV: Integer;
|
||||
StartAt, EndAt: Double;
|
||||
begin
|
||||
Result := '';
|
||||
if (AProject = nil) or (not HasText(AProject)) then
|
||||
Exit;
|
||||
|
||||
W := AProject.Master.Width;
|
||||
H := AProject.Master.Height;
|
||||
if (W < 2) or (H < 2) then
|
||||
begin
|
||||
W := 1920;
|
||||
H := 1080;
|
||||
end;
|
||||
MarginH := Round(W * MarginShare);
|
||||
MarginV := Round(H * MarginShare);
|
||||
|
||||
Lines := TStringList.Create;
|
||||
Styles := TStringList.Create;
|
||||
Events := TStringList.Create;
|
||||
try
|
||||
for T := 0 to AProject.TrackCount - 1 do
|
||||
begin
|
||||
Track := AProject.Tracks[T];
|
||||
if Track.Kind <> tkText then
|
||||
Continue;
|
||||
if not AProject.TrackAudible(Track) then
|
||||
Continue;
|
||||
|
||||
for C := 0 to Track.Count - 1 do
|
||||
begin
|
||||
Clip := Track[C];
|
||||
if Trim(Clip.Text) = '' then
|
||||
Continue;
|
||||
{ Надписи, не попадающие в нужный отрезок, разбирать незачем. }
|
||||
if (ATo >= 0) and (Clip.TlStart >= ATo - 0.0005) then
|
||||
Continue;
|
||||
if Clip.TlEnd <= AFrom + 0.0005 then
|
||||
Continue;
|
||||
|
||||
Size := FitText(Clip.Text, Clip.TextStyle, W, H, Lines);
|
||||
|
||||
{ У каждого клипа своё оформление, поэтому и свой стиль. Имя
|
||||
выводим из номера дорожки и клипа — оно должно быть
|
||||
неповторимым и не содержать запятых. }
|
||||
Name := Format('s%d_%d', [T, C]);
|
||||
Styles.Add(Format(
|
||||
'Style: %s,%s,%d,%s,%s,%s,%s,%d,%d,0,0,100,100,0,0,1,%d,%d,%d,' +
|
||||
'%d,%d,%d,1',
|
||||
[Name, Clip.TextStyle.FontName, Size,
|
||||
AssColor(Clip.TextStyle.Color),
|
||||
AssColor(Clip.TextStyle.Color),
|
||||
AssColor(Clip.TextStyle.OutlineColor),
|
||||
AssColor(Clip.TextStyle.ShadowColor,
|
||||
Clip.TextStyle.Shadow <= 0),
|
||||
Ord(Clip.TextStyle.Bold) * -1, Ord(Clip.TextStyle.Italic) * -1,
|
||||
Clip.TextStyle.Outline, Clip.TextStyle.Shadow,
|
||||
Ord(Clip.TextStyle.Place),
|
||||
MarginH, MarginH, MarginV]));
|
||||
|
||||
Body := '';
|
||||
for I := 0 to Lines.Count - 1 do
|
||||
begin
|
||||
if Body <> '' then
|
||||
Body := Body + '\N';
|
||||
Body := Body + AssEscape(Lines[I]);
|
||||
end;
|
||||
|
||||
{ Обрезаем по границам куска и сдвигаем к его началу. }
|
||||
StartAt := Max(Clip.TlStart, AFrom) - AFrom;
|
||||
EndAt := Clip.TlEnd - AFrom;
|
||||
if ATo >= 0 then
|
||||
EndAt := Min(EndAt, ATo - AFrom);
|
||||
if EndAt <= StartAt then
|
||||
Continue;
|
||||
|
||||
Events.Add(Format('Dialogue: 0,%s,%s,%s,,0,0,0,,%s',
|
||||
[AssTime(StartAt), AssTime(EndAt), Name, Body]));
|
||||
end;
|
||||
end;
|
||||
|
||||
if Events.Count = 0 then
|
||||
Exit;
|
||||
|
||||
Result :=
|
||||
'[Script Info]' + LineEnding +
|
||||
'ScriptType: v4.00+' + LineEnding +
|
||||
Format('PlayResX: %d', [W]) + LineEnding +
|
||||
Format('PlayResY: %d', [H]) + LineEnding +
|
||||
{ Переносы мы уже расставили, libass переносить нечего. }
|
||||
'WrapStyle: 2' + LineEnding +
|
||||
'ScaledBorderAndShadow: yes' + LineEnding +
|
||||
'YCbCr Matrix: None' + LineEnding +
|
||||
LineEnding +
|
||||
'[V4+ Styles]' + LineEnding +
|
||||
'Format: Name,Fontname,Fontsize,PrimaryColour,SecondaryColour,' +
|
||||
'OutlineColour,BackColour,Bold,Italic,Underline,StrikeOut,ScaleX,' +
|
||||
'ScaleY,Spacing,Angle,BorderStyle,Outline,Shadow,Alignment,' +
|
||||
'MarginL,MarginR,MarginV,Encoding' + LineEnding +
|
||||
Styles.Text +
|
||||
LineEnding +
|
||||
'[Events]' + LineEnding +
|
||||
'Format: Layer,Start,End,Style,Name,MarginL,MarginR,MarginV,' +
|
||||
'Effect,Text' + LineEnding +
|
||||
Events.Text;
|
||||
finally
|
||||
Events.Free;
|
||||
Styles.Free;
|
||||
Lines.Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
function BuildAss(AProject: TProject): string;
|
||||
begin
|
||||
Result := BuildAssRange(AProject, 0, -1);
|
||||
end;
|
||||
|
||||
function WriteText(const AText, APath: string): Boolean;
|
||||
var
|
||||
Stream: TFileStream;
|
||||
begin
|
||||
Result := False;
|
||||
try
|
||||
ForceDirectories(ExtractFilePath(APath));
|
||||
Stream := TFileStream.Create(APath, fmCreate);
|
||||
try
|
||||
if Length(AText) > 0 then
|
||||
Stream.WriteBuffer(AText[1], Length(AText));
|
||||
finally
|
||||
Stream.Free;
|
||||
end;
|
||||
Result := True;
|
||||
except
|
||||
Result := False;
|
||||
end;
|
||||
end;
|
||||
|
||||
function WriteAssForRange(AProject: TProject; const AFrom, ATo: Double;
|
||||
const APath: string): Boolean;
|
||||
var
|
||||
Text: string;
|
||||
begin
|
||||
Text := BuildAssRange(AProject, AFrom, ATo);
|
||||
Result := (Text <> '') and WriteText(Text, APath);
|
||||
end;
|
||||
|
||||
function BurnTextIntoSegments(AProject: TProject;
|
||||
var ASegments: TFlatSegmentArray; const ACacheDir: string;
|
||||
const AStartTime: Double): Double;
|
||||
var
|
||||
I: Integer;
|
||||
At, SegEnd: Double;
|
||||
Path: string;
|
||||
begin
|
||||
Result := 0;
|
||||
if (AProject = nil) or (not HasText(AProject)) then
|
||||
Exit;
|
||||
|
||||
At := AStartTime;
|
||||
for I := 0 to High(ASegments) do
|
||||
begin
|
||||
SegEnd := At + ASegments[I].Duration;
|
||||
if TextCovers(AProject, At, SegEnd) then
|
||||
begin
|
||||
Path := IncludeTrailingPathDelimiter(ACacheDir) +
|
||||
Format('burn_%.3d.ass', [I]);
|
||||
if WriteAssForRange(AProject, At, SegEnd, Path) then
|
||||
begin
|
||||
{ Кусок, который и так пересобирался, дороже не становится —
|
||||
считаем только те, что раньше копировались. }
|
||||
if not (ASegments[I].Transcode or (Length(ASegments[I].Layers) > 0)) then
|
||||
Result := Result + ASegments[I].Duration;
|
||||
ASegments[I].SubFile := Path;
|
||||
ASegments[I].Transcode := True;
|
||||
end;
|
||||
end;
|
||||
At := SegEnd;
|
||||
end;
|
||||
end;
|
||||
|
||||
function WriteAssFile(AProject: TProject; const APath: string): Boolean;
|
||||
var
|
||||
Text: string;
|
||||
begin
|
||||
Result := False;
|
||||
Text := BuildAss(AProject);
|
||||
if Text = '' then
|
||||
begin
|
||||
{ Текста не стало — старый файл убираем, иначе плеер покажет то,
|
||||
чего на монтаже уже нет. }
|
||||
if FileExists(APath) then
|
||||
SysUtils.DeleteFile(APath);
|
||||
Exit;
|
||||
end;
|
||||
|
||||
Result := WriteText(Text, APath);
|
||||
end;
|
||||
|
||||
end.
|
||||
372
src/uTextForm.pas
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
{
|
||||
uTextForm — окно текстового клипа.
|
||||
|
||||
Всё, что можно задать надписи: сам текст, шрифт, размер, три цвета,
|
||||
толщина обводки, тень, место в кадре и доля высоты, которую тексту
|
||||
позволено занять.
|
||||
|
||||
Место выбирается сеткой три на три — это девять положений ASS, ровно
|
||||
тех, что понимает libass. Кнопки сетки хранят номер положения прямо в
|
||||
Tag: перекладывать его в отдельный список незачем.
|
||||
|
||||
Размер шрифта здесь — верхняя граница, а не приказ. Если текст не
|
||||
влезает в отведённую полосу, при сборке разметки он уменьшится; здесь
|
||||
же рядом показано, до чего именно.
|
||||
}
|
||||
unit uTextForm;
|
||||
|
||||
{$mode objfpc}{$H+}
|
||||
|
||||
interface
|
||||
|
||||
uses
|
||||
Classes, SysUtils, Controls, Forms, Graphics, StdCtrls, Dialogs, ExtCtrls,
|
||||
uAppTypes, uTheme, uProject;
|
||||
|
||||
{ Показывает окно. True — человек нажал «Готово», и в AText с AStyle лежит
|
||||
изменённое. }
|
||||
function EditTextClip(var AText: string; var AStyle: TTextStyle;
|
||||
AFrameWidth, AFrameHeight: Integer): Boolean;
|
||||
|
||||
implementation
|
||||
|
||||
uses
|
||||
uSubtitles;
|
||||
|
||||
type
|
||||
TTextDialog = class(TComponent)
|
||||
public
|
||||
Form: TForm;
|
||||
Memo: TMemo;
|
||||
cbFont: TComboBox;
|
||||
edSize: TEdit;
|
||||
edOutline: TEdit;
|
||||
edShadow: TEdit;
|
||||
edHeight: TEdit;
|
||||
cbBold: TCheckBox;
|
||||
cbItalic: TCheckBox;
|
||||
BtnColor: TThemedButton;
|
||||
BtnOutlineColor: TThemedButton;
|
||||
BtnShadowColor: TThemedButton;
|
||||
Places: array[1..9] of TThemedButton;
|
||||
lblFit: TLabel;
|
||||
Style: TTextStyle;
|
||||
FrameW: Integer;
|
||||
FrameH: Integer;
|
||||
procedure PlaceClick(Sender: TObject);
|
||||
procedure ColorClick(Sender: TObject);
|
||||
procedure AnyChange(Sender: TObject);
|
||||
procedure ReadControls;
|
||||
procedure UpdateFit;
|
||||
end;
|
||||
|
||||
{ Кнопка цвета показывает сам цвет: подпись «выбрать» ничего не сообщает,
|
||||
а образец нужного цвета сообщает всё. }
|
||||
procedure PaintColorButton(ABtn: TThemedButton; AColor: TRgb);
|
||||
begin
|
||||
ABtn.Swatch := TColor(AColor);
|
||||
end;
|
||||
|
||||
procedure TTextDialog.PlaceClick(Sender: TObject);
|
||||
var
|
||||
I: Integer;
|
||||
begin
|
||||
Style.Place := TTextPlace((Sender as TThemedButton).Tag);
|
||||
for I := 1 to 9 do
|
||||
Places[I].Down := Places[I].Tag = Ord(Style.Place);
|
||||
UpdateFit;
|
||||
end;
|
||||
|
||||
procedure TTextDialog.ColorClick(Sender: TObject);
|
||||
var
|
||||
Dlg: TColorDialog;
|
||||
Btn: TThemedButton;
|
||||
begin
|
||||
Btn := Sender as TThemedButton;
|
||||
Dlg := TColorDialog.Create(nil);
|
||||
try
|
||||
if Btn = BtnColor then
|
||||
Dlg.Color := TColor(Style.Color)
|
||||
else if Btn = BtnOutlineColor then
|
||||
Dlg.Color := TColor(Style.OutlineColor)
|
||||
else
|
||||
Dlg.Color := TColor(Style.ShadowColor);
|
||||
|
||||
if not Dlg.Execute then
|
||||
Exit;
|
||||
|
||||
if Btn = BtnColor then
|
||||
Style.Color := TRgb(Dlg.Color)
|
||||
else if Btn = BtnOutlineColor then
|
||||
Style.OutlineColor := TRgb(Dlg.Color)
|
||||
else
|
||||
Style.ShadowColor := TRgb(Dlg.Color);
|
||||
PaintColorButton(Btn, TRgb(Dlg.Color));
|
||||
finally
|
||||
Dlg.Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
procedure TTextDialog.ReadControls;
|
||||
var
|
||||
V: Integer;
|
||||
D: Double;
|
||||
begin
|
||||
if cbFont.ItemIndex >= 0 then
|
||||
Style.FontName := cbFont.Items[cbFont.ItemIndex];
|
||||
|
||||
V := StrToIntDef(Trim(edSize.Text), Style.FontSize);
|
||||
if (V >= 8) and (V <= 400) then
|
||||
Style.FontSize := V;
|
||||
|
||||
V := StrToIntDef(Trim(edOutline.Text), Style.Outline);
|
||||
if (V >= 0) and (V <= 20) then
|
||||
Style.Outline := V;
|
||||
|
||||
V := StrToIntDef(Trim(edShadow.Text), Style.Shadow);
|
||||
if (V >= 0) and (V <= 20) then
|
||||
Style.Shadow := V;
|
||||
|
||||
{ Доля вводится процентами: «двадцать» понятнее, чем «ноль целых два». }
|
||||
V := StrToIntDef(Trim(edHeight.Text), Round(Style.MaxHeight * 100));
|
||||
if (V >= 5) and (V <= 100) then
|
||||
begin
|
||||
D := V / 100;
|
||||
Style.MaxHeight := D;
|
||||
end;
|
||||
|
||||
Style.Bold := cbBold.Checked;
|
||||
Style.Italic := cbItalic.Checked;
|
||||
end;
|
||||
|
||||
{ Показывает, во что превратится надпись: сколько строк и каким размером.
|
||||
Считает тот же код, что и сборка разметки, поэтому расхождения быть не
|
||||
может. }
|
||||
procedure TTextDialog.UpdateFit;
|
||||
var
|
||||
Lines: TStringList;
|
||||
Size: Integer;
|
||||
begin
|
||||
ReadControls;
|
||||
Lines := TStringList.Create;
|
||||
try
|
||||
Size := FitTextForPreview(Memo.Lines.Text, Style, FrameW, FrameH, Lines);
|
||||
if Size < Style.FontSize then
|
||||
lblFit.Caption := Format(
|
||||
'Не влезает: размер уменьшится с %d до %d, строк — %d',
|
||||
[Style.FontSize, Size, Lines.Count])
|
||||
else
|
||||
lblFit.Caption := Format('Влезает: размер %d, строк — %d',
|
||||
[Size, Lines.Count]);
|
||||
finally
|
||||
Lines.Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
procedure TTextDialog.AnyChange(Sender: TObject);
|
||||
begin
|
||||
UpdateFit;
|
||||
end;
|
||||
|
||||
function EditTextClip(var AText: string; var AStyle: TTextStyle;
|
||||
AFrameWidth, AFrameHeight: Integer): Boolean;
|
||||
const
|
||||
Rows: array[0..2] of Integer = (7, 4, 1);
|
||||
var
|
||||
Dlg: TTextDialog;
|
||||
BtnOk, BtnCancel: TThemedButton;
|
||||
Lbl: TLabel;
|
||||
I, R, C, Place: Integer;
|
||||
Grid: TPanel;
|
||||
begin
|
||||
Result := False;
|
||||
Dlg := TTextDialog.Create(nil);
|
||||
try
|
||||
Dlg.Style := AStyle;
|
||||
Dlg.FrameW := AFrameWidth;
|
||||
Dlg.FrameH := AFrameHeight;
|
||||
|
||||
Dlg.Form := TForm.CreateNew(nil);
|
||||
Dlg.Form.Caption := 'Текст';
|
||||
Dlg.Form.Position := poScreenCenter;
|
||||
Dlg.Form.ClientWidth := 640;
|
||||
Dlg.Form.ClientHeight := 470;
|
||||
|
||||
Lbl := TLabel.Create(Dlg.Form);
|
||||
Lbl.Parent := Dlg.Form;
|
||||
Lbl.SetBounds(12, 10, 200, 20);
|
||||
Lbl.Caption := 'Текст надписи';
|
||||
|
||||
Dlg.Memo := TMemo.Create(Dlg.Form);
|
||||
Dlg.Memo.Parent := Dlg.Form;
|
||||
Dlg.Memo.SetBounds(12, 32, 616, 100);
|
||||
Dlg.Memo.Anchors := [akTop, akLeft, akRight];
|
||||
Dlg.Memo.ScrollBars := ssAutoVertical;
|
||||
Dlg.Memo.Text := AText;
|
||||
Dlg.Memo.OnChange := @Dlg.AnyChange;
|
||||
|
||||
Lbl := TLabel.Create(Dlg.Form);
|
||||
Lbl.Parent := Dlg.Form;
|
||||
Lbl.SetBounds(12, 146, 60, 20);
|
||||
Lbl.Caption := 'Шрифт';
|
||||
|
||||
Dlg.cbFont := TComboBox.Create(Dlg.Form);
|
||||
Dlg.cbFont.Parent := Dlg.Form;
|
||||
Dlg.cbFont.SetBounds(80, 142, 260, 26);
|
||||
Dlg.cbFont.Style := csDropDownList;
|
||||
Dlg.cbFont.Items.Assign(Screen.Fonts);
|
||||
Dlg.cbFont.ItemIndex := Dlg.cbFont.Items.IndexOf(AStyle.FontName);
|
||||
if Dlg.cbFont.ItemIndex < 0 then
|
||||
Dlg.cbFont.ItemIndex := Dlg.cbFont.Items.IndexOf(DefaultTextFont);
|
||||
Dlg.cbFont.OnChange := @Dlg.AnyChange;
|
||||
|
||||
Lbl := TLabel.Create(Dlg.Form);
|
||||
Lbl.Parent := Dlg.Form;
|
||||
Lbl.SetBounds(356, 146, 60, 20);
|
||||
Lbl.Caption := 'Размер';
|
||||
|
||||
Dlg.edSize := TEdit.Create(Dlg.Form);
|
||||
Dlg.edSize.Parent := Dlg.Form;
|
||||
Dlg.edSize.SetBounds(420, 142, 60, 26);
|
||||
Dlg.edSize.Text := IntToStr(AStyle.FontSize);
|
||||
Dlg.edSize.OnChange := @Dlg.AnyChange;
|
||||
|
||||
Dlg.cbBold := TCheckBox.Create(Dlg.Form);
|
||||
Dlg.cbBold.Parent := Dlg.Form;
|
||||
Dlg.cbBold.SetBounds(492, 144, 76, 22);
|
||||
Dlg.cbBold.Caption := 'Жирный';
|
||||
Dlg.cbBold.Checked := AStyle.Bold;
|
||||
Dlg.cbBold.OnChange := @Dlg.AnyChange;
|
||||
|
||||
Dlg.cbItalic := TCheckBox.Create(Dlg.Form);
|
||||
Dlg.cbItalic.Parent := Dlg.Form;
|
||||
Dlg.cbItalic.SetBounds(568, 144, 66, 22);
|
||||
Dlg.cbItalic.Caption := 'Косой';
|
||||
Dlg.cbItalic.Checked := AStyle.Italic;
|
||||
Dlg.cbItalic.OnChange := @Dlg.AnyChange;
|
||||
|
||||
{ --- Цвета --- }
|
||||
Lbl := TLabel.Create(Dlg.Form);
|
||||
Lbl.Parent := Dlg.Form;
|
||||
Lbl.SetBounds(12, 186, 60, 20);
|
||||
Lbl.Caption := 'Цвет';
|
||||
|
||||
Dlg.BtnColor := TThemedButton.Create(Dlg.Form);
|
||||
Dlg.BtnColor.Parent := Dlg.Form;
|
||||
Dlg.BtnColor.SetBounds(80, 182, 70, 26);
|
||||
Dlg.BtnColor.OnClick := @Dlg.ColorClick;
|
||||
PaintColorButton(Dlg.BtnColor, AStyle.Color);
|
||||
|
||||
Lbl := TLabel.Create(Dlg.Form);
|
||||
Lbl.Parent := Dlg.Form;
|
||||
Lbl.SetBounds(160, 186, 70, 20);
|
||||
Lbl.Caption := 'Обводка';
|
||||
|
||||
Dlg.BtnOutlineColor := TThemedButton.Create(Dlg.Form);
|
||||
Dlg.BtnOutlineColor.Parent := Dlg.Form;
|
||||
Dlg.BtnOutlineColor.SetBounds(232, 182, 70, 26);
|
||||
Dlg.BtnOutlineColor.OnClick := @Dlg.ColorClick;
|
||||
PaintColorButton(Dlg.BtnOutlineColor, AStyle.OutlineColor);
|
||||
|
||||
Dlg.edOutline := TEdit.Create(Dlg.Form);
|
||||
Dlg.edOutline.Parent := Dlg.Form;
|
||||
Dlg.edOutline.SetBounds(310, 182, 40, 26);
|
||||
Dlg.edOutline.Text := IntToStr(AStyle.Outline);
|
||||
Dlg.edOutline.OnChange := @Dlg.AnyChange;
|
||||
|
||||
Lbl := TLabel.Create(Dlg.Form);
|
||||
Lbl.Parent := Dlg.Form;
|
||||
Lbl.SetBounds(360, 186, 50, 20);
|
||||
Lbl.Caption := 'Тень';
|
||||
|
||||
Dlg.BtnShadowColor := TThemedButton.Create(Dlg.Form);
|
||||
Dlg.BtnShadowColor.Parent := Dlg.Form;
|
||||
Dlg.BtnShadowColor.SetBounds(412, 182, 70, 26);
|
||||
Dlg.BtnShadowColor.OnClick := @Dlg.ColorClick;
|
||||
PaintColorButton(Dlg.BtnShadowColor, AStyle.ShadowColor);
|
||||
|
||||
Dlg.edShadow := TEdit.Create(Dlg.Form);
|
||||
Dlg.edShadow.Parent := Dlg.Form;
|
||||
Dlg.edShadow.SetBounds(490, 182, 40, 26);
|
||||
Dlg.edShadow.Text := IntToStr(AStyle.Shadow);
|
||||
Dlg.edShadow.OnChange := @Dlg.AnyChange;
|
||||
|
||||
{ --- Место в кадре --- }
|
||||
Lbl := TLabel.Create(Dlg.Form);
|
||||
Lbl.Parent := Dlg.Form;
|
||||
Lbl.SetBounds(12, 224, 160, 20);
|
||||
Lbl.Caption := 'Место в кадре';
|
||||
|
||||
Grid := TPanel.Create(Dlg.Form);
|
||||
Grid.Parent := Dlg.Form;
|
||||
Grid.SetBounds(12, 246, 130, 130);
|
||||
Grid.BevelOuter := bvNone;
|
||||
|
||||
for R := 0 to 2 do
|
||||
for C := 0 to 2 do
|
||||
begin
|
||||
Place := Rows[R] + C;
|
||||
Dlg.Places[Place] := TThemedButton.Create(Dlg.Form);
|
||||
Dlg.Places[Place].Parent := Grid;
|
||||
Dlg.Places[Place].SetBounds(C * 44, R * 44, 40, 40);
|
||||
Dlg.Places[Place].Tag := Place;
|
||||
Dlg.Places[Place].TabStop := False;
|
||||
Dlg.Places[Place].Down := Place = Ord(AStyle.Place);
|
||||
Dlg.Places[Place].OnClick := @Dlg.PlaceClick;
|
||||
end;
|
||||
|
||||
Lbl := TLabel.Create(Dlg.Form);
|
||||
Lbl.Parent := Dlg.Form;
|
||||
Lbl.SetBounds(160, 250, 260, 20);
|
||||
Lbl.Caption := 'Доля высоты кадра под текст, %';
|
||||
|
||||
Dlg.edHeight := TEdit.Create(Dlg.Form);
|
||||
Dlg.edHeight.Parent := Dlg.Form;
|
||||
Dlg.edHeight.SetBounds(420, 246, 60, 26);
|
||||
Dlg.edHeight.Text := IntToStr(Round(AStyle.MaxHeight * 100));
|
||||
Dlg.edHeight.OnChange := @Dlg.AnyChange;
|
||||
|
||||
Dlg.lblFit := TLabel.Create(Dlg.Form);
|
||||
Dlg.lblFit.Parent := Dlg.Form;
|
||||
Dlg.lblFit.SetBounds(160, 284, 460, 40);
|
||||
Dlg.lblFit.AutoSize := False;
|
||||
Dlg.lblFit.WordWrap := True;
|
||||
|
||||
BtnOk := TThemedButton.Create(Dlg.Form);
|
||||
BtnOk.Parent := Dlg.Form;
|
||||
BtnOk.SetBounds(420, 424, 100, 30);
|
||||
BtnOk.Anchors := [akRight, akBottom];
|
||||
BtnOk.Caption := 'Готово';
|
||||
BtnOk.ModalResult := mrOk;
|
||||
BtnOk.Default := True;
|
||||
|
||||
BtnCancel := TThemedButton.Create(Dlg.Form);
|
||||
BtnCancel.Parent := Dlg.Form;
|
||||
BtnCancel.SetBounds(528, 424, 100, 30);
|
||||
BtnCancel.Anchors := [akRight, akBottom];
|
||||
BtnCancel.Caption := 'Отмена';
|
||||
BtnCancel.ModalResult := mrCancel;
|
||||
BtnCancel.Cancel := True;
|
||||
|
||||
ApplyDarkTheme(Dlg.Form);
|
||||
{ Цвета кнопок ставятся после раскраски: она сбрасывает цвет надписи
|
||||
всем кнопкам разом. }
|
||||
PaintColorButton(Dlg.BtnColor, Dlg.Style.Color);
|
||||
PaintColorButton(Dlg.BtnOutlineColor, Dlg.Style.OutlineColor);
|
||||
PaintColorButton(Dlg.BtnShadowColor, Dlg.Style.ShadowColor);
|
||||
Dlg.UpdateFit;
|
||||
|
||||
if Dlg.Form.ShowModal <> mrOk then
|
||||
Exit;
|
||||
|
||||
Dlg.ReadControls;
|
||||
AText := TrimRight(Dlg.Memo.Lines.Text);
|
||||
AStyle := Dlg.Style;
|
||||
Result := True;
|
||||
finally
|
||||
Dlg.Form.Free;
|
||||
Dlg.Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
end.
|
||||
62
src/uTextMeasure.pas
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
{
|
||||
uTextMeasure — точный замер ширины текста для подбора размера.
|
||||
|
||||
Живёт отдельно от uSubtitles нарочно: там модель, а здесь холст, то есть
|
||||
оконная библиотека. Служебная программа собирает субтитры без окна и
|
||||
обходится приблизительной оценкой; окно ставит сюда точный замер.
|
||||
|
||||
Меряем тем же шрифтом и тем же движком, каким libass будет рисовать: за
|
||||
обоими стоит FreeType. Полного совпадения нет — настройки сглаживания
|
||||
разные, — поэтому в подборе размера заложен запас.
|
||||
}
|
||||
unit uTextMeasure;
|
||||
|
||||
{$mode objfpc}{$H+}
|
||||
|
||||
interface
|
||||
|
||||
{ Ставит точный замер в uSubtitles. Вызывается один раз при запуске. }
|
||||
procedure InstallTextMeasure;
|
||||
|
||||
implementation
|
||||
|
||||
uses
|
||||
SysUtils, Graphics, uProject, uSubtitles;
|
||||
|
||||
var
|
||||
{ Холст для замеров. Один на всё время работы: заводить растр на каждое
|
||||
слово было бы расточительно, а слов бывают тысячи. }
|
||||
Bitmap: TBitmap = nil;
|
||||
|
||||
function MeasureText(const AText: string; const AStyle: TTextStyle;
|
||||
ASize: Integer): Integer;
|
||||
begin
|
||||
if Bitmap = nil then
|
||||
begin
|
||||
Bitmap := TBitmap.Create;
|
||||
Bitmap.SetSize(4, 4);
|
||||
end;
|
||||
|
||||
Bitmap.Canvas.Font.Name := AStyle.FontName;
|
||||
{ Height, а не Size: он задаётся в точках холста, и отношение к размеру
|
||||
в кадре получается прямым. Size пересчитывался бы через разрешение
|
||||
экрана, а оно к кадру проекта отношения не имеет. }
|
||||
Bitmap.Canvas.Font.Height := ASize;
|
||||
Bitmap.Canvas.Font.Style := [];
|
||||
if AStyle.Bold then
|
||||
Bitmap.Canvas.Font.Style := Bitmap.Canvas.Font.Style + [fsBold];
|
||||
if AStyle.Italic then
|
||||
Bitmap.Canvas.Font.Style := Bitmap.Canvas.Font.Style + [fsItalic];
|
||||
|
||||
Result := Bitmap.Canvas.TextWidth(AText);
|
||||
end;
|
||||
|
||||
procedure InstallTextMeasure;
|
||||
begin
|
||||
uSubtitles.TextMeasure := @MeasureText;
|
||||
end;
|
||||
|
||||
finalization
|
||||
Bitmap.Free;
|
||||
|
||||
end.
|
||||
696
src/uTheme.pas
|
|
@ -18,8 +18,10 @@
|
|||
- полосы прокрутки внутри списков и журнала рисует система;
|
||||
- рамка окна и её кнопки принадлежат оконному менеджеру;
|
||||
- меню под gtk2 своими руками не рисуется — в LCL это прямо помечено
|
||||
как неподдерживаемое (interfaces/gtk2/issues.xml). Под Windows
|
||||
рисуется, там это и нужно.
|
||||
как неподдерживаемое (interfaces/gtk2/issues.xml). Зато сам gtk
|
||||
принимает описание стиля текстом, и цвет меню задаётся так
|
||||
(ApplyMenuStyle). Под Windows наоборот: строк стиля нет, зато
|
||||
работает отрисовка пунктов своими руками (ApplyDarkMenu).
|
||||
}
|
||||
unit uTheme;
|
||||
|
||||
|
|
@ -29,7 +31,8 @@ interface
|
|||
|
||||
uses
|
||||
Classes, SysUtils, Types, Controls, Graphics, Forms, StdCtrls, ExtCtrls,
|
||||
ComCtrls, Grids, Menus, Dialogs, LCLType, LCLIntf, LCLProc;
|
||||
ComCtrls, Grids, Menus, Dialogs, Math, IntfGraphics, FPimage,
|
||||
LCLType, LCLIntf, LCLProc, uIconData;
|
||||
|
||||
const
|
||||
{ Порядок байт в TColor — $00BBGGRR, а не привычный RGB. У серых цветов
|
||||
|
|
@ -49,8 +52,46 @@ const
|
|||
ThemeSelection = TColor($785434); // приглушённый синий: выделение
|
||||
ThemeSelText = TColor($FFFFFF);
|
||||
ThemeGridLine = TColor($3A3A3A);
|
||||
ThemeLink = TColor($E0A060); // голубой: ссылка
|
||||
|
||||
type
|
||||
{ Значок на кнопке.
|
||||
|
||||
Картинки вшиты в программу (uIconData): зависеть от того, лежит ли
|
||||
рядом с ней файл, она не должна. Исходники — в icons_online, оттуда
|
||||
же требование указать авторство, см. окно «О программе».
|
||||
|
||||
Цвет значку задаётся при отрисовке: берётся прозрачность точки, а
|
||||
цвет — тот, каким сейчас рисуется кнопка. Поэтому один значок годится
|
||||
и для тёмной кнопки, и для оранжевой, и для недоступной. }
|
||||
TThemedIcon = (
|
||||
tiNone,
|
||||
tiSnap, // прилипание: края притягиваются к общей линии
|
||||
tiRipple, // схлопывание: дыра между кусками закрывается
|
||||
tiLog, // журнал: полоса с сообщениями
|
||||
tiGlue, // курсор липнет к границам клипов
|
||||
tiTarget // курсор держится середины монтажа
|
||||
);
|
||||
|
||||
{ Полоска выполнения, которая рисует себя сама.
|
||||
|
||||
Системная берёт цвета у темы оформления и на тёмном окне остаётся
|
||||
светлой — та же причина, по которой здесь своя кнопка и свои полосы
|
||||
прокрутки.
|
||||
|
||||
Доля хранится числом от нуля до единицы, а не «позицией» и
|
||||
«максимумом»: снаружи она всегда получается делением одного на
|
||||
другое, и хранить обе части незачем. }
|
||||
TThemedProgress = class(TGraphicControl)
|
||||
private
|
||||
FFraction: Double;
|
||||
procedure SetFraction(const AValue: Double);
|
||||
protected
|
||||
procedure Paint; override;
|
||||
public
|
||||
constructor Create(AOwner: TComponent); override;
|
||||
property Fraction: Double read FFraction write SetFraction;
|
||||
end;
|
||||
|
||||
{ Кнопка, которая рисует себя сама.
|
||||
|
||||
|
|
@ -60,6 +101,8 @@ type
|
|||
TThemedButton = class(TCustomControl)
|
||||
private
|
||||
FArrow: Boolean;
|
||||
FSwatch: TColor;
|
||||
FIcon: TThemedIcon;
|
||||
FFlat: Boolean;
|
||||
FDown: Boolean;
|
||||
FHover: Boolean;
|
||||
|
|
@ -68,6 +111,9 @@ type
|
|||
FCancel: Boolean;
|
||||
FModalResult: TModalResult;
|
||||
procedure SetArrow(AValue: Boolean);
|
||||
procedure SetIcon(AValue: TThemedIcon);
|
||||
procedure SetSwatch(AValue: TColor);
|
||||
procedure DrawIcon(const ARect: TRect; AColor: TColor);
|
||||
procedure SetFlat(AValue: Boolean);
|
||||
procedure SetDown(AValue: Boolean);
|
||||
procedure SetDefault(AValue: Boolean);
|
||||
|
|
@ -100,6 +146,12 @@ type
|
|||
property Arrow: Boolean read FArrow write SetArrow default False;
|
||||
property Cancel: Boolean read FCancel write SetCancel default False;
|
||||
property Caption;
|
||||
{ Значок вместо надписи. Надпись при этом не пропадает зря: её место —
|
||||
подсказка, которая всплывает по наведению. }
|
||||
property Icon: TThemedIcon read FIcon write SetIcon default tiNone;
|
||||
{ Образец цвета вместо надписи: кнопка выбора цвета должна показывать
|
||||
сам цвет, а не слово «выбрать». clNone — обычная кнопка. }
|
||||
property Swatch: TColor read FSwatch write SetSwatch default clNone;
|
||||
property Default: Boolean read FDefault write SetDefault default False;
|
||||
property Down: Boolean read FDown write SetDown default False;
|
||||
property Enabled;
|
||||
|
|
@ -131,9 +183,41 @@ procedure ApplyDarkTheme(AComponent: TComponent);
|
|||
при запуске. }
|
||||
procedure HookNewForms;
|
||||
|
||||
{ Меню в наших цветах. Под gtk2 не действует — там меню рисует GTK. }
|
||||
{ Меню в наших цветах отрисовкой своими руками. Действует только под
|
||||
win32; под gtk2 цвет задаётся строкой стиля, см. ApplyMenuStyle. }
|
||||
procedure ApplyDarkMenu(AMenu: TMenu);
|
||||
|
||||
{ Закрашивает остаток полосы меню — то место правее последнего названия,
|
||||
которое не занято ни одним пунктом.
|
||||
|
||||
Нужно потому, что до него не дотянуться ни отрисовкой пунктов (это не
|
||||
пункт), ни подложкой через SetMenuInfo (проверено под Wine: пункты
|
||||
тёмные, полоса светлая). Полоса живёт в неклиентской части окна,
|
||||
поэтому рисуем прямо в её область.
|
||||
|
||||
Под gtk2 ничего не делает: там всю полосу красит строка стиля. }
|
||||
procedure PaintMenuBarTail(AForm: TCustomForm);
|
||||
|
||||
{ Что вышло у последней закраски: какие прямоугольники выдала система и
|
||||
куда мы рисовали. Нужно, чтобы не гадать, почему полоса осталась
|
||||
светлой. }
|
||||
function MenuBarTailNote: string;
|
||||
|
||||
{ Цвета для меню, которое рисует не программа, а система.
|
||||
|
||||
Вызывать до создания окон: строка стиля разбирается один раз, и уже
|
||||
созданные виджеты о ней не узнают.
|
||||
|
||||
Под gtk2 это единственный способ. Собственных средств у LCL там нет —
|
||||
OwnerDraw, OnDrawItem и OnMeasureItem в gtk2 прямо помечены как
|
||||
неподдерживаемые (lcl/interfaces/gtk2/issues.xml), и событие из
|
||||
инспектора объектов просто никогда не наступит. Зато сам gtk принимает
|
||||
описание стиля текстом.
|
||||
|
||||
Под win32 ничего не делает: там работает отрисовка своими руками через
|
||||
ApplyDarkMenu. }
|
||||
procedure ApplyMenuStyle;
|
||||
|
||||
{ Окно сообщения в наших цветах вместо системного.
|
||||
|
||||
Кнопки задаются готовым набором, как у MessageDlg, но рисуются нашими.
|
||||
|
|
@ -142,8 +226,26 @@ function ThemedMessageDlg(const ACaption, AText: string;
|
|||
ADlgType: TMsgDlgType; AButtons: TMsgDlgButtons;
|
||||
AHelpCtx: Longint = 0): TModalResult;
|
||||
|
||||
{ Вопрос со своими кнопками. Возвращает номер нажатой, считая с нуля, или
|
||||
-1, если окно просто закрыли.
|
||||
|
||||
Нужен там, где готовых «Да / Нет / Отмена» не хватает: например,
|
||||
третьей кнопкой «Больше не напоминать». }
|
||||
function ThemedChoiceDlg(const ACaption, AText: string;
|
||||
const ACaptions: array of string): Integer;
|
||||
|
||||
{ Ввод одной строки в тёмном окне. Системный InputQuery выглядел бы белым
|
||||
пятном посреди тёмной программы. }
|
||||
function ThemedInputDlg(const ACaption, APrompt: string;
|
||||
var AValue: string): Boolean;
|
||||
|
||||
implementation
|
||||
|
||||
{$IFDEF LCLGTK2}
|
||||
uses
|
||||
glib2, gtk2;
|
||||
{$ENDIF}
|
||||
|
||||
{$IFDEF WINDOWS}
|
||||
{ Модуль Windows целиком подключать нельзя: он объявляет свои TRect и
|
||||
RegisterClass, а имена в Паскале нечувствительны к регистру — и обычные
|
||||
|
|
@ -166,6 +268,42 @@ const
|
|||
|
||||
function SetMenuInfo(AMenu: HMENU; const AInfo: TMenuInfoRec): LongBool;
|
||||
stdcall; external 'user32' name 'SetMenuInfo';
|
||||
|
||||
{ Имя своё, а не DrawMenuBar: так называется и функция LCL, а имена в
|
||||
Паскале нечувствительны к регистру. По той же причине своё имя у всех
|
||||
остальных вызовов ниже. }
|
||||
function SysDrawMenuBar(AWnd: HWND): LongBool;
|
||||
stdcall; external 'user32' name 'DrawMenuBar';
|
||||
|
||||
type
|
||||
TMenuBarInfoRec = record
|
||||
cbSize: DWORD;
|
||||
rcBar: TRect;
|
||||
hMenu: HMENU;
|
||||
hwndMenu: HWND;
|
||||
Flags: DWORD; { два битовых признака, нам не нужны }
|
||||
end;
|
||||
|
||||
const
|
||||
{ Знаковое, как в заголовках Windows: -3. }
|
||||
OBJID_MENU = LongInt(-3);
|
||||
|
||||
function SysGetMenuBarInfo(AWnd: HWND; AObject, AItem: LongInt;
|
||||
var AInfo: TMenuBarInfoRec): LongBool;
|
||||
stdcall; external 'user32' name 'GetMenuBarInfo';
|
||||
function SysGetMenuItemRect(AWnd: HWND; AMenu: HMENU; AItem: UINT;
|
||||
var ARect: TRect): LongBool;
|
||||
stdcall; external 'user32' name 'GetMenuItemRect';
|
||||
function SysGetMenuItemCount(AMenu: HMENU): Integer;
|
||||
stdcall; external 'user32' name 'GetMenuItemCount';
|
||||
function SysGetWindowDC(AWnd: HWND): HDC;
|
||||
stdcall; external 'user32' name 'GetWindowDC';
|
||||
function SysReleaseDC(AWnd: HWND; ADC: HDC): Integer;
|
||||
stdcall; external 'user32' name 'ReleaseDC';
|
||||
function SysFillRect(ADC: HDC; const ARect: TRect; ABrush: HBRUSH): Integer;
|
||||
stdcall; external 'user32' name 'FillRect';
|
||||
function SysGetWindowRect(AWnd: HWND; var ARect: TRect): LongBool;
|
||||
stdcall; external 'user32' name 'GetWindowRect';
|
||||
{$ENDIF}
|
||||
|
||||
type
|
||||
|
|
@ -183,6 +321,8 @@ type
|
|||
|
||||
var
|
||||
Painter: TThemePainter;
|
||||
{ Перекрашенные значки: ключ «номер значка : цвет». }
|
||||
IconCache: TStringList;
|
||||
{$IFDEF WINDOWS}
|
||||
{ Кисть под полосу меню. Создаётся один раз: Windows держит на неё
|
||||
ссылку, пока меню живо, и удалять её раньше нельзя. }
|
||||
|
|
@ -193,6 +333,54 @@ const
|
|||
MenuTextLeft = 26; { отступ слева: место под галочку }
|
||||
MenuTextRight = 16; { отступ справа: место под стрелку подменю }
|
||||
MenuGap = 28; { просвет между названием и сочетанием клавиш }
|
||||
MenuBarSide = 10; { отступ по краям названия в полосе меню }
|
||||
|
||||
{ TThemedProgress }
|
||||
|
||||
constructor TThemedProgress.Create(AOwner: TComponent);
|
||||
begin
|
||||
inherited Create(AOwner);
|
||||
FFraction := 0;
|
||||
Height := 6;
|
||||
Width := 120;
|
||||
end;
|
||||
|
||||
procedure TThemedProgress.SetFraction(const AValue: Double);
|
||||
var
|
||||
V: Double;
|
||||
begin
|
||||
V := AValue;
|
||||
if V < 0 then
|
||||
V := 0;
|
||||
if V > 1 then
|
||||
V := 1;
|
||||
{ Перерисовываем только при заметном изменении: полоска обновляется по
|
||||
таймеру, и дёргать отрисовку на каждую тысячную незачем. }
|
||||
if Abs(V - FFraction) < 0.002 then
|
||||
Exit;
|
||||
FFraction := V;
|
||||
Invalidate;
|
||||
end;
|
||||
|
||||
procedure TThemedProgress.Paint;
|
||||
var
|
||||
R: TRect;
|
||||
W: Integer;
|
||||
begin
|
||||
R := ClientRect;
|
||||
Canvas.Brush.Style := bsSolid;
|
||||
Canvas.Brush.Color := ThemeButtonDown;
|
||||
Canvas.FillRect(R);
|
||||
W := Round(R.Right * FFraction);
|
||||
if W > 0 then
|
||||
begin
|
||||
Canvas.Brush.Color := ThemeAccent;
|
||||
Canvas.FillRect(Rect(0, 0, W, R.Bottom));
|
||||
end;
|
||||
Canvas.Brush.Style := bsClear;
|
||||
Canvas.Pen.Color := ThemeBorder;
|
||||
Canvas.Rectangle(R);
|
||||
end;
|
||||
|
||||
{ TThemedButton }
|
||||
|
||||
|
|
@ -204,6 +392,9 @@ begin
|
|||
ControlStyle := ControlStyle + [csHasDefaultAction, csHasCancelAction];
|
||||
TabStop := True;
|
||||
DoubleBuffered := True;
|
||||
{ Без явного значения образец был бы чёрным (ноль — это clBlack), и
|
||||
чёрные прямоугольники полезли бы на все кнопки разом. }
|
||||
FSwatch := clNone;
|
||||
with GetControlClassDefaultSize do
|
||||
SetInitialBounds(0, 0, cx, cy);
|
||||
end;
|
||||
|
|
@ -214,6 +405,121 @@ begin
|
|||
Result.cy := 28;
|
||||
end;
|
||||
|
||||
procedure TThemedButton.SetSwatch(AValue: TColor);
|
||||
begin
|
||||
if FSwatch = AValue then
|
||||
Exit;
|
||||
FSwatch := AValue;
|
||||
Invalidate;
|
||||
end;
|
||||
|
||||
procedure TThemedButton.SetIcon(AValue: TThemedIcon);
|
||||
begin
|
||||
if FIcon = AValue then
|
||||
Exit;
|
||||
FIcon := AValue;
|
||||
Invalidate;
|
||||
end;
|
||||
|
||||
{ Значок в нужном цвете.
|
||||
|
||||
Картинка чёрная с прозрачностью, поэтому перекрашивается прямо по
|
||||
точкам: прозрачность остаётся своя, цвет ставится наш. Результат
|
||||
запоминается — цветов ровно три (обычный, на оранжевом, недоступный), и
|
||||
пересчитывать их на каждую перерисовку незачем. }
|
||||
function ThemedIconImage(AIcon: TThemedIcon; AColor: TColor):
|
||||
TPortableNetworkGraphic;
|
||||
var
|
||||
Key: string;
|
||||
Idx, X, Y: Integer;
|
||||
Stream: TMemoryStream;
|
||||
Img: TLazIntfImage;
|
||||
Px: TFPColor;
|
||||
R, G, B: Byte;
|
||||
begin
|
||||
Result := nil;
|
||||
if AIcon = tiNone then
|
||||
Exit;
|
||||
|
||||
Key := IntToStr(Ord(AIcon)) + ':' + IntToStr(AColor);
|
||||
if IconCache = nil then
|
||||
begin
|
||||
IconCache := TStringList.Create;
|
||||
IconCache.Sorted := True;
|
||||
IconCache.OwnsObjects := True;
|
||||
end;
|
||||
Idx := IconCache.IndexOf(Key);
|
||||
if Idx >= 0 then
|
||||
Exit(TPortableNetworkGraphic(IconCache.Objects[Idx]));
|
||||
|
||||
Stream := TMemoryStream.Create;
|
||||
try
|
||||
case AIcon of
|
||||
tiSnap: Stream.WriteBuffer(IconSnapData[0], Length(IconSnapData));
|
||||
tiRipple: Stream.WriteBuffer(IconRippleData[0], Length(IconRippleData));
|
||||
tiLog: Stream.WriteBuffer(IconLogData[0], Length(IconLogData));
|
||||
tiGlue: Stream.WriteBuffer(IconGlueData[0], Length(IconGlueData));
|
||||
tiTarget: Stream.WriteBuffer(IconTargetData[0], Length(IconTargetData));
|
||||
else
|
||||
Exit;
|
||||
end;
|
||||
Stream.Position := 0;
|
||||
|
||||
Result := TPortableNetworkGraphic.Create;
|
||||
try
|
||||
Result.LoadFromStream(Stream);
|
||||
{ Порядок байт в TColor обратный привычному. }
|
||||
R := AColor and $FF;
|
||||
G := (AColor shr 8) and $FF;
|
||||
B := (AColor shr 16) and $FF;
|
||||
|
||||
Img := Result.CreateIntfImage;
|
||||
try
|
||||
for Y := 0 to Img.Height - 1 do
|
||||
for X := 0 to Img.Width - 1 do
|
||||
begin
|
||||
Px := Img.Colors[X, Y];
|
||||
{ Прозрачное трогать незачем, а видимое красим целиком. }
|
||||
if Px.Alpha = 0 then
|
||||
Continue;
|
||||
Px.Red := R * 257;
|
||||
Px.Green := G * 257;
|
||||
Px.Blue := B * 257;
|
||||
Img.Colors[X, Y] := Px;
|
||||
end;
|
||||
Result.LoadFromIntfImage(Img);
|
||||
finally
|
||||
Img.Free;
|
||||
end;
|
||||
except
|
||||
FreeAndNil(Result);
|
||||
end;
|
||||
finally
|
||||
Stream.Free;
|
||||
end;
|
||||
|
||||
{ Неудачу тоже запоминаем: иначе разбирать картинку заново пришлось бы
|
||||
на каждой перерисовке. }
|
||||
IconCache.AddObject(Key, Result);
|
||||
end;
|
||||
|
||||
{ Значок рисуется без растягивания: картинка вшита ровно того размера, под
|
||||
который сделана кнопка. Если кнопка оказалась крупнее — значок остаётся
|
||||
своего размера и стоит по центру, а не расплывается. }
|
||||
procedure TThemedButton.DrawIcon(const ARect: TRect; AColor: TColor);
|
||||
var
|
||||
Img: TPortableNetworkGraphic;
|
||||
X, Y: Integer;
|
||||
begin
|
||||
Img := ThemedIconImage(FIcon, AColor);
|
||||
if (Img = nil) or (Img.Width = 0) then
|
||||
Exit;
|
||||
|
||||
X := (ARect.Left + ARect.Right - Img.Width) div 2;
|
||||
Y := (ARect.Top + ARect.Bottom - Img.Height) div 2;
|
||||
Canvas.Draw(X, Y, Img);
|
||||
end;
|
||||
|
||||
procedure TThemedButton.Paint;
|
||||
var
|
||||
R: TRect;
|
||||
|
|
@ -279,6 +585,16 @@ begin
|
|||
Canvas.Polygon([Point(CX - 5, CY - 2), Point(CX + 5, CY - 2),
|
||||
Point(CX, CY + 4)]);
|
||||
end
|
||||
else if FSwatch <> clNone then
|
||||
begin
|
||||
{ Образец с рамкой: без неё белый цвет на светлой кнопке сливается. }
|
||||
Canvas.Brush.Color := FSwatch;
|
||||
Canvas.Brush.Style := bsSolid;
|
||||
Canvas.Pen.Color := ThemeBorder;
|
||||
Canvas.Rectangle(R.Left + 5, R.Top + 5, R.Right - 5, R.Bottom - 5);
|
||||
end
|
||||
else if FIcon <> tiNone then
|
||||
DrawIcon(R, Fore)
|
||||
else
|
||||
begin
|
||||
Canvas.Font.Assign(Font);
|
||||
|
|
@ -287,7 +603,10 @@ begin
|
|||
Canvas.TextOut((R.Right - TS.cx) div 2, (R.Bottom - TS.cy) div 2, Caption);
|
||||
end;
|
||||
|
||||
if Focused then
|
||||
{ Рамка фокуса рисуется только там, где фокус вообще предусмотрен.
|
||||
Кнопки полосы меню его не берут, а рамка на них появлялась и
|
||||
выглядела как испорченный пункт. }
|
||||
if Focused and TabStop then
|
||||
begin
|
||||
Canvas.Pen.Color := ThemeTextDim;
|
||||
Canvas.Pen.Style := psDot;
|
||||
|
|
@ -390,18 +709,22 @@ end;
|
|||
|
||||
procedure TThemedButton.MouseUp(Button: TMouseButton; Shift: TShiftState;
|
||||
X, Y: Integer);
|
||||
var
|
||||
WasPressed: Boolean;
|
||||
begin
|
||||
WasPressed := FPressed;
|
||||
FPressed := False;
|
||||
Invalidate;
|
||||
inherited MouseUp(Button, Shift, X, Y);
|
||||
{ Щелчком считается только отпускание внутри кнопки: увёл курсор в
|
||||
сторону — значит передумал. }
|
||||
if WasPressed and (Button = mbLeft) and
|
||||
PtInRect(ClientRect, Point(X, Y)) then
|
||||
Click;
|
||||
|
||||
{ Click отсюда не вызывается.
|
||||
|
||||
Его вызывает сама LCL при отпускании внутри кнопки — TControl.
|
||||
WMLButtonUp, с той же проверкой «внутри», какая напрашивается здесь.
|
||||
Написанный руками вызов оказывался вторым, и каждый щелчок срабатывал
|
||||
дважды. На кнопке-переключателе это значит «ничего не произошло»:
|
||||
первое срабатывание меняло состояние, второе возвращало обратно.
|
||||
|
||||
Найдено на невидимом экране: в журнал легли две записи «обработчик
|
||||
кнопки журнала вызван» на один щелчок мышью, тогда как то же действие
|
||||
с клавиатуры давало одну. }
|
||||
end;
|
||||
|
||||
procedure TThemedButton.KeyDown(var Key: Word; Shift: TShiftState);
|
||||
|
|
@ -497,6 +820,15 @@ begin
|
|||
H := ACanvas.TextHeight('Wg');
|
||||
Y := ARect.Top + (ARect.Bottom - ARect.Top - H) div 2;
|
||||
|
||||
{ В полосе меню отступ слева другой: место под галочку и под сочетание
|
||||
клавиш там не нужно, а с отступом раскрытого списка названия разошлись
|
||||
бы с шириной, которую мы же и посчитали. }
|
||||
if Item.IsInMenuBar then
|
||||
begin
|
||||
ACanvas.TextOut(ARect.Left + MenuBarSide, Y, Item.Caption);
|
||||
Exit;
|
||||
end;
|
||||
|
||||
if Item.Checked then
|
||||
ACanvas.TextOut(ARect.Left + 8, Y, '•');
|
||||
|
||||
|
|
@ -529,6 +861,13 @@ begin
|
|||
end;
|
||||
|
||||
AHeight := ACanvas.TextHeight('Wg') + 10;
|
||||
|
||||
if Item.IsInMenuBar then
|
||||
begin
|
||||
AWidth := ACanvas.TextWidth(Item.Caption) + MenuBarSide * 2;
|
||||
Exit;
|
||||
end;
|
||||
|
||||
W := MenuTextLeft + ACanvas.TextWidth(Item.Caption) + MenuTextRight;
|
||||
if Item.ShortCut <> 0 then
|
||||
Inc(W, MenuGap + ACanvas.TextWidth(ShortCutToText(Item.ShortCut)));
|
||||
|
|
@ -702,6 +1041,153 @@ begin
|
|||
ApplyToChildren(TWinControl(AControl));
|
||||
end;
|
||||
|
||||
{ Цвет Паскаля хранит составляющие в обратном порядке, а gtk ждёт
|
||||
привычную запись «решётка, красный, зелёный, синий». }
|
||||
function GtkColorText(AColor: TColor): string;
|
||||
begin
|
||||
Result := Format('#%.2x%.2x%.2x',
|
||||
[AColor and $FF, (AColor shr 8) and $FF, (AColor shr 16) and $FF]);
|
||||
end;
|
||||
|
||||
procedure ApplyMenuStyle;
|
||||
{$IFDEF LCLGTK2}
|
||||
var
|
||||
Rc: string;
|
||||
{$ENDIF}
|
||||
begin
|
||||
{$IFDEF LCLGTK2}
|
||||
Rc :=
|
||||
'style "nlve-menu" {' + LineEnding +
|
||||
' bg[NORMAL] = "' + GtkColorText(ThemePanel) + '"' + LineEnding +
|
||||
' bg[PRELIGHT] = "' + GtkColorText(ThemeAccent) + '"' + LineEnding +
|
||||
' bg[ACTIVE] = "' + GtkColorText(ThemePanel) + '"' + LineEnding +
|
||||
' bg[INSENSITIVE] = "' + GtkColorText(ThemePanel) + '"' + LineEnding +
|
||||
' fg[NORMAL] = "' + GtkColorText(ThemeText) + '"' + LineEnding +
|
||||
' fg[PRELIGHT] = "' + GtkColorText(ThemeAccentText) + '"' + LineEnding +
|
||||
' fg[ACTIVE] = "' + GtkColorText(ThemeText) + '"' + LineEnding +
|
||||
' fg[INSENSITIVE] = "' + GtkColorText(ThemeTextDim) + '"' + LineEnding +
|
||||
' text[NORMAL] = "' + GtkColorText(ThemeText) + '"' + LineEnding +
|
||||
' base[NORMAL] = "' + GtkColorText(ThemePanel) + '"' + LineEnding +
|
||||
'}' + LineEnding +
|
||||
'class "GtkMenuBar" style "nlve-menu"' + LineEnding +
|
||||
'class "GtkMenu" style "nlve-menu"' + LineEnding +
|
||||
'class "GtkMenuItem" style "nlve-menu"' + LineEnding +
|
||||
'widget_class "*<GtkMenuBar>*" style "nlve-menu"' + LineEnding +
|
||||
'widget_class "*<GtkMenuItem>*" style "nlve-menu"' + LineEnding;
|
||||
gtk_rc_parse_string(PChar(Rc));
|
||||
{$ENDIF}
|
||||
end;
|
||||
|
||||
var
|
||||
TailNote: string = 'закраска полосы меню не вызывалась';
|
||||
{ Идёт ли закраска прямо сейчас. См. пояснение у PaintMenuBarTail. }
|
||||
TailBusy: Boolean = False;
|
||||
|
||||
function MenuBarTailNote: string;
|
||||
begin
|
||||
Result := TailNote;
|
||||
end;
|
||||
|
||||
procedure PaintMenuBarTail(AForm: TCustomForm);
|
||||
{$IFDEF LCLWIN32}
|
||||
{ Защита от повторного входа.
|
||||
|
||||
Закраска вызывается из обработчика перерисовки рамки окна, а сама
|
||||
рисует в эту рамку — и под Wine рисование снова просило перерисовку.
|
||||
Получалась воронка: при появлении любого модального окна стек кончался
|
||||
и программа падала с «stack overflow». Найдено на невидимом экране в
|
||||
отдельном префиксе Wine: падало и из напоминания о зависимостях, и из
|
||||
меню «Файл → Зависимости». }
|
||||
{$ENDIF}
|
||||
{$IFDEF LCLWIN32}
|
||||
var
|
||||
Info: TMenuBarInfoRec;
|
||||
WinRect, ItemRect, Fill: TRect;
|
||||
DC: HDC;
|
||||
Count: Integer;
|
||||
Menu: HMENU;
|
||||
{$ENDIF}
|
||||
begin
|
||||
{$IFDEF LCLWIN32}
|
||||
if TailBusy then
|
||||
Exit;
|
||||
if (AForm = nil) or (not AForm.HandleAllocated) or (MenuBrush = 0) then
|
||||
begin
|
||||
TailNote := 'полоса: нет окна или кисти';
|
||||
Exit;
|
||||
end;
|
||||
if (AForm.Menu = nil) or (not AForm.Menu.HandleAllocated) then
|
||||
begin
|
||||
TailNote := 'полоса: у меню нет дескриптора';
|
||||
Exit;
|
||||
end;
|
||||
Menu := AForm.Menu.Handle;
|
||||
|
||||
FillChar(Info{%H-}, SizeOf(Info), 0);
|
||||
Info.cbSize := SizeOf(Info);
|
||||
if not SysGetMenuBarInfo(AForm.Handle, OBJID_MENU, 0, Info) then
|
||||
begin
|
||||
TailNote := 'полоса: GetMenuBarInfo отказала';
|
||||
Exit;
|
||||
end;
|
||||
|
||||
Count := SysGetMenuItemCount(Menu);
|
||||
if Count <= 0 then
|
||||
begin
|
||||
TailNote := 'полоса: пунктов не найдено';
|
||||
Exit;
|
||||
end;
|
||||
{ Правый край последнего названия. Всё, что правее, — ничьё, его и
|
||||
закрашиваем. }
|
||||
if not SysGetMenuItemRect(AForm.Handle, Menu, Count - 1, ItemRect{%H-}) then
|
||||
begin
|
||||
TailNote := 'полоса: GetMenuItemRect отказала';
|
||||
Exit;
|
||||
end;
|
||||
if not SysGetWindowRect(AForm.Handle, WinRect{%H-}) then
|
||||
begin
|
||||
TailNote := 'полоса: GetWindowRect отказала';
|
||||
Exit;
|
||||
end;
|
||||
|
||||
{ Обе рамки система выдаёт в координатах экрана, а рисуем мы в
|
||||
координатах окна. }
|
||||
Fill.Left := ItemRect.Right - WinRect.Left;
|
||||
Fill.Top := Info.rcBar.Top - WinRect.Top;
|
||||
Fill.Right := Info.rcBar.Right - WinRect.Left;
|
||||
Fill.Bottom := Info.rcBar.Bottom - WinRect.Top;
|
||||
TailNote := Format(
|
||||
'полоса: пунктов %d, полоса экрана %d,%d..%d,%d, край пункта %d, ' +
|
||||
'окно %d,%d, красим %d,%d..%d,%d',
|
||||
[Count, Info.rcBar.Left, Info.rcBar.Top, Info.rcBar.Right,
|
||||
Info.rcBar.Bottom, ItemRect.Right, WinRect.Left, WinRect.Top,
|
||||
Fill.Left, Fill.Top, Fill.Right, Fill.Bottom]);
|
||||
|
||||
if (Fill.Right <= Fill.Left) or (Fill.Bottom <= Fill.Top) then
|
||||
begin
|
||||
TailNote := TailNote + ' — красить нечего';
|
||||
Exit;
|
||||
end;
|
||||
|
||||
TailBusy := True;
|
||||
try
|
||||
DC := SysGetWindowDC(AForm.Handle);
|
||||
if DC = 0 then
|
||||
begin
|
||||
TailNote := TailNote + ' — окно не дало холста';
|
||||
Exit;
|
||||
end;
|
||||
try
|
||||
SysFillRect(DC, Fill, MenuBrush);
|
||||
finally
|
||||
SysReleaseDC(AForm.Handle, DC);
|
||||
end;
|
||||
finally
|
||||
TailBusy := False;
|
||||
end;
|
||||
{$ENDIF}
|
||||
end;
|
||||
|
||||
procedure ApplyDarkMenu(AMenu: TMenu);
|
||||
|
||||
procedure Walk(AItem: TMenuItem);
|
||||
|
|
@ -732,7 +1218,11 @@ begin
|
|||
создаёт его принудительно, и у всплывающего меню, до показа которого
|
||||
ещё далеко, это ломает само меню: оно перестаёт открываться.
|
||||
Цена отказа — тонкая рамка вокруг раскрытого списка остаётся системной;
|
||||
сами пункты рисуются нами и цвет берут. }
|
||||
сами пункты рисуются нами и цвет берут.
|
||||
|
||||
Отсюда следует, что для полосы меню эту процедуру надо звать дважды:
|
||||
до присваивания окну — чтобы расставить обработчики отрисовки, и после
|
||||
— когда дескриптор уже есть и подложку можно задать. }
|
||||
if AMenu.HandleAllocated then
|
||||
begin
|
||||
FillChar(Info{%H-}, SizeOf(Info), 0);
|
||||
|
|
@ -740,6 +1230,13 @@ begin
|
|||
Info.fMask := MIM_BACKGROUND or MIM_APPLYTOSUBMENUS;
|
||||
Info.hbrBack := MenuBrush;
|
||||
SetMenuInfo(AMenu.Handle, Info);
|
||||
|
||||
{ Полоса меню живёт в неклиентской части окна и сама себя не
|
||||
перерисовывает: без этого новая подложка появилась бы только при
|
||||
следующем изменении размера окна. }
|
||||
if (AMenu.Parent is TCustomForm) and
|
||||
TCustomForm(AMenu.Parent).HandleAllocated then
|
||||
SysDrawMenuBar(TCustomForm(AMenu.Parent).Handle);
|
||||
end;
|
||||
{$ENDIF}
|
||||
end;
|
||||
|
|
@ -768,6 +1265,170 @@ end;
|
|||
|
||||
{ Окно сообщения }
|
||||
|
||||
{ Высота надписи с переносом.
|
||||
|
||||
Считать по общей ширине текста нельзя: в сообщении бывают свои переводы
|
||||
строк, и оценка «ширина поделить на ширину поля» их не видит — текст
|
||||
обрезается снизу. Здесь каждая строка считается отдельно, а пустые
|
||||
строки дают пустой ряд. }
|
||||
function WrappedTextHeight(ACanvas: TCanvas; const AText: string;
|
||||
AWidth: Integer): Integer;
|
||||
var
|
||||
Lines: TStringList;
|
||||
I, Rows, RowH: Integer;
|
||||
begin
|
||||
RowH := ACanvas.TextHeight('Wg');
|
||||
Rows := 0;
|
||||
Lines := TStringList.Create;
|
||||
try
|
||||
Lines.Text := AText;
|
||||
if Lines.Count = 0 then
|
||||
Rows := 1;
|
||||
for I := 0 to Lines.Count - 1 do
|
||||
if Lines[I] = '' then
|
||||
Inc(Rows)
|
||||
else
|
||||
Inc(Rows, 1 + (ACanvas.TextWidth(Lines[I]) div AWidth));
|
||||
finally
|
||||
Lines.Free;
|
||||
end;
|
||||
Result := Rows * RowH + RowH div 2;
|
||||
end;
|
||||
|
||||
function ThemedChoiceDlg(const ACaption, AText: string;
|
||||
const ACaptions: array of string): Integer;
|
||||
const
|
||||
MarginX = 20;
|
||||
MarginY = 18;
|
||||
ButtonH = 30;
|
||||
ButtonGap = 10;
|
||||
var
|
||||
Dlg: TForm;
|
||||
Lbl: TLabel;
|
||||
Btn: TThemedButton;
|
||||
I, X, TotalW, Wid: Integer;
|
||||
Widths: array of Integer;
|
||||
begin
|
||||
Result := -1;
|
||||
if Length(ACaptions) = 0 then
|
||||
Exit;
|
||||
|
||||
Dlg := TForm.CreateNew(nil);
|
||||
try
|
||||
Dlg.Caption := ACaption;
|
||||
Dlg.BorderStyle := bsDialog;
|
||||
Dlg.Position := poScreenCenter;
|
||||
Dlg.Color := ThemeWindow;
|
||||
Dlg.Font.Color := ThemeText;
|
||||
|
||||
Lbl := TLabel.Create(Dlg);
|
||||
Lbl.Parent := Dlg;
|
||||
Lbl.Left := MarginX;
|
||||
Lbl.Top := MarginY;
|
||||
Lbl.WordWrap := True;
|
||||
Lbl.AutoSize := False;
|
||||
Lbl.Width := 520;
|
||||
Lbl.Caption := AText;
|
||||
Lbl.Font.Color := ThemeText;
|
||||
Lbl.Transparent := True;
|
||||
|
||||
Lbl.Height := WrappedTextHeight(Dlg.Canvas, AText, Lbl.Width);
|
||||
|
||||
{ Ширина у каждой кнопки своя: надписи разной длины, а одинаковая
|
||||
ширина по самой длинной растянула бы «ОК» на пол-окна. }
|
||||
SetLength(Widths, Length(ACaptions));
|
||||
TotalW := 0;
|
||||
for I := 0 to High(ACaptions) do
|
||||
begin
|
||||
Wid := Dlg.Canvas.TextWidth(ACaptions[I]) + 32;
|
||||
if Wid < 90 then
|
||||
Wid := 90;
|
||||
Widths[I] := Wid;
|
||||
Inc(TotalW, Wid);
|
||||
end;
|
||||
Inc(TotalW, ButtonGap * High(ACaptions));
|
||||
|
||||
Dlg.ClientWidth := Lbl.Width + MarginX * 2;
|
||||
if TotalW + MarginX * 2 > Dlg.ClientWidth then
|
||||
Dlg.ClientWidth := TotalW + MarginX * 2;
|
||||
Dlg.ClientHeight := MarginY + Lbl.Height + MarginY + ButtonH + MarginY;
|
||||
|
||||
X := (Dlg.ClientWidth - TotalW) div 2;
|
||||
for I := 0 to High(ACaptions) do
|
||||
begin
|
||||
Btn := TThemedButton.Create(Dlg);
|
||||
Btn.Parent := Dlg;
|
||||
Btn.SetBounds(X, Dlg.ClientHeight - MarginY - ButtonH, Widths[I],
|
||||
ButtonH);
|
||||
Btn.Caption := ACaptions[I];
|
||||
{ Номер кнопки возвращается через ModalResult: mrNone равен нулю,
|
||||
поэтому считаем с единицы и вычитаем её обратно. }
|
||||
Btn.ModalResult := I + 1;
|
||||
Btn.Default := I = 0;
|
||||
Btn.Cancel := I = High(ACaptions);
|
||||
Inc(X, Widths[I] + ButtonGap);
|
||||
end;
|
||||
|
||||
Result := Dlg.ShowModal - 1;
|
||||
if Result > High(ACaptions) then
|
||||
Result := -1;
|
||||
finally
|
||||
Dlg.Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
function ThemedInputDlg(const ACaption, APrompt: string;
|
||||
var AValue: string): Boolean;
|
||||
var
|
||||
Dlg: TForm;
|
||||
Lbl: TLabel;
|
||||
Edit: TEdit;
|
||||
BtnOk, BtnCancel: TThemedButton;
|
||||
begin
|
||||
Result := False;
|
||||
Dlg := TForm.CreateNew(nil);
|
||||
try
|
||||
Dlg.Caption := ACaption;
|
||||
Dlg.BorderStyle := bsDialog;
|
||||
Dlg.Position := poScreenCenter;
|
||||
Dlg.ClientWidth := 360;
|
||||
Dlg.ClientHeight := 130;
|
||||
|
||||
Lbl := TLabel.Create(Dlg);
|
||||
Lbl.Parent := Dlg;
|
||||
Lbl.SetBounds(16, 16, 328, 20);
|
||||
Lbl.Caption := APrompt;
|
||||
|
||||
Edit := TEdit.Create(Dlg);
|
||||
Edit.Parent := Dlg;
|
||||
Edit.SetBounds(16, 40, 328, 26);
|
||||
Edit.Text := AValue;
|
||||
|
||||
BtnOk := TThemedButton.Create(Dlg);
|
||||
BtnOk.Parent := Dlg;
|
||||
BtnOk.SetBounds(136, 84, 100, 30);
|
||||
BtnOk.Caption := 'Готово';
|
||||
BtnOk.ModalResult := mrOk;
|
||||
BtnOk.Default := True;
|
||||
|
||||
BtnCancel := TThemedButton.Create(Dlg);
|
||||
BtnCancel.Parent := Dlg;
|
||||
BtnCancel.SetBounds(244, 84, 100, 30);
|
||||
BtnCancel.Caption := 'Отмена';
|
||||
BtnCancel.ModalResult := mrCancel;
|
||||
BtnCancel.Cancel := True;
|
||||
|
||||
ApplyDarkTheme(Dlg);
|
||||
Edit.SetFocus;
|
||||
if Dlg.ShowModal <> mrOk then
|
||||
Exit;
|
||||
AValue := Trim(Edit.Text);
|
||||
Result := AValue <> '';
|
||||
finally
|
||||
Dlg.Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
function ThemedMessageDlg(const ACaption, AText: string;
|
||||
ADlgType: TMsgDlgType; AButtons: TMsgDlgButtons;
|
||||
AHelpCtx: Longint): TModalResult;
|
||||
|
|
@ -784,7 +1445,7 @@ var
|
|||
Order: array[0..3] of TModalResult = (mrOk, mrYes, mrNo, mrCancel);
|
||||
Names: array[0..3] of string = ('ОК', 'Да', 'Нет', 'Отмена');
|
||||
Flags: array[0..3] of TMsgDlgBtn = (mbOK, mbYes, mbNo, mbCancel);
|
||||
I, Count, X, TextW, TextH, TotalW: Integer;
|
||||
I, Count, X, TotalW: Integer;
|
||||
begin
|
||||
Dlg := TForm.CreateNew(nil);
|
||||
try
|
||||
|
|
@ -814,9 +1475,7 @@ begin
|
|||
|
||||
{ Высоту надписи считаем по её содержимому: сообщения бывают и в одну
|
||||
строку, и в десять. }
|
||||
TextH := Dlg.Canvas.TextHeight('Wg');
|
||||
TextW := Dlg.Canvas.TextWidth(AText);
|
||||
Lbl.Height := TextH * (1 + TextW div Lbl.Width) + TextH div 2;
|
||||
Lbl.Height := WrappedTextHeight(Dlg.Canvas, AText, Lbl.Width);
|
||||
|
||||
TotalW := Count * ButtonW + (Count - 1) * ButtonGap;
|
||||
Dlg.ClientWidth := Lbl.Width + MarginX * 2;
|
||||
|
|
@ -861,5 +1520,6 @@ initialization
|
|||
|
||||
finalization
|
||||
Painter.Free;
|
||||
IconCache.Free;
|
||||
|
||||
end.
|
||||
|
|
|
|||
|
|
@ -93,6 +93,8 @@ type
|
|||
FVBar: TThemedScrollBar;
|
||||
FHBar: TThemedScrollBar;
|
||||
FUpdating: Boolean;
|
||||
{ Глубина вложенности пересчёта размещения. Ограничена: см. SyncBars. }
|
||||
FDepth: Integer;
|
||||
{ Сколько места занимает всё содержимое таблицы. Считается сложением
|
||||
ширин столбцов и высот строк, а не свойствами GridWidth и
|
||||
GridHeight: с выключенными полосами прокрутки те возвращают не
|
||||
|
|
@ -475,10 +477,28 @@ begin
|
|||
finally
|
||||
FUpdating := False;
|
||||
end;
|
||||
{ Пересчёт размещения сам вызовет согласование ещё раз, но уже без
|
||||
расхождения — второго круга не будет. }
|
||||
if NeedRelayout then
|
||||
Relayout;
|
||||
{ Пересчёт размещения сам вызовет согласование ещё раз.
|
||||
|
||||
Обещать, что второго круга не будет, нельзя, и это стоило падения:
|
||||
появление вертикальной полосы сужает таблицу, от этого перестаёт
|
||||
помещаться содержимое по ширине, появляется горизонтальная полоса,
|
||||
от неё убавляется высота — и решение о вертикальной принимается
|
||||
заново. Под Wine эти качели не сходились никогда: стек в шестнадцать
|
||||
мегабайт съедался целиком, и программа падала при открытии окна
|
||||
зависимостей.
|
||||
|
||||
Поэтому глубина ограничена. Три круга хватает любому разумному
|
||||
случаю, а на четвёртом мы просто оставляем как есть: лишняя полоса
|
||||
прокрутки не беда, а падение — беда. }
|
||||
if NeedRelayout and (FDepth < 3) then
|
||||
begin
|
||||
Inc(FDepth);
|
||||
try
|
||||
Relayout;
|
||||
finally
|
||||
Dec(FDepth);
|
||||
end;
|
||||
end;
|
||||
end;
|
||||
|
||||
procedure TScrolledGrid.GridTopLeftChanged(Sender: TObject);
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ type
|
|||
private
|
||||
FRequests: TThumbRequests;
|
||||
FCurrent: Integer;
|
||||
FDone: Integer;
|
||||
FOnReady: TThumbReadyEvent;
|
||||
procedure SyncReady;
|
||||
function CancelQuery: Boolean;
|
||||
|
|
@ -53,6 +54,9 @@ type
|
|||
public
|
||||
constructor Create(const ARequests: TThumbRequests);
|
||||
property OnReady: TThumbReadyEvent read FOnReady write FOnReady;
|
||||
{ Столько же, сколько у разбора звука: очередь и сделанное. }
|
||||
function Total: Integer;
|
||||
property Done: Integer read FDone;
|
||||
end;
|
||||
|
||||
{ Путь к миниатюре в кэше проекта. Имя выводится из пути к исходнику,
|
||||
|
|
@ -91,11 +95,17 @@ var
|
|||
begin
|
||||
inherited Create(True);
|
||||
FreeOnTerminate := False;
|
||||
FDone := 0;
|
||||
SetLength(FRequests, Length(ARequests));
|
||||
for I := 0 to High(ARequests) do
|
||||
FRequests[I] := ARequests[I];
|
||||
end;
|
||||
|
||||
function TThumbThread.Total: Integer;
|
||||
begin
|
||||
Result := Length(FRequests);
|
||||
end;
|
||||
|
||||
function TThumbThread.CancelQuery: Boolean;
|
||||
begin
|
||||
Result := Terminated;
|
||||
|
|
@ -122,11 +132,13 @@ begin
|
|||
Exit;
|
||||
|
||||
FCurrent := I;
|
||||
FDone := I;
|
||||
|
||||
{ Уже готовая миниатюра — просто сообщаем о ней. }
|
||||
if FileExists(FRequests[I].TargetPath) then
|
||||
begin
|
||||
Synchronize(@SyncReady);
|
||||
FDone := I + 1;
|
||||
Continue;
|
||||
end;
|
||||
|
||||
|
|
@ -143,6 +155,7 @@ begin
|
|||
if Res.Started and (Res.ExitCode = 0) and
|
||||
FileExists(FRequests[I].TargetPath) then
|
||||
Synchronize(@SyncReady);
|
||||
FDone := I + 1;
|
||||
end;
|
||||
end;
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ unit uTransform;
|
|||
interface
|
||||
|
||||
uses
|
||||
Classes, SysUtils;
|
||||
Classes, SysUtils, uAppTypes;
|
||||
|
||||
type
|
||||
{ Как соотносятся кадр исходника и кадр проекта. }
|
||||
|
|
@ -69,6 +69,29 @@ function TransformFilter(const AT: TMediaTransform;
|
|||
Отсюда честное ограничение: предпросмотр показывает кадр проекта верно,
|
||||
но собственную настройку кадрирования файла не учитывает. Её видно в
|
||||
окне кадрирования и в готовом ролике. }
|
||||
{ То же самое, но для наложения в плеере: цепочка без обкладывания
|
||||
полями и отдельно точка, куда встанет получившаяся картинка.
|
||||
|
||||
Поля и наложение — две записи одного и того же. Сборка обкладывает
|
||||
картинку полями до кадра проекта, потому что кусок должен быть
|
||||
самостоятельным файлом. Плееру поля не нужны и вредны: чёрное поле
|
||||
закрыло бы нижнее видео. Ему нужны те же самые координаты, но как место
|
||||
наложения. Считаются они одним и тем же кодом — иначе окно показывало
|
||||
бы одно, а сборка давала другое. }
|
||||
function OverlayFilter(const AT: TMediaTransform;
|
||||
ASrcWidth, ASrcHeight, ADstWidth, ADstHeight: Integer;
|
||||
out AX, AY, AWidth, AHeight: Integer): string;
|
||||
|
||||
{ Фильтр цветового ключа: этот цвет становится прозрачным.
|
||||
|
||||
Один на всех: и сборка, и предпросмотр должны выбивать одинаково, иначе
|
||||
окно обещает одно, а готовый ролик даёт другое. Цвет записывается как
|
||||
0xRRGGBB — порядок обратный тому, в каком он хранится у нас. }
|
||||
function ColorKeyFilter(AColor: TRgb; ASimilarity, ABlend: Integer): string;
|
||||
|
||||
{ Фильтр приглушения по альфа-каналу. Пусто, если приглушать нечего. }
|
||||
function OpacityFilter(const AOpacity: Double): string;
|
||||
|
||||
function PreviewFitFilter(ADstWidth, ADstHeight: Integer): string;
|
||||
|
||||
function FitModeToStr(AMode: TFitMode): string;
|
||||
|
|
@ -78,7 +101,7 @@ function FitModeCaption(AMode: TFitMode): string;
|
|||
implementation
|
||||
|
||||
uses
|
||||
Math, uAppTypes;
|
||||
Math;
|
||||
|
||||
function DefaultTransform: TMediaTransform;
|
||||
begin
|
||||
|
|
@ -130,6 +153,28 @@ begin
|
|||
(ASrcWidth = ADstWidth) and (ASrcHeight = ADstHeight);
|
||||
end;
|
||||
|
||||
{ Дробное число через точку: у ffmpeg разделитель всегда точка, а в
|
||||
русской локали Format вставил бы запятую и развалил бы всю цепочку. }
|
||||
function FilterNum(const AValue: Double): string;
|
||||
begin
|
||||
Result := SecondsToStr(AValue);
|
||||
end;
|
||||
|
||||
function ColorKeyFilter(AColor: TRgb; ASimilarity, ABlend: Integer): string;
|
||||
begin
|
||||
Result := Format('colorkey=0x%.2x%.2x%.2x:%s:%s',
|
||||
[AColor and $FF, (AColor shr 8) and $FF, (AColor shr 16) and $FF,
|
||||
FilterNum(ASimilarity / 100), FilterNum(ABlend / 100)]);
|
||||
end;
|
||||
|
||||
function OpacityFilter(const AOpacity: Double): string;
|
||||
begin
|
||||
if AOpacity >= 0.999 then
|
||||
Result := ''
|
||||
else
|
||||
Result := 'colorchannelmixer=aa=' + FilterNum(Max(0, AOpacity));
|
||||
end;
|
||||
|
||||
function PreviewFitFilter(ADstWidth, ADstHeight: Integer): string;
|
||||
var
|
||||
W, H: string;
|
||||
|
|
@ -160,15 +205,17 @@ begin
|
|||
Result := 2;
|
||||
end;
|
||||
|
||||
function TransformFilter(const AT: TMediaTransform;
|
||||
ASrcWidth, ASrcHeight, ADstWidth, ADstHeight: Integer): string;
|
||||
{ Общая часть: цепочка до обкладывания полями и место, куда встанет
|
||||
получившаяся картинка в кадре проекта. }
|
||||
function BuildTransform(const AT: TMediaTransform;
|
||||
ASrcWidth, ASrcHeight, ADstWidth, ADstHeight: Integer;
|
||||
out AX, AY, AWidth, AHeight: Integer): string;
|
||||
var
|
||||
Chain: string;
|
||||
W0, H0, Tmp: Integer;
|
||||
K, KFit, KFill: Double;
|
||||
SW, SH: Integer;
|
||||
CW, CH, CX, CY: Integer;
|
||||
PX, PY: Integer;
|
||||
PW, PH, PX, PY: Integer;
|
||||
Rot: Integer;
|
||||
|
||||
procedure Add(const AFilter: string);
|
||||
|
|
@ -185,32 +232,14 @@ begin
|
|||
Exit;
|
||||
|
||||
Chain := '';
|
||||
W0 := ASrcWidth;
|
||||
H0 := ASrcHeight;
|
||||
|
||||
{ --- Поворот и отражение --- }
|
||||
Rot := ((AT.Rotation mod 360) + 360) mod 360;
|
||||
case Rot of
|
||||
90:
|
||||
begin
|
||||
{ transpose=1 — поворот на 90 по часовой стрелке. }
|
||||
Add('transpose=1');
|
||||
Tmp := W0; W0 := H0; H0 := Tmp;
|
||||
end;
|
||||
180:
|
||||
{ Два поворота подряд дешевле и точнее, чем поворот на произвольный
|
||||
угол: последний пересчитывает каждый пиксель. }
|
||||
Add('transpose=1,transpose=1');
|
||||
270:
|
||||
begin
|
||||
Add('transpose=2');
|
||||
Tmp := W0; W0 := H0; H0 := Tmp;
|
||||
end;
|
||||
end;
|
||||
if AT.FlipH then
|
||||
Add('hflip');
|
||||
if AT.FlipV then
|
||||
Add('vflip');
|
||||
{ Порядок действий: сначала выбирается кусок кадра, и только потом он
|
||||
поворачивается и отражается.
|
||||
|
||||
Так это устроено в монтажных программах, и так понятнее: рамка
|
||||
показывает, что́ останется, а поворот применяется к оставшемуся. При
|
||||
обратном порядке обрезался бы уже перевёрнутый кадр, и в рамку
|
||||
попадало бы совсем не то, что видно. }
|
||||
|
||||
{ --- Масштаб --- }
|
||||
if AT.Fit = fmStretch then
|
||||
|
|
@ -220,16 +249,16 @@ begin
|
|||
end
|
||||
else
|
||||
begin
|
||||
KFit := Min(ADstWidth / W0, ADstHeight / H0);
|
||||
KFill := Max(ADstWidth / W0, ADstHeight / H0);
|
||||
KFit := Min(ADstWidth / ASrcWidth, ADstHeight / ASrcHeight);
|
||||
KFill := Max(ADstWidth / ASrcWidth, ADstHeight / ASrcHeight);
|
||||
if AT.Fit = fmFill then
|
||||
K := KFill
|
||||
else
|
||||
K := KFit;
|
||||
if AT.Zoom > 0 then
|
||||
K := K * AT.Zoom;
|
||||
SW := MakeEven(Round(W0 * K));
|
||||
SH := MakeEven(Round(H0 * K));
|
||||
SW := MakeEven(Round(ASrcWidth * K));
|
||||
SH := MakeEven(Round(ASrcHeight * K));
|
||||
end;
|
||||
Add(Format('scale=%d:%d', [SW, SH]));
|
||||
|
||||
|
|
@ -247,20 +276,91 @@ begin
|
|||
Add(Format('crop=%d:%d:%d:%d', [CW, CH, CX, CY]));
|
||||
end;
|
||||
|
||||
{ --- Поля, если картинка меньше кадра --- }
|
||||
if (CW < ADstWidth) or (CH < ADstHeight) then
|
||||
{ --- Поворот и отражение оставшегося куска --- }
|
||||
Rot := ((AT.Rotation mod 360) + 360) mod 360;
|
||||
case Rot of
|
||||
90:
|
||||
begin
|
||||
{ transpose=1 — поворот на 90 по часовой стрелке. }
|
||||
Add('transpose=1');
|
||||
PW := CH; PH := CW;
|
||||
end;
|
||||
180:
|
||||
begin
|
||||
{ Два поворота подряд дешевле и точнее, чем поворот на
|
||||
произвольный угол: последний пересчитывает каждый пиксель. }
|
||||
Add('transpose=1,transpose=1');
|
||||
PW := CW; PH := CH;
|
||||
end;
|
||||
270:
|
||||
begin
|
||||
Add('transpose=2');
|
||||
PW := CH; PH := CW;
|
||||
end;
|
||||
else
|
||||
PW := CW; PH := CH;
|
||||
end;
|
||||
if AT.FlipH then
|
||||
Add('hflip');
|
||||
if AT.FlipV then
|
||||
Add('vflip');
|
||||
|
||||
{ --- Приведение к кадру проекта --- }
|
||||
|
||||
{ После поворота на 90 кусок лежит поперёк и в кадр проекта уже не
|
||||
вписывается. Вписываем целиком, с полями. }
|
||||
if (PW > ADstWidth) or (PH > ADstHeight) then
|
||||
begin
|
||||
PX := Round((ADstWidth - CW) / 2 + AT.OffsetX * ADstWidth);
|
||||
PY := Round((ADstHeight - CH) / 2 + AT.OffsetY * ADstHeight);
|
||||
PX := Max(0, Min(ADstWidth - CW, PX));
|
||||
PY := Max(0, Min(ADstHeight - CH, PY));
|
||||
Add(Format('pad=%d:%d:%d:%d', [ADstWidth, ADstHeight, PX, PY]));
|
||||
K := Min(ADstWidth / PW, ADstHeight / PH);
|
||||
PW := MakeEven(Round(PW * K));
|
||||
PH := MakeEven(Round(PH * K));
|
||||
Add(Format('scale=%d:%d', [PW, PH]));
|
||||
end;
|
||||
|
||||
{ Пиксель считаем квадратным: иначе плеер растянет кадр по своему
|
||||
разумению, и результат разойдётся с тем, что видно в предпросмотре. }
|
||||
Add('setsar=1');
|
||||
{ Смещение двигает картинку и там, где её не обрезали. Когда картинка
|
||||
больше кадра, двигать можно лишь окно, через которое на неё смотрят,
|
||||
— это делает crop выше. Когда она меньше, двигать надо её саму по
|
||||
полю, иначе уменьшенный кадр навсегда прибит к середине и в угол его
|
||||
не отправить. Обе стороны считают смещение в долях кадра проекта, так
|
||||
что число значит одно и то же. }
|
||||
PX := (ADstWidth - PW) div 2 + Round(AT.OffsetX * ADstWidth);
|
||||
PY := (ADstHeight - PH) div 2 + Round(AT.OffsetY * ADstHeight);
|
||||
PX := Max(0, Min(Max(0, ADstWidth - PW), PX));
|
||||
PY := Max(0, Min(Max(0, ADstHeight - PH), PY));
|
||||
|
||||
AX := PX;
|
||||
AY := PY;
|
||||
AWidth := PW;
|
||||
AHeight := PH;
|
||||
Result := Chain;
|
||||
end;
|
||||
|
||||
function TransformFilter(const AT: TMediaTransform;
|
||||
ASrcWidth, ASrcHeight, ADstWidth, ADstHeight: Integer): string;
|
||||
var
|
||||
X, Y, W, H: Integer;
|
||||
begin
|
||||
Result := BuildTransform(AT, ASrcWidth, ASrcHeight, ADstWidth, ADstHeight,
|
||||
X, Y, W, H);
|
||||
if Result = '' then
|
||||
Exit;
|
||||
|
||||
if (W < ADstWidth) or (H < ADstHeight) then
|
||||
Result := Result + Format(',pad=%d:%d:%d:%d', [ADstWidth, ADstHeight, X, Y]);
|
||||
|
||||
{ Пиксель считаем квадратным: иначе плеер растянет кадр по своему
|
||||
разумению, и результат разойдётся с тем, что видно в предпросмотре. }
|
||||
Result := Result + ',setsar=1';
|
||||
end;
|
||||
|
||||
function OverlayFilter(const AT: TMediaTransform;
|
||||
ASrcWidth, ASrcHeight, ADstWidth, ADstHeight: Integer;
|
||||
out AX, AY, AWidth, AHeight: Integer): string;
|
||||
begin
|
||||
Result := BuildTransform(AT, ASrcWidth, ASrcHeight, ADstWidth, ADstHeight,
|
||||
AX, AY, AWidth, AHeight);
|
||||
if Result <> '' then
|
||||
Result := Result + ',setsar=1';
|
||||
end;
|
||||
|
||||
end.
|
||||
|
|
|
|||
396
src/uWaveform.pas
Normal file
|
|
@ -0,0 +1,396 @@
|
|||
{
|
||||
uWaveform — форма звука для отрисовки на клипах.
|
||||
|
||||
Что считается. Для каждого файла один раз строится список пиков: сто
|
||||
значений в секунду, каждое — наибольший модуль отсчёта за свои десять
|
||||
миллисекунд. Часовая запись даёт 360 тысяч байт, это ничто.
|
||||
|
||||
Почему не полная звуковая дорожка. Рисовать по настоящим отсчётам
|
||||
нельзя: в секунде их 48 тысяч, а в клипе на экране — сотни пикселей.
|
||||
Пришлось бы каждый раз перечитывать файл целиком.
|
||||
|
||||
Почему через временный файл, а не по трубе. Запуск внешних программ у
|
||||
нас режет вывод на строки, а тут поток байтов, где перевод строки —
|
||||
такой же отсчёт, как любой другой. Временный файл на четырнадцать
|
||||
мегабайт для часовой записи проще и надёжнее, чем отдельный двоичный
|
||||
путь чтения.
|
||||
|
||||
Считается в отдельном потоке: список файлов должен работать, пока звук
|
||||
разбирается.
|
||||
}
|
||||
unit uWaveform;
|
||||
|
||||
{$mode objfpc}{$H+}
|
||||
|
||||
interface
|
||||
|
||||
uses
|
||||
Classes, SysUtils, uProcRunner;
|
||||
|
||||
const
|
||||
{ Пиков в секунду. Сто — это десять миллисекунд на пик: мельче глазу
|
||||
всё равно не видно, а крупнее уже теряются короткие звуки. }
|
||||
PeaksPerSecond = 100;
|
||||
|
||||
{ Частота, на которой разбирается звук. Восьми килогерц хватает, чтобы
|
||||
отличить тишину от звука, а объём вчетверо меньше исходного. }
|
||||
DecodeRate = 8000;
|
||||
|
||||
type
|
||||
TPeaks = record
|
||||
Valid: Boolean;
|
||||
Rate: Integer;
|
||||
{ Наибольший модуль отсчёта за интервал, приведённый к 0..255. }
|
||||
Data: array of Byte;
|
||||
end;
|
||||
|
||||
{ Путь к файлу пиков в кэше проекта. }
|
||||
function PeaksPath(const ACacheDir, ASourcePath: string): string;
|
||||
|
||||
{ Разбирает звук файла и записывает пики. Долгая операция, вызывать из
|
||||
рабочего потока. }
|
||||
function BuildPeaks(const ASourcePath, ATargetPath: string;
|
||||
ACancel: TProcCancelQuery; out AError: string): Boolean;
|
||||
|
||||
{ Читает готовые пики. }
|
||||
function LoadPeaks(const APath: string; out APeaks: TPeaks): Boolean;
|
||||
|
||||
{ Пики файла с запоминанием в памяти. Если их ещё нет на диске, вернёт
|
||||
False — считать надо отдельно, в потоке. }
|
||||
function CachedPeaks(const ACacheDir, ASourcePath: string;
|
||||
out APeaks: TPeaks): Boolean;
|
||||
|
||||
{ Забыть запомненное. Нужно при закрытии проекта: следующий проект живёт
|
||||
в другом кэше. }
|
||||
procedure ForgetPeaks;
|
||||
|
||||
type
|
||||
TPeaksRequest = record
|
||||
SourcePath: string;
|
||||
TargetPath: string;
|
||||
end;
|
||||
|
||||
TPeaksRequests = array of TPeaksRequest;
|
||||
|
||||
TPeaksReadyEvent = procedure(Sender: TObject;
|
||||
const ASourcePath: string) of object;
|
||||
|
||||
TPeaksThread = class(TThread)
|
||||
private
|
||||
FRequests: TPeaksRequests;
|
||||
FCurrent: Integer;
|
||||
FDone: Integer;
|
||||
FOnReady: TPeaksReadyEvent;
|
||||
procedure SyncReady;
|
||||
function CancelQuery: Boolean;
|
||||
protected
|
||||
procedure Execute; override;
|
||||
public
|
||||
constructor Create(const ARequests: TPeaksRequests);
|
||||
property OnReady: TPeaksReadyEvent read FOnReady write FOnReady;
|
||||
{ Сколько всего файлов в очереди и сколько уже разобрано. Читаются из
|
||||
главного потока для показа хода работы; целое читается и пишется
|
||||
одним действием, поэтому замок здесь не нужен. }
|
||||
function Total: Integer;
|
||||
property Done: Integer read FDone;
|
||||
end;
|
||||
|
||||
implementation
|
||||
|
||||
uses
|
||||
md5, uFfmpegCmd;
|
||||
|
||||
const
|
||||
{ Заголовок файла пиков: подпись и номер вида. Номер понадобится, если
|
||||
когда-нибудь поменяется частота или формат. }
|
||||
PeaksMagic = 'NLVW';
|
||||
PeaksVersion = 1;
|
||||
|
||||
{ Сколько отсчётов приходится на один пик. }
|
||||
SamplesPerPeak = DecodeRate div PeaksPerSecond;
|
||||
|
||||
ReadChunk = 64 * 1024;
|
||||
|
||||
type
|
||||
TPeaksCacheItem = class
|
||||
Peaks: TPeaks;
|
||||
end;
|
||||
|
||||
var
|
||||
{ Запомненные пики: путь к исходнику — объект с данными. }
|
||||
FCache: TStringList = nil;
|
||||
|
||||
function PeaksPath(const ACacheDir, ASourcePath: string): string;
|
||||
begin
|
||||
Result := IncludeTrailingPathDelimiter(ACacheDir) + 'peaks' + PathDelim +
|
||||
MD5Print(MD5String(ASourcePath)) + '.pk';
|
||||
end;
|
||||
|
||||
function BuildPeaks(const ASourcePath, ATargetPath: string;
|
||||
ACancel: TProcCancelQuery; out AError: string): Boolean;
|
||||
var
|
||||
RawPath: string;
|
||||
Res: TProcResult;
|
||||
Src: TFileStream;
|
||||
Dst: TFileStream;
|
||||
Buffer: array[0..ReadChunk - 1] of SmallInt;
|
||||
Got, I, Count: Integer;
|
||||
Acc, Peak: Integer;
|
||||
InPeak: Integer;
|
||||
Peaks: array of Byte;
|
||||
PeakCount: Integer;
|
||||
Rate: Integer;
|
||||
V: SmallInt;
|
||||
|
||||
procedure PushPeak(AValue: Integer);
|
||||
begin
|
||||
if PeakCount >= Length(Peaks) then
|
||||
SetLength(Peaks, Length(Peaks) * 2 + 1024);
|
||||
{ Отсчёт занимает 15 бит со знаком, а в байте помещается 8. Делим на
|
||||
129, а не на 128: иначе полная амплитуда дала бы 256. }
|
||||
Peaks[PeakCount] := AValue div 129;
|
||||
Inc(PeakCount);
|
||||
end;
|
||||
|
||||
begin
|
||||
Result := False;
|
||||
AError := '';
|
||||
Rate := PeaksPerSecond;
|
||||
|
||||
if not ForceDirectories(ExtractFilePath(ATargetPath)) then
|
||||
begin
|
||||
AError := 'Не удалось создать каталог для пиков.';
|
||||
Exit;
|
||||
end;
|
||||
|
||||
RawPath := ATargetPath + '.raw';
|
||||
|
||||
{ Один звуковой поток, одна дорожка, восемь килогерц. Видео отбрасываем
|
||||
целиком: оно здесь только мешает и тратит время. }
|
||||
Res := RunProcess(FfmpegExe,
|
||||
['-hide_banner', '-nostdin', '-loglevel', 'error', '-y',
|
||||
'-i', ASourcePath, '-vn', '-map', '0:a:0',
|
||||
'-ac', '1', '-ar', IntToStr(DecodeRate),
|
||||
'-f', 's16le', RawPath],
|
||||
nil, nil, ACancel);
|
||||
|
||||
if not Res.Started then
|
||||
begin
|
||||
AError := 'Не удалось запустить ffmpeg: ' + Res.ErrorText;
|
||||
Exit;
|
||||
end;
|
||||
if Res.Cancelled then
|
||||
begin
|
||||
SysUtils.DeleteFile(RawPath);
|
||||
Exit;
|
||||
end;
|
||||
if (Res.ExitCode <> 0) or (not FileExists(RawPath)) then
|
||||
begin
|
||||
{ Обычная причина — в файле нет звука. Это не беда, просто рисовать
|
||||
нечего. }
|
||||
AError := 'Звук не разобран (код ' + IntToStr(Res.ExitCode) + ').';
|
||||
SysUtils.DeleteFile(RawPath);
|
||||
Exit;
|
||||
end;
|
||||
|
||||
Peaks := nil;
|
||||
PeakCount := 0;
|
||||
InPeak := 0;
|
||||
Peak := 0;
|
||||
|
||||
try
|
||||
Src := TFileStream.Create(RawPath, fmOpenRead or fmShareDenyNone);
|
||||
try
|
||||
repeat
|
||||
Got := Src.Read(Buffer, SizeOf(Buffer)) div SizeOf(SmallInt);
|
||||
if Got <= 0 then
|
||||
Break;
|
||||
for I := 0 to Got - 1 do
|
||||
begin
|
||||
V := Buffer[I];
|
||||
if V < 0 then
|
||||
Acc := -Integer(V)
|
||||
else
|
||||
Acc := V;
|
||||
if Acc > Peak then
|
||||
Peak := Acc;
|
||||
Inc(InPeak);
|
||||
if InPeak >= SamplesPerPeak then
|
||||
begin
|
||||
PushPeak(Peak);
|
||||
Peak := 0;
|
||||
InPeak := 0;
|
||||
end;
|
||||
end;
|
||||
until False;
|
||||
|
||||
{ Хвост короче полного интервала тоже показываем. }
|
||||
if InPeak > 0 then
|
||||
PushPeak(Peak);
|
||||
finally
|
||||
Src.Free;
|
||||
end;
|
||||
|
||||
Dst := TFileStream.Create(ATargetPath, fmCreate);
|
||||
try
|
||||
Dst.WriteBuffer(PeaksMagic[1], 4);
|
||||
Count := PeaksVersion;
|
||||
Dst.WriteBuffer(Count, SizeOf(Count));
|
||||
Dst.WriteBuffer(Rate, SizeOf(Rate));
|
||||
Dst.WriteBuffer(PeakCount, SizeOf(PeakCount));
|
||||
if PeakCount > 0 then
|
||||
Dst.WriteBuffer(Peaks[0], PeakCount);
|
||||
finally
|
||||
Dst.Free;
|
||||
end;
|
||||
|
||||
Result := True;
|
||||
except
|
||||
on E: Exception do
|
||||
AError := E.Message;
|
||||
end;
|
||||
|
||||
SysUtils.DeleteFile(RawPath);
|
||||
end;
|
||||
|
||||
function LoadPeaks(const APath: string; out APeaks: TPeaks): Boolean;
|
||||
var
|
||||
Src: TFileStream;
|
||||
Sig: array[0..3] of AnsiChar;
|
||||
Version, Rate, Count: Integer;
|
||||
begin
|
||||
Result := False;
|
||||
APeaks := Default(TPeaks);
|
||||
if not FileExists(APath) then
|
||||
Exit;
|
||||
|
||||
try
|
||||
Src := TFileStream.Create(APath, fmOpenRead or fmShareDenyNone);
|
||||
try
|
||||
if Src.Size < 16 then
|
||||
Exit;
|
||||
Src.ReadBuffer(Sig, 4);
|
||||
if (Sig[0] <> 'N') or (Sig[1] <> 'L') or (Sig[2] <> 'V') or
|
||||
(Sig[3] <> 'W') then
|
||||
Exit;
|
||||
Src.ReadBuffer(Version, SizeOf(Version));
|
||||
if Version <> PeaksVersion then
|
||||
Exit;
|
||||
Src.ReadBuffer(Rate, SizeOf(Rate));
|
||||
Src.ReadBuffer(Count, SizeOf(Count));
|
||||
if (Count < 0) or (Count > Src.Size - Src.Position) then
|
||||
Exit;
|
||||
|
||||
SetLength(APeaks.Data, Count);
|
||||
if Count > 0 then
|
||||
Src.ReadBuffer(APeaks.Data[0], Count);
|
||||
APeaks.Rate := Rate;
|
||||
APeaks.Valid := True;
|
||||
Result := True;
|
||||
finally
|
||||
Src.Free;
|
||||
end;
|
||||
except
|
||||
{ Испорченный кэш — не беда: осциллограммы просто не будет. }
|
||||
APeaks := Default(TPeaks);
|
||||
Result := False;
|
||||
end;
|
||||
end;
|
||||
|
||||
function CachedPeaks(const ACacheDir, ASourcePath: string;
|
||||
out APeaks: TPeaks): Boolean;
|
||||
var
|
||||
Idx: Integer;
|
||||
Item: TPeaksCacheItem;
|
||||
Path: string;
|
||||
begin
|
||||
APeaks := Default(TPeaks);
|
||||
if FCache = nil then
|
||||
begin
|
||||
FCache := TStringList.Create;
|
||||
FCache.Sorted := True;
|
||||
FCache.OwnsObjects := True;
|
||||
end;
|
||||
|
||||
Idx := FCache.IndexOf(ASourcePath);
|
||||
if Idx >= 0 then
|
||||
begin
|
||||
APeaks := TPeaksCacheItem(FCache.Objects[Idx]).Peaks;
|
||||
Exit(APeaks.Valid);
|
||||
end;
|
||||
|
||||
Path := PeaksPath(ACacheDir, ASourcePath);
|
||||
Item := TPeaksCacheItem.Create;
|
||||
{ Неудачу тоже запоминаем: иначе при каждой отрисовке мы бы стучались
|
||||
на диск за файлом, которого нет. }
|
||||
LoadPeaks(Path, Item.Peaks);
|
||||
FCache.AddObject(ASourcePath, Item);
|
||||
APeaks := Item.Peaks;
|
||||
Result := APeaks.Valid;
|
||||
end;
|
||||
|
||||
procedure ForgetPeaks;
|
||||
begin
|
||||
FreeAndNil(FCache);
|
||||
end;
|
||||
|
||||
{ TPeaksThread }
|
||||
|
||||
constructor TPeaksThread.Create(const ARequests: TPeaksRequests);
|
||||
var
|
||||
I: Integer;
|
||||
begin
|
||||
inherited Create(True);
|
||||
{ Освобождает поток окно, а не он сам: окно обращается к нему и после
|
||||
завершения, чтобы понять, надо ли начинать заново. }
|
||||
FreeOnTerminate := False;
|
||||
FDone := 0;
|
||||
SetLength(FRequests, Length(ARequests));
|
||||
for I := 0 to High(ARequests) do
|
||||
FRequests[I] := ARequests[I];
|
||||
end;
|
||||
|
||||
function TPeaksThread.Total: Integer;
|
||||
begin
|
||||
Result := Length(FRequests);
|
||||
end;
|
||||
|
||||
function TPeaksThread.CancelQuery: Boolean;
|
||||
begin
|
||||
Result := Terminated;
|
||||
end;
|
||||
|
||||
procedure TPeaksThread.SyncReady;
|
||||
begin
|
||||
if Assigned(FOnReady) and (FCurrent >= 0) and
|
||||
(FCurrent <= High(FRequests)) then
|
||||
FOnReady(Self, FRequests[FCurrent].SourcePath);
|
||||
end;
|
||||
|
||||
procedure TPeaksThread.Execute;
|
||||
var
|
||||
I: Integer;
|
||||
Err: string;
|
||||
begin
|
||||
for I := 0 to High(FRequests) do
|
||||
begin
|
||||
if Terminated then
|
||||
Break;
|
||||
FCurrent := I;
|
||||
if FileExists(FRequests[I].TargetPath) then
|
||||
begin
|
||||
Synchronize(@SyncReady);
|
||||
FDone := I + 1;
|
||||
Continue;
|
||||
end;
|
||||
if BuildPeaks(FRequests[I].SourcePath, FRequests[I].TargetPath,
|
||||
@CancelQuery, Err) then
|
||||
Synchronize(@SyncReady);
|
||||
FDone := I + 1;
|
||||
end;
|
||||
end;
|
||||
|
||||
finalization
|
||||
FreeAndNil(FCache);
|
||||
|
||||
end.
|
||||
37
tests/lfmcheck/lfmcheck.lpi
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<CONFIG>
|
||||
<ProjectOptions>
|
||||
<Version Value="12"/>
|
||||
<General>
|
||||
<Flags>
|
||||
<CompatibilityMode Value="True"/>
|
||||
</Flags>
|
||||
<SessionStorage Value="InProjectDir"/>
|
||||
<Title Value="lfmcheck"/>
|
||||
</General>
|
||||
<BuildModes Count="1">
|
||||
<Item1 Name="Default" Default="True"/>
|
||||
</BuildModes>
|
||||
<RequiredPackages Count="1">
|
||||
<Item1>
|
||||
<PackageName Value="LCL"/>
|
||||
</Item1>
|
||||
</RequiredPackages>
|
||||
<Units Count="1">
|
||||
<Unit0>
|
||||
<Filename Value="lfmcheck.lpr"/>
|
||||
<IsPartOfProject Value="True"/>
|
||||
</Unit0>
|
||||
</Units>
|
||||
</ProjectOptions>
|
||||
<CompilerOptions>
|
||||
<Version Value="11"/>
|
||||
<Target>
|
||||
<Filename Value="lfmcheck"/>
|
||||
</Target>
|
||||
<SearchPaths>
|
||||
<OtherUnitFiles Value="../../src"/>
|
||||
<UnitOutputDirectory Value="units"/>
|
||||
</SearchPaths>
|
||||
</CompilerOptions>
|
||||
</CONFIG>
|
||||
287
tests/lfmcheck/lfmcheck.lpr
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
{
|
||||
Проверка того, что свойства, записанные в файле формы, у наших
|
||||
элементов действительно есть.
|
||||
|
||||
Зачем отдельно. Файл формы разбирается при запуске, а не при сборке:
|
||||
опечатка в имени свойства или в значении перечисления даёт не ошибку
|
||||
компиляции, а падение окна на старте. Здесь то же самое проверяется без
|
||||
окна — по сведениям о типах.
|
||||
}
|
||||
program lfmcheck;
|
||||
|
||||
{$mode objfpc}{$H+}
|
||||
|
||||
uses
|
||||
{ Interfaces нужен, чтобы связалась вся LCL и заработала загрузка
|
||||
файлов форм. }
|
||||
Interfaces, Forms, Classes, SysUtils, Math, TypInfo, uTheme,
|
||||
uMediaProbe, uProject, uTransform, uTimelineCtrl,
|
||||
uPanCropForm, uDepsForm, uMismatchForm, uProgressForm, uProjectInfoForm,
|
||||
uRemuxForm;
|
||||
|
||||
var
|
||||
Failed: Integer = 0;
|
||||
|
||||
procedure CheckProp(AClass: TClass; const AName: string);
|
||||
begin
|
||||
if GetPropInfo(AClass, AName) <> nil then
|
||||
WriteLn(' ok ', AClass.ClassName, '.', AName)
|
||||
else
|
||||
begin
|
||||
WriteLn(' ПЛОХО ', AClass.ClassName, '.', AName, ' — свойства нет');
|
||||
Inc(Failed);
|
||||
end;
|
||||
end;
|
||||
|
||||
procedure CheckEnum(ATypeInfo: PTypeInfo; const AName: string);
|
||||
begin
|
||||
if GetEnumValue(ATypeInfo, AName) >= 0 then
|
||||
WriteLn(' ok значение ', AName)
|
||||
else
|
||||
begin
|
||||
WriteLn(' ПЛОХО значение ', AName, ' — такого нет');
|
||||
Inc(Failed);
|
||||
end;
|
||||
end;
|
||||
|
||||
{ Создаёт окно и тут же убирает.
|
||||
|
||||
Смысл в самом создании: файл формы разбирается именно тогда. Элемент,
|
||||
который есть в файле формы и которого нет в классе, или обработчик с
|
||||
опечаткой в имени — всё это здесь и вскрывается, а иначе вскрылось бы у
|
||||
человека при открытии окна.
|
||||
|
||||
Главное окно не проверяется: оно при создании читает настройки, строит
|
||||
меню и заводит плеер — это уже не проверка формы, а запуск программы. }
|
||||
procedure CheckForm(AClass: TFormClass);
|
||||
var
|
||||
F: TForm;
|
||||
begin
|
||||
try
|
||||
F := AClass.Create(nil);
|
||||
try
|
||||
WriteLn(' ok ', AClass.ClassName, ' — форма загружена');
|
||||
finally
|
||||
F.Free;
|
||||
end;
|
||||
except
|
||||
on E: Exception do
|
||||
begin
|
||||
WriteLn(' ПЛОХО ', AClass.ClassName, ': ', E.Message);
|
||||
Inc(Failed);
|
||||
end;
|
||||
end;
|
||||
end;
|
||||
|
||||
procedure CheckTrue(const AName: string; AValue: Boolean);
|
||||
begin
|
||||
if AValue then
|
||||
WriteLn(' ok ', AName)
|
||||
else
|
||||
begin
|
||||
WriteLn(' ПЛОХО ', AName);
|
||||
Inc(Failed);
|
||||
end;
|
||||
end;
|
||||
|
||||
{ Прилипание курсора воспроизведения к границам клипов.
|
||||
|
||||
Проверяется числами, а не мышью: расчёт открыт наружу именно ради
|
||||
этого. Мышью пришлось бы поднимать окно, а окно в проверках — это уже
|
||||
не проверка расчёта, а проверка окружения. }
|
||||
procedure CheckPlayheadSnap;
|
||||
var
|
||||
Proj: TProject;
|
||||
Info: TMediaInfo;
|
||||
Media: TMediaItem;
|
||||
Timeline: TTimelineControl;
|
||||
begin
|
||||
WriteLn;
|
||||
WriteLn('Прилипание курсора к границам клипов');
|
||||
WriteLn;
|
||||
|
||||
Info := Default(TMediaInfo);
|
||||
Info.Valid := True;
|
||||
Info.Kind := mkVideo;
|
||||
Info.Duration := 600;
|
||||
Info.HasVideo := True;
|
||||
Info.VideoCodec := 'hevc';
|
||||
Info.Width := 1920;
|
||||
Info.Height := 1080;
|
||||
Info.FpsNum := 30;
|
||||
Info.FpsDen := 1;
|
||||
Info.PixFmt := 'yuv420p';
|
||||
|
||||
Proj := TProject.Create;
|
||||
Timeline := TTimelineControl.Create(nil);
|
||||
try
|
||||
Proj.Clear;
|
||||
Proj.AddTrack('V1');
|
||||
Media := Proj.AddMedia('/несуществующий/видео.mp4', Info);
|
||||
{ Два куска встык: границы на 0, 1.5 и 3. }
|
||||
Proj.AddClip(Proj.Tracks[0], Media, 0, 1.5, 0);
|
||||
Proj.AddClip(Proj.Tracks[0], Media, 10, 1.5, 1.5);
|
||||
|
||||
Timeline.Project := Proj;
|
||||
{ Сто точек в секунду: порог прилипания в восемь точек — это 0.08 с. }
|
||||
Timeline.PixelsPerSecond := 100;
|
||||
Timeline.PlayheadSnap := True;
|
||||
|
||||
CheckTrue('стык рядом — притянуло',
|
||||
Abs(Timeline.SnapPlayhead(1.53) - 1.5) < 0.0001);
|
||||
CheckTrue('с другой стороны — тоже',
|
||||
Abs(Timeline.SnapPlayhead(1.47) - 1.5) < 0.0001);
|
||||
CheckTrue('далеко от границ — не тронуло',
|
||||
Abs(Timeline.SnapPlayhead(0.7) - 0.7) < 0.0001);
|
||||
CheckTrue('начало монтажа притягивает',
|
||||
Abs(Timeline.SnapPlayhead(0.05)) < 0.0001);
|
||||
CheckTrue('конец последнего клипа притягивает',
|
||||
Abs(Timeline.SnapPlayhead(2.96) - 3) < 0.0001);
|
||||
|
||||
{ На мелком масштабе порог в точках даёт больший запас по времени. }
|
||||
Timeline.PixelsPerSecond := 10;
|
||||
CheckTrue('масштаб мельче — притягивает дальше',
|
||||
Abs(Timeline.SnapPlayhead(1.7) - 1.5) < 0.0001);
|
||||
|
||||
Timeline.PixelsPerSecond := 100;
|
||||
Timeline.PlayheadSnap := False;
|
||||
CheckTrue('выключено — не притягивает',
|
||||
Abs(Timeline.SnapPlayhead(1.53) - 1.53) < 0.0001);
|
||||
finally
|
||||
Timeline.Free;
|
||||
Proj.Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
{ Рез не должен терять настройки клипа.
|
||||
|
||||
Половина клипа после реза — тот же клип, и цветовой ключ, кадрирование,
|
||||
прозрачность, громкость и текст должны остаться при обеих половинах.
|
||||
Однажды не остались: у хвоста пропадал ключ, клип переставал быть
|
||||
наложением и закрывал собой всё, что под ним. }
|
||||
procedure CheckSplitKeepsSettings;
|
||||
var
|
||||
Proj: TProject;
|
||||
Info: TMediaInfo;
|
||||
Media: TMediaItem;
|
||||
Timeline: TTimelineControl;
|
||||
Head, Tail, Cl: TClip;
|
||||
Key: TColorKey;
|
||||
Tr: TMediaTransform;
|
||||
I: Integer;
|
||||
begin
|
||||
WriteLn;
|
||||
WriteLn('Рез сохраняет настройки клипа');
|
||||
WriteLn;
|
||||
|
||||
Info := Default(TMediaInfo);
|
||||
Info.Valid := True;
|
||||
Info.Kind := mkVideo;
|
||||
Info.Duration := 600;
|
||||
Info.HasVideo := True;
|
||||
Info.VideoCodec := 'hevc';
|
||||
Info.Width := 1920;
|
||||
Info.Height := 1080;
|
||||
Info.FpsNum := 30;
|
||||
Info.FpsDen := 1;
|
||||
Info.PixFmt := 'yuv420p';
|
||||
|
||||
Proj := TProject.Create;
|
||||
Timeline := TTimelineControl.Create(nil);
|
||||
try
|
||||
Proj.Clear;
|
||||
Proj.AddTrack('V1');
|
||||
Media := Proj.AddMedia('/несуществующий/видео.mp4', Info);
|
||||
Cl := Proj.AddClip(Proj.Tracks[0], Media, 0, 10, 0);
|
||||
|
||||
Key := DefaultColorKey;
|
||||
Key.Enabled := True;
|
||||
Key.Similarity := 22;
|
||||
Cl.ColorKey := Key;
|
||||
Cl.Opacity := 0.5;
|
||||
Cl.Volume := 0.3;
|
||||
Tr := Cl.Transform;
|
||||
Tr.Zoom := 0.4;
|
||||
Tr.OffsetX := 0.7;
|
||||
Cl.Transform := Tr;
|
||||
Cl.GroupId := 'g1';
|
||||
|
||||
Timeline.Project := Proj;
|
||||
Timeline.PixelsPerSecond := 100;
|
||||
Timeline.Playhead := 4;
|
||||
Timeline.SplitAtPlayhead(False, False);
|
||||
|
||||
CheckTrue('клипов стало два', Proj.Tracks[0].Count = 2);
|
||||
if Proj.Tracks[0].Count <> 2 then
|
||||
Exit;
|
||||
|
||||
Head := nil;
|
||||
Tail := nil;
|
||||
for I := 0 to Proj.Tracks[0].Count - 1 do
|
||||
if Proj.Tracks[0][I].TlStart < 0.001 then
|
||||
Head := Proj.Tracks[0][I]
|
||||
else
|
||||
Tail := Proj.Tracks[0][I];
|
||||
CheckTrue('обе половины нашлись', (Head <> nil) and (Tail <> nil));
|
||||
if (Head = nil) or (Tail = nil) then
|
||||
Exit;
|
||||
|
||||
CheckTrue('ключ остался у хвоста', Tail.ColorKey.Enabled);
|
||||
CheckTrue('и с тем же цветом', Tail.ColorKey.Color = Key.Color);
|
||||
CheckTrue('и с тем же допуском', Tail.ColorKey.Similarity = 22);
|
||||
CheckTrue('прозрачность осталась', Abs(Tail.Opacity - 0.5) < 0.001);
|
||||
CheckTrue('громкость осталась', Abs(Tail.Volume - 0.3) < 0.001);
|
||||
CheckTrue('кадрирование осталось',
|
||||
(Abs(Tail.Transform.Zoom - 0.4) < 0.001) and
|
||||
(Abs(Tail.Transform.OffsetX - 0.7) < 0.001));
|
||||
CheckTrue('ключ остался и у головы', Head.ColorKey.Enabled);
|
||||
{ Связки разные, и обе не пустые: иначе удаление одной половины
|
||||
уносило бы вторую. }
|
||||
CheckTrue('связка у половин разная и не пустая',
|
||||
(Head.GroupId <> Tail.GroupId) and (Head.GroupId <> '') and
|
||||
(Tail.GroupId <> ''));
|
||||
CheckTrue('появление у хвоста снято', Abs(Tail.FadeIn) < 0.001);
|
||||
finally
|
||||
Timeline.Free;
|
||||
Proj.Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
begin
|
||||
WriteLn('Свойства из файлов форм');
|
||||
WriteLn;
|
||||
|
||||
CheckProp(TThemedButton, 'Icon');
|
||||
CheckProp(TThemedButton, 'Down');
|
||||
CheckProp(TThemedButton, 'Arrow');
|
||||
CheckProp(TThemedButton, 'Caption');
|
||||
CheckProp(TThemedButton, 'ShowHint');
|
||||
|
||||
CheckEnum(TypeInfo(TThemedIcon), 'tiSnap');
|
||||
CheckEnum(TypeInfo(TThemedIcon), 'tiRipple');
|
||||
CheckEnum(TypeInfo(TThemedIcon), 'tiLog');
|
||||
CheckEnum(TypeInfo(TThemedIcon), 'tiNone');
|
||||
|
||||
WriteLn;
|
||||
WriteLn('Загрузка файлов форм');
|
||||
WriteLn;
|
||||
Application.Initialize;
|
||||
CheckForm(TPanCropForm);
|
||||
CheckForm(TDepsForm);
|
||||
CheckForm(TMismatchForm);
|
||||
CheckForm(TProgressForm);
|
||||
CheckForm(TProjectInfoForm);
|
||||
CheckForm(TRemuxForm);
|
||||
|
||||
CheckPlayheadSnap;
|
||||
CheckSplitKeepsSettings;
|
||||
|
||||
WriteLn;
|
||||
if Failed = 0 then
|
||||
WriteLn('ВЕРДИКТ: все проверки пройдены')
|
||||
else
|
||||
begin
|
||||
WriteLn('ВЕРДИКТ: не пройдено проверок: ', Failed);
|
||||
Halt(1);
|
||||
end;
|
||||
end.
|
||||
|
|
@ -23,6 +23,16 @@
|
|||
render <проект.nlve> <выход>
|
||||
уплотить проект, создать вставки, собрать ролик
|
||||
и сверить число кадров
|
||||
subs <проект.nlve>
|
||||
собрать разметку субтитров из текстовых клипов
|
||||
и напечатать её
|
||||
preview <проект.nlve>
|
||||
собрать список кусков так же, как это делает
|
||||
предпросмотр: с готовыми наложениями вместо
|
||||
участков с прозрачностью и переходами
|
||||
audio <проект.nlve>
|
||||
разложить звуковые дорожки проекта на куски и
|
||||
напечатать их вместе со строками edl://
|
||||
buildcut <проект.nlve> <видео>
|
||||
проект из одного файла с точным резом в заведомо
|
||||
неопорной точке
|
||||
|
|
@ -37,7 +47,7 @@ uses
|
|||
{$ENDIF}
|
||||
Classes, SysUtils, DateUtils, Math, StrUtils,
|
||||
uAppTypes, uEdl, uKeyframeIndex, uFfmpegCmd, uExport, uRemuxQueue,
|
||||
uMediaProbe, uProject, uFlatten, uGenerate;
|
||||
uMediaProbe, uProject, uFlatten, uGenerate, uSubtitles, uTransform;
|
||||
|
||||
procedure PrintUsage;
|
||||
begin
|
||||
|
|
@ -306,8 +316,9 @@ begin
|
|||
WriteLn('остановка через: ', SecondsToStr(CancelAfter), ' с');
|
||||
WriteLn;
|
||||
|
||||
Thread := TExportThread.Create(Segments, ParamStr(FirstArg - 1), True,
|
||||
Default(TMediaInfo));
|
||||
{ Проверка вырезания и склейки картинки: звук здесь не собирается. }
|
||||
Thread := TExportThread.Create(Segments, nil, ParamStr(FirstArg - 1), True,
|
||||
Default(TMediaInfo), Default(TEncodeProfile));
|
||||
Watcher := TExportWatcher.Create(Thread);
|
||||
try
|
||||
Thread.OnProgress := @Watcher.Progress;
|
||||
|
|
@ -550,8 +561,12 @@ procedure CmdFlatTest;
|
|||
var
|
||||
Proj: TProject;
|
||||
Media: TMediaItem;
|
||||
Segs: TFlatSegmentArray;
|
||||
Segs, AudioSegs: TFlatSegmentArray;
|
||||
Overlays: TPreviewOverlays;
|
||||
Missing: TGenRequests;
|
||||
Clip: TClip;
|
||||
Key: TColorKey;
|
||||
Tr: TMediaTransform;
|
||||
TempFile, Err: string;
|
||||
begin
|
||||
WriteLn('Проверка уплощения монтажа');
|
||||
|
|
@ -563,7 +578,7 @@ begin
|
|||
try
|
||||
Proj.AddClip(Proj.Tracks[0], Media, 10, 5, 0);
|
||||
Proj.AddClip(Proj.Tracks[0], Media, 15, 5, 5);
|
||||
FlattenProject(Proj, Segs, Missing);
|
||||
FlattenStream(Proj, tkVideo, 0, Segs, Missing);
|
||||
DumpSegments(Segs);
|
||||
Check(Length(Segs) = 1, 'склеились в один кусок');
|
||||
if Length(Segs) = 1 then
|
||||
|
|
@ -582,7 +597,7 @@ begin
|
|||
try
|
||||
Proj.AddClip(Proj.Tracks[0], Media, 100, 5, 0);
|
||||
Proj.AddClip(Proj.Tracks[0], Media, 200, 5, 5);
|
||||
FlattenProject(Proj, Segs, Missing);
|
||||
FlattenStream(Proj, tkVideo, 0, Segs, Missing);
|
||||
DumpSegments(Segs);
|
||||
Check(Length(Segs) = 2, 'остались двумя кусками');
|
||||
finally
|
||||
|
|
@ -597,7 +612,7 @@ begin
|
|||
{ Дорожка 0 — верхняя, она побеждает. }
|
||||
Proj.AddClip(Proj.Tracks[1], Media, 0, 30, 0);
|
||||
Proj.AddClip(Proj.Tracks[0], Media, 100, 10, 10);
|
||||
FlattenProject(Proj, Segs, Missing);
|
||||
FlattenStream(Proj, tkVideo, 0, Segs, Missing);
|
||||
DumpSegments(Segs);
|
||||
Check(Length(Segs) = 3, 'получилось три куска');
|
||||
if Length(Segs) = 3 then
|
||||
|
|
@ -621,7 +636,7 @@ begin
|
|||
try
|
||||
Proj.AddClip(Proj.Tracks[0], Media, 0, 5, 3);
|
||||
Proj.AddClip(Proj.Tracks[0], Media, 50, 5, 12);
|
||||
FlattenProject(Proj, Segs, Missing);
|
||||
FlattenStream(Proj, tkVideo, 0, Segs, Missing);
|
||||
DumpSegments(Segs);
|
||||
Check(Length(Segs) = 4, 'четыре куска: чёрное, видео, чёрное, видео');
|
||||
if Length(Segs) = 4 then
|
||||
|
|
@ -651,7 +666,7 @@ begin
|
|||
try
|
||||
Proj.AddClip(Proj.Tracks[1], Media, 0, 10, 0);
|
||||
Proj.AddClip(Proj.Tracks[0], Media, 200, 10, 0);
|
||||
FlattenProject(Proj, Segs, Missing);
|
||||
FlattenStream(Proj, tkVideo, 0, Segs, Missing);
|
||||
DumpSegments(Segs);
|
||||
Check(Length(Segs) = 1, 'остался один кусок');
|
||||
if Length(Segs) = 1 then
|
||||
|
|
@ -668,7 +683,7 @@ begin
|
|||
Proj.AddClip(Proj.Tracks[1], Media, 0, 10, 0);
|
||||
Proj.AddClip(Proj.Tracks[0], Media, 200, 10, 0);
|
||||
Proj.Tracks[0].Muted := True;
|
||||
FlattenProject(Proj, Segs, Missing);
|
||||
FlattenStream(Proj, tkVideo, 0, Segs, Missing);
|
||||
DumpSegments(Segs);
|
||||
Check(Length(Segs) = 1, 'остался один кусок');
|
||||
if Length(Segs) = 1 then
|
||||
|
|
@ -688,7 +703,7 @@ begin
|
|||
Proj.Tracks[0][0].EdgeIn := edPrecise;
|
||||
Proj.RippleMode := rmAllTracks;
|
||||
Proj.RippleEnabled := True;
|
||||
FlattenProject(Proj, Segs, Missing);
|
||||
FlattenStream(Proj, tkVideo, 0, Segs, Missing);
|
||||
Check(Length(Segs) = 3, 'до записи три куска');
|
||||
Check(Proj.SaveToFile(TempFile, Err), 'записан: ' + Err);
|
||||
finally
|
||||
|
|
@ -698,13 +713,17 @@ begin
|
|||
Proj := TProject.Create;
|
||||
try
|
||||
Check(Proj.LoadFromFile(TempFile, Err), 'прочитан: ' + Err);
|
||||
Check(Proj.TrackCount = 2, 'дорожек снова две');
|
||||
{ Четыре: две картинки и две звука. Звуковые заводятся сразу, чтобы
|
||||
каждому клипу картинки было куда положить парный звук. }
|
||||
Check(Proj.TrackCount = 4, 'дорожек снова четыре');
|
||||
Check(Proj.TrackCountOf(tkVideo) = 2, 'из них две для картинки');
|
||||
Check(Proj.TrackCountOf(tkAudio) = 2, 'и две для звука');
|
||||
Check(Proj.PoolCount = 1, 'файл в списке один');
|
||||
Check(Proj.RippleMode = rmAllTracks, 'режим схлопывания сохранился');
|
||||
Check(Proj.RippleEnabled, 'состояние схлопывания сохранилось');
|
||||
if Proj.TrackCount = 2 then
|
||||
Check(Proj.Tracks[0][0].EdgeIn = edPrecise, 'признак точного реза сохранился');
|
||||
FlattenProject(Proj, Segs, Missing);
|
||||
FlattenStream(Proj, tkVideo, 0, Segs, Missing);
|
||||
DumpSegments(Segs);
|
||||
Check(Length(Segs) = 3, 'после чтения снова три куска');
|
||||
if Length(Segs) = 3 then
|
||||
|
|
@ -715,6 +734,185 @@ begin
|
|||
DeleteFile(TempFile);
|
||||
WriteLn;
|
||||
|
||||
{ --- 8. Оверлей в предпросмотре не закрывает нижний клип --- }
|
||||
WriteLn(' 8. Клип с ключом поверх: основа и оверлей врозь');
|
||||
Proj := MakeProject(2, Media);
|
||||
try
|
||||
Proj.Master := Media.Info;
|
||||
{ Нижняя дорожка — обычный кусок, верхняя — тот же файл с выбитым
|
||||
цветом во всю длину. Так выглядит компас или карта поверх ролика. }
|
||||
Proj.AddClip(Proj.Tracks[1], Media, 0, 10, 0);
|
||||
Clip := Proj.AddClip(Proj.Tracks[0], Media, 100, 10, 0);
|
||||
Key := DefaultColorKey;
|
||||
Key.Enabled := True;
|
||||
Clip.ColorKey := Key;
|
||||
Tr := Clip.Transform;
|
||||
Tr.Zoom := 0.5;
|
||||
Clip.Transform := Tr;
|
||||
|
||||
{ Обычное уплощение: побеждает верхний, нижнего не видно. }
|
||||
FlattenStream(Proj, tkVideo, 0, Segs, Missing);
|
||||
Check(Length(Segs) = 1, 'при сборке кусок один');
|
||||
if Length(Segs) = 1 then
|
||||
Check(Abs(Segs[0].SrcStart - 100) < 0.001, 'и это верхний клип');
|
||||
|
||||
{ Уплощение для предпросмотра: верхний ушёл в оверлей. }
|
||||
Check(FlattenPreview(Proj, Segs, AudioSegs, Overlays, Missing),
|
||||
'уплощение для предпросмотра прошло');
|
||||
DumpSegments(Segs);
|
||||
Check(Length(Segs) = 1, 'основа из одного куска');
|
||||
if Length(Segs) = 1 then
|
||||
Check(Abs(Segs[0].SrcStart - 0) < 0.001, 'в основе виден нижний клип');
|
||||
Check(Length(Overlays) = 1, 'оверлей один');
|
||||
if Length(Overlays) = 1 then
|
||||
begin
|
||||
Check(Abs(Overlays[0].TlStart) < 0.001, 'оверлей с начала');
|
||||
Check(Abs(Overlays[0].TlEnd - 10) < 0.001, 'и до конца');
|
||||
Check(Abs(Overlays[0].SrcStart - 100) < 0.001, 'берётся с 100 с');
|
||||
Check((Overlays[0].Width = 960) and (Overlays[0].Height = 540),
|
||||
'уменьшен вдвое: ' + IntToStr(Overlays[0].Width) + 'x' +
|
||||
IntToStr(Overlays[0].Height));
|
||||
Check((Overlays[0].X = 480) and (Overlays[0].Y = 270),
|
||||
'и стоит в середине кадра: ' + IntToStr(Overlays[0].X) + ':' +
|
||||
IntToStr(Overlays[0].Y));
|
||||
end;
|
||||
finally
|
||||
Proj.Free;
|
||||
end;
|
||||
WriteLn;
|
||||
|
||||
{ --- 9. Обрезка списка кусков по отмеченному участку --- }
|
||||
WriteLn(' 9. Сборка только отмеченного участка');
|
||||
Proj := MakeProject(1, Media);
|
||||
try
|
||||
{ Три куска по 5 секунд из разных мест файла: склеиться в один они не
|
||||
могут, и границы участка приходятся на середину крайних. }
|
||||
Proj.AddClip(Proj.Tracks[0], Media, 100, 5, 0);
|
||||
Proj.AddClip(Proj.Tracks[0], Media, 200, 5, 5);
|
||||
Proj.AddClip(Proj.Tracks[0], Media, 300, 5, 10);
|
||||
FlattenStream(Proj, tkVideo, 0, Segs, Missing);
|
||||
Check(Length(Segs) = 3, 'до обрезки три куска');
|
||||
|
||||
Check(Abs(ClipSegmentsToRange(Segs, 2, 12) - 10) < 0.001,
|
||||
'осталось десять секунд');
|
||||
DumpSegments(Segs);
|
||||
Check(Length(Segs) = 3, 'куски остались все три');
|
||||
if Length(Segs) = 3 then
|
||||
begin
|
||||
Check(Abs(Segs[0].SrcStart - 102) < 0.001,
|
||||
'первый начинается на две секунды позже');
|
||||
Check(Abs(Segs[0].Duration - 3) < 0.001, 'и длится три секунды');
|
||||
Check(Abs(Segs[1].SrcStart - 200) < 0.001, 'средний не тронут');
|
||||
Check(Abs(Segs[1].Duration - 5) < 0.001, 'и целиком');
|
||||
Check(Abs(Segs[2].SrcStart - 300) < 0.001, 'последний с начала');
|
||||
Check(Abs(Segs[2].Duration - 2) < 0.001, 'и укорочен до двух секунд');
|
||||
end;
|
||||
|
||||
{ Участок целиком внутри одного куска. }
|
||||
FlattenStream(Proj, tkVideo, 0, Segs, Missing);
|
||||
Check(Abs(ClipSegmentsToRange(Segs, 6, 8) - 2) < 0.001,
|
||||
'внутри одного куска остались две секунды');
|
||||
Check(Length(Segs) = 1, 'и один кусок');
|
||||
if Length(Segs) = 1 then
|
||||
Check(Abs(Segs[0].SrcStart - 201) < 0.001, 'взят с нужного места');
|
||||
|
||||
{ Участок мимо монтажа. }
|
||||
FlattenStream(Proj, tkVideo, 0, Segs, Missing);
|
||||
Check(Abs(ClipSegmentsToRange(Segs, 100, 200)) < 0.001,
|
||||
'за концом монтажа не осталось ничего');
|
||||
Check(Length(Segs) = 0, 'и ни одного куска');
|
||||
finally
|
||||
Proj.Free;
|
||||
end;
|
||||
WriteLn;
|
||||
|
||||
{ --- 10. Клипы на дорожках чужого вида --- }
|
||||
WriteLn(' 10. Клип на дорожке не своего вида');
|
||||
Proj := MakeProject(1, Media);
|
||||
try
|
||||
Proj.EnsureAudioTracks;
|
||||
Proj.AddTrackOfKind('Т1', tkText);
|
||||
{ Картинка на текстовой дорожке — ровно то, что нашлось в живом
|
||||
проекте: клип виден на монтаже, а в ролик не попадает. }
|
||||
Clip := Proj.AddClip(Proj.Tracks[Proj.TrackCount - 1], Media, 0, 3, 10);
|
||||
Clip.Kind := ckImage;
|
||||
|
||||
Check(Proj.MisplacedClipCount = 1, 'нашёлся один такой клип');
|
||||
FlattenStream(Proj, tkVideo, 0, Segs, Missing);
|
||||
Check(Length(Segs) = 0, 'в уплощении его нет');
|
||||
|
||||
Check(Proj.FixMisplacedClips = 1, 'перенесён один клип');
|
||||
Check(Proj.MisplacedClipCount = 0, 'чужих клипов не осталось');
|
||||
FlattenStream(Proj, tkVideo, 0, Segs, Missing);
|
||||
DumpSegments(Segs);
|
||||
Check(Length(Segs) > 0, 'после переноса он попадает в уплощение');
|
||||
Clip := Proj.Tracks[0].ClipAt(11);
|
||||
Check((Clip <> nil) and (Abs(Clip.TlStart - 10) < 0.001),
|
||||
'и стоит на прежнем месте');
|
||||
finally
|
||||
Proj.Free;
|
||||
end;
|
||||
WriteLn;
|
||||
|
||||
{ --- 11. Оверлей под сплошным клипом в окне не показывается --- }
|
||||
WriteLn(' 11. Оверлей закрыт сверху сплошным клипом');
|
||||
Proj := MakeProject(3, Media);
|
||||
try
|
||||
Proj.Master := Media.Info;
|
||||
{ Снизу вверх: V3 — съёмка, V2 — оверлей с ключом на весь ролик,
|
||||
V1 — фотография поверх куска. Так и было в живом проекте. }
|
||||
Proj.AddClip(Proj.Tracks[2], Media, 0, 20, 0);
|
||||
Clip := Proj.AddClip(Proj.Tracks[1], Media, 0, 20, 0);
|
||||
Key := DefaultColorKey;
|
||||
Key.Enabled := True;
|
||||
Clip.ColorKey := Key;
|
||||
Clip := Proj.AddClip(Proj.Tracks[0], Media, 0, 5, 5);
|
||||
Clip.Kind := ckImage;
|
||||
|
||||
Check(FlattenPreview(Proj, Segs, AudioSegs, Overlays, Missing),
|
||||
'уплощение для предпросмотра прошло');
|
||||
{ Фотография лежит с 5 по 10 секунду и закрывает оверлей: показывать
|
||||
его там нельзя — в ролике его там не будет. Значит, от оверлея
|
||||
остаются два окна: до фотографии и после. }
|
||||
Check(Length(Overlays) = 2, 'оверлей показывается двумя окнами: ' +
|
||||
IntToStr(Length(Overlays)));
|
||||
if Length(Overlays) = 2 then
|
||||
begin
|
||||
Check(Abs(Overlays[0].TlStart) < 0.001, 'первое окно с начала');
|
||||
Check(Abs(Overlays[0].TlEnd - 5) < 0.001, 'и до фотографии');
|
||||
Check(Abs(Overlays[1].TlStart - 10) < 0.001, 'второе после неё');
|
||||
Check(Abs(Overlays[1].TlEnd - 20) < 0.001, 'и до конца');
|
||||
Check(Abs(Overlays[1].SrcStart - 10) < 0.001,
|
||||
'и берётся с того же места исходника');
|
||||
end;
|
||||
finally
|
||||
Proj.Free;
|
||||
end;
|
||||
WriteLn;
|
||||
|
||||
{ --- 12. Имена дорожек не повторяются --- }
|
||||
WriteLn(' 12. Имена дорожек');
|
||||
Proj := MakeProject(3, Media);
|
||||
try
|
||||
Check(Proj.UniqueTrackName('V') = 'V4', 'следующее имя V4: ' +
|
||||
Proj.UniqueTrackName('V'));
|
||||
{ Убираем среднюю: счёт дорожек снова три, но имя V3 уже занято, и
|
||||
считать их для имени нельзя. }
|
||||
Proj.RemoveTrack(1);
|
||||
Check(Proj.UniqueTrackName('V') = 'V2', 'после удаления средней — V2: ' +
|
||||
Proj.UniqueTrackName('V'));
|
||||
Proj.AddTrack(Proj.UniqueTrackName('V'));
|
||||
Check(Proj.Tracks[2].Name = 'V2', 'новая дорожка названа V2');
|
||||
|
||||
{ Одинаковые имена, доставшиеся от старого проекта, разводятся. }
|
||||
Proj.Tracks[2].Name := 'V1';
|
||||
Proj.EnsureUniqueTrackNames;
|
||||
Check(Proj.Tracks[2].Name <> 'V1', 'повтор разведён: ' + Proj.Tracks[2].Name);
|
||||
finally
|
||||
Proj.Free;
|
||||
end;
|
||||
WriteLn;
|
||||
|
||||
WriteLn;
|
||||
if FailCount = 0 then
|
||||
begin
|
||||
|
|
@ -739,6 +937,7 @@ begin
|
|||
Proj.Clear;
|
||||
Proj.AddTrack('V1');
|
||||
Proj.AddTrack('V2');
|
||||
Proj.EnsureAudioTracks;
|
||||
|
||||
if not ProbeMedia(ExpandFileName(ParamStr(3)), Info, Err) then
|
||||
begin
|
||||
|
|
@ -750,8 +949,10 @@ begin
|
|||
|
||||
{ Нижняя дорожка: два куска видео из разных мест, между ними дыра
|
||||
в две секунды, и ещё дыра в начале. }
|
||||
Proj.AddClip(Proj.Tracks[1], Video, 100, 6, 1.5);
|
||||
Proj.AddClip(Proj.Tracks[1], Video, 400, 6, 9.5);
|
||||
{ Парой: клип картинки и его звук на соответствующей звуковой
|
||||
дорожке. Так собранный проект похож на настоящий. }
|
||||
Proj.AddClipPair(Proj.Tracks[1], Video, 100, 6, 1.5);
|
||||
Proj.AddClipPair(Proj.Tracks[1], Video, 400, 6, 9.5);
|
||||
|
||||
if ParamCount >= 4 then
|
||||
begin
|
||||
|
|
@ -779,6 +980,58 @@ begin
|
|||
end;
|
||||
end;
|
||||
|
||||
{ Проект для проверки цветового ключа: нижний клип во всю длину и
|
||||
верхний с выбитым пурпурным поверх него. Собранное потом смотрим по
|
||||
точкам кадра: где был пурпурный, должно быть видно нижний клип. }
|
||||
procedure CmdKeyTest;
|
||||
var
|
||||
Proj: TProject;
|
||||
Info: TMediaInfo;
|
||||
Bottom, Top: TMediaItem;
|
||||
Clip: TClip;
|
||||
Key: TColorKey;
|
||||
Err: string;
|
||||
begin
|
||||
Proj := TProject.Create;
|
||||
try
|
||||
Proj.Clear;
|
||||
Proj.AddTrack('V1');
|
||||
Proj.AddTrack('V2');
|
||||
Proj.EnsureAudioTracks;
|
||||
|
||||
if not ProbeMedia(ExpandFileName(ParamStr(3)), Info, Err) then
|
||||
begin
|
||||
WriteLn('Не удалось прочитать нижнее видео: ', Err);
|
||||
Halt(1);
|
||||
end;
|
||||
Bottom := Proj.AddMedia(ExpandFileName(ParamStr(3)), Info);
|
||||
WriteLn('низ: ', DescribeMedia(Info));
|
||||
Proj.AddClipPair(Proj.Tracks[1], Bottom, 0, 3, 0);
|
||||
|
||||
if not ProbeMedia(ExpandFileName(ParamStr(4)), Info, Err) then
|
||||
begin
|
||||
WriteLn('Не удалось прочитать верхнее видео: ', Err);
|
||||
Halt(1);
|
||||
end;
|
||||
Top := Proj.AddMedia(ExpandFileName(ParamStr(4)), Info);
|
||||
WriteLn('верх: ', DescribeMedia(Info));
|
||||
Clip := Proj.AddClip(Proj.Tracks[0], Top, 0, 3, 0);
|
||||
|
||||
Key := DefaultColorKey;
|
||||
Key.Enabled := True;
|
||||
Clip.ColorKey := Key;
|
||||
|
||||
if not Proj.SaveToFile(ExpandFileName(ParamStr(2)), Err) then
|
||||
begin
|
||||
WriteLn('Не удалось записать проект: ', Err);
|
||||
Halt(1);
|
||||
end;
|
||||
WriteLn('проект записан: ', Proj.FileName);
|
||||
finally
|
||||
Proj.Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
procedure CmdBuildCut;
|
||||
var
|
||||
Proj: TProject;
|
||||
|
|
@ -850,12 +1103,208 @@ begin
|
|||
WriteLn(' ', FThread.PendingLog);
|
||||
end;
|
||||
|
||||
procedure CmdRender;
|
||||
|
||||
{ Раскладка звуковых дорожек.
|
||||
|
||||
Нужна потому, что «звука нет вот здесь» глазами не разобрать: громкость
|
||||
может быть верной, а кусок в этом месте — ссылаться не туда. Здесь
|
||||
печатается ровно то, что уходит в плеер: место на монтаже, файл, место
|
||||
в файле, длина. }
|
||||
procedure CmdAudio;
|
||||
var
|
||||
Proj: TProject;
|
||||
Segs: TFlatSegmentArray;
|
||||
Missing: TGenRequests;
|
||||
Err: string;
|
||||
I, J: Integer;
|
||||
Total, At: Double;
|
||||
Kind: string;
|
||||
begin
|
||||
Proj := TProject.Create;
|
||||
try
|
||||
if not Proj.LoadFromFile(ExpandFileName(ParamStr(2)), Err) then
|
||||
begin
|
||||
WriteLn('Не удалось прочитать проект: ', Err);
|
||||
Halt(1);
|
||||
end;
|
||||
|
||||
Total := Max(AudibleEnd(Proj, tkVideo), AudibleEnd(Proj, tkAudio));
|
||||
WriteLn('проект: ', Proj.FileName);
|
||||
WriteLn('длина слышимого: ', SecondsToStr(Total), ' с');
|
||||
WriteLn;
|
||||
|
||||
for I := 0 to Proj.TrackCount - 1 do
|
||||
begin
|
||||
if Proj.Tracks[I].Kind <> tkAudio then
|
||||
Continue;
|
||||
WriteLn('=== ', Proj.Tracks[I].Name, ' ===');
|
||||
if not Proj.TrackAudible(Proj.Tracks[I]) then
|
||||
begin
|
||||
WriteLn(' не слышна (заглушена или пуста)');
|
||||
WriteLn;
|
||||
Continue;
|
||||
end;
|
||||
if Proj.Tracks[I].Count = 0 then
|
||||
begin
|
||||
WriteLn(' клипов нет');
|
||||
WriteLn;
|
||||
Continue;
|
||||
end;
|
||||
if not FlattenAudioTrack(Proj, Proj.Tracks[I], Total, Segs, Missing) then
|
||||
begin
|
||||
WriteLn(' разложить не удалось');
|
||||
WriteLn;
|
||||
Continue;
|
||||
end;
|
||||
|
||||
At := 0;
|
||||
for J := 0 to High(Segs) do
|
||||
begin
|
||||
if Segs[J].Kind = skBlack then
|
||||
Kind := 'тишина'
|
||||
else
|
||||
Kind := 'звук ';
|
||||
WriteLn(Format(' [%2d] %s монтаж %8s..%8s длина %8s из %8s %s',
|
||||
[J, Kind, SecondsToStr(At), SecondsToStr(At + Segs[J].Duration),
|
||||
SecondsToStr(Segs[J].Duration), SecondsToStr(Segs[J].SrcStart),
|
||||
ExtractFileName(Segs[J].FilePath)]));
|
||||
if (Segs[J].Kind = skBlack) and (not FileExists(Segs[J].FilePath)) then
|
||||
WriteLn(' ВНИМАНИЕ: вставки тишины нет на диске');
|
||||
At := At + Segs[J].Duration;
|
||||
end;
|
||||
WriteLn(' всего по дорожке: ', SecondsToStr(At), ' с');
|
||||
WriteLn;
|
||||
WriteLn(' ', BuildEdlUri(Segs));
|
||||
WriteLn;
|
||||
end;
|
||||
finally
|
||||
Proj.Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
|
||||
{ Список кусков предпросмотра.
|
||||
|
||||
Отличается от сборки одним: участки с прозрачностью и переходами плеер
|
||||
показать не может, поэтому они заранее сводятся в отдельные файлы. Здесь
|
||||
видно, какие именно участки и во что превратились. }
|
||||
|
||||
{ Разметка субтитров из текстовых клипов.
|
||||
|
||||
Точного замера шрифта здесь нет — он требует холста, а холст оконного
|
||||
набора. Поэтому ширины оцениваются приблизительно; для проверки самой
|
||||
разметки этого хватает, а окно ставит точный замер. }
|
||||
procedure CmdSubs;
|
||||
var
|
||||
Proj: TProject;
|
||||
Err, Text: string;
|
||||
begin
|
||||
Proj := TProject.Create;
|
||||
try
|
||||
if not Proj.LoadFromFile(ExpandFileName(ParamStr(2)), Err) then
|
||||
begin
|
||||
WriteLn('Не удалось прочитать проект: ', Err);
|
||||
Halt(1);
|
||||
end;
|
||||
Text := BuildAss(Proj);
|
||||
if Text = '' then
|
||||
begin
|
||||
WriteLn('Текстовых клипов в проекте нет.');
|
||||
Halt(1);
|
||||
end;
|
||||
Write(Text);
|
||||
finally
|
||||
Proj.Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
procedure CmdPreview;
|
||||
var
|
||||
Proj: TProject;
|
||||
Segs, AudioSegs: TFlatSegmentArray;
|
||||
Missing: TGenRequests;
|
||||
Err: string;
|
||||
I: Integer;
|
||||
At: Double;
|
||||
Gen: TGenerateThread;
|
||||
GenWatch: TGenWatcher;
|
||||
Kind: string;
|
||||
begin
|
||||
Proj := TProject.Create;
|
||||
try
|
||||
if not Proj.LoadFromFile(ExpandFileName(ParamStr(2)), Err) then
|
||||
begin
|
||||
WriteLn('Не удалось прочитать проект: ', Err);
|
||||
Halt(1);
|
||||
end;
|
||||
|
||||
if not FlattenProject(Proj, Segs, AudioSegs, Missing) then
|
||||
begin
|
||||
WriteLn('Уплотить нечего.');
|
||||
Halt(1);
|
||||
end;
|
||||
|
||||
ResolveLayers(Proj, Segs, Missing);
|
||||
|
||||
if Length(Missing) > 0 then
|
||||
begin
|
||||
WriteLn('Не хватает вставок: ', Length(Missing), '. Делаю.');
|
||||
Gen := TGenerateThread.Create(Missing, Proj.Master);
|
||||
GenWatch := TGenWatcher.Create(Gen);
|
||||
try
|
||||
Gen.OnProgress := @GenWatch.Progress;
|
||||
Gen.OnLog := @GenWatch.LogLine;
|
||||
Gen.Start;
|
||||
while not Gen.Finished do
|
||||
CheckSynchronize(100);
|
||||
CheckSynchronize(0);
|
||||
if not Gen.Succeeded then
|
||||
begin
|
||||
WriteLn('Не вышло: ', Gen.Message);
|
||||
Halt(1);
|
||||
end;
|
||||
finally
|
||||
GenWatch.Free;
|
||||
Gen.Free;
|
||||
end;
|
||||
|
||||
{ Второй заход: теперь файлы на месте и должны подставиться. }
|
||||
Missing := nil;
|
||||
ResolveLayers(Proj, Segs, Missing);
|
||||
if Length(Missing) > 0 then
|
||||
begin
|
||||
WriteLn('Часть наложений так и не появилась.');
|
||||
Halt(1);
|
||||
end;
|
||||
end;
|
||||
|
||||
At := 0;
|
||||
for I := 0 to High(Segs) do
|
||||
begin
|
||||
if Length(Segs[I].Layers) > 0 then
|
||||
Kind := 'НАЛОЖЕНИЕ (плеер не покажет)'
|
||||
else if Segs[I].Kind = skBlack then
|
||||
Kind := 'чёрное'
|
||||
else
|
||||
Kind := 'кусок ';
|
||||
WriteLn(Format(' [%2d] %s монтаж %8s..%8s из %8s %s',
|
||||
[I, Kind, SecondsToStr(At), SecondsToStr(At + Segs[I].Duration),
|
||||
SecondsToStr(Segs[I].SrcStart), ExtractFileName(Segs[I].FilePath)]));
|
||||
At := At + Segs[I].Duration;
|
||||
end;
|
||||
WriteLn;
|
||||
WriteLn(BuildEdlUri(Segs));
|
||||
finally
|
||||
Proj.Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
procedure CmdRender;
|
||||
var
|
||||
Proj: TProject;
|
||||
Segs, AudioSegs: TFlatSegmentArray;
|
||||
Missing: TGenRequests;
|
||||
Err: string;
|
||||
Gen: TGenerateThread;
|
||||
GenWatch: TGenWatcher;
|
||||
Thread: TExportThread;
|
||||
|
|
@ -877,7 +1326,7 @@ begin
|
|||
WriteLn('длина: ', SecondsToStr(Proj.EndTime), ' с');
|
||||
WriteLn;
|
||||
|
||||
if not FlattenProject(Proj, Segs, Missing) then
|
||||
if not FlattenProject(Proj, Segs, AudioSegs, Missing) then
|
||||
begin
|
||||
WriteLn('Уплотить нечего.');
|
||||
Halt(1);
|
||||
|
|
@ -914,7 +1363,7 @@ begin
|
|||
WriteLn;
|
||||
|
||||
{ После создания уплощаем заново: теперь все файлы на месте. }
|
||||
FlattenProject(Proj, Segs, Missing);
|
||||
FlattenProject(Proj, Segs, AudioSegs, Missing);
|
||||
if Length(Missing) > 0 then
|
||||
begin
|
||||
WriteLn('Часть вставок так и не появилась.');
|
||||
|
|
@ -922,8 +1371,23 @@ begin
|
|||
end;
|
||||
end;
|
||||
|
||||
Thread := TExportThread.Create(Segs, ExpandFileName(ParamStr(3)), True,
|
||||
Proj.Master);
|
||||
{ Вжигание текста — последним, уже по окончательному списку кусков:
|
||||
повторное уплощение стёрло бы отметки о разметке. Так же устроено и
|
||||
окно сборки. }
|
||||
if (ParamCount >= 4) and (LowerCase(ParamStr(4)) = 'burn') then
|
||||
WriteLn('вжигаю текст, пересобрать придётся ',
|
||||
SecondsToStr(BurnTextIntoSegments(Proj, Segs, Proj.CacheDir)), ' с');
|
||||
|
||||
{ Куски после удачной сборки убираются — как и в самой программе.
|
||||
Словом keep их можно оставить: пригодится, когда разбираешься, из
|
||||
чего собрался ролик. }
|
||||
Thread := TExportThread.Create(Segs, AudioSegs,
|
||||
ExpandFileName(ParamStr(3)),
|
||||
(ParamCount >= 4) and (LowerCase(ParamStr(4)) = 'keep'),
|
||||
Proj.Master, Default(TEncodeProfile));
|
||||
{ Словом resume просят продолжить прерванную сборку: готовые куски
|
||||
берутся с диска, а не считаются заново. }
|
||||
Thread.Resume := (ParamCount >= 4) and (LowerCase(ParamStr(4)) = 'resume');
|
||||
Watcher := TExportWatcher.Create(Thread);
|
||||
try
|
||||
Thread.OnProgress := @Watcher.Progress;
|
||||
|
|
@ -989,8 +1453,16 @@ begin
|
|||
CmdFlatTest
|
||||
else if (Cmd = 'build') and (ParamCount >= 3) then
|
||||
CmdBuild
|
||||
else if (Cmd = 'keytest') and (ParamCount >= 4) then
|
||||
CmdKeyTest
|
||||
else if (Cmd = 'buildcut') and (ParamCount >= 3) then
|
||||
CmdBuildCut
|
||||
else if (Cmd = 'subs') and (ParamCount >= 2) then
|
||||
CmdSubs
|
||||
else if (Cmd = 'preview') and (ParamCount >= 2) then
|
||||
CmdPreview
|
||||
else if (Cmd = 'audio') and (ParamCount >= 2) then
|
||||
CmdAudio
|
||||
else if (Cmd = 'render') and (ParamCount >= 3) then
|
||||
CmdRender
|
||||
else
|
||||
|
|
|
|||
110
tests/progress/progtest.lpr
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
{
|
||||
Проверка того, как ползёт полоска выполнения.
|
||||
|
||||
Проверяется не картинка, а поведение: назад не идёт, между вехами
|
||||
замедляется, при затянувшейся работе не упирается в конец.
|
||||
|
||||
Отдельная программа, а не проверка внутри редактора: расчёт вынесен в
|
||||
uAppTypes именно затем, чтобы его можно было прогнать числами.
|
||||
}
|
||||
program progtest;
|
||||
|
||||
{$mode objfpc}{$H+}
|
||||
|
||||
uses
|
||||
SysUtils, uAppTypes;
|
||||
|
||||
const
|
||||
Step = 0.07;
|
||||
|
||||
var
|
||||
Failed: Integer = 0;
|
||||
|
||||
procedure Check(const AName: string; ACondition: Boolean);
|
||||
begin
|
||||
if ACondition then
|
||||
WriteLn(' ok ', AName)
|
||||
else
|
||||
begin
|
||||
WriteLn(' ПЛОХО ', AName);
|
||||
Inc(Failed);
|
||||
end;
|
||||
end;
|
||||
|
||||
{ Сколько шагов таймера пройдёт, пока полоска идёт от одного значения к
|
||||
другому при неизменных вехах. }
|
||||
function StepsFrom(AShown, AReal, ANext, ATarget: Double): Integer;
|
||||
var
|
||||
V: Double;
|
||||
begin
|
||||
Result := 0;
|
||||
V := AShown;
|
||||
while (V < ATarget) and (Result < 100000) do
|
||||
begin
|
||||
V := AdvanceProgress(V, AReal, ANext, Step);
|
||||
Inc(Result);
|
||||
end;
|
||||
end;
|
||||
|
||||
var
|
||||
V, Prev: Double;
|
||||
I, N1, N2: Integer;
|
||||
|
||||
begin
|
||||
WriteLn('Ход полоски выполнения');
|
||||
WriteLn;
|
||||
|
||||
{ 1. Назад не идёт никогда, даже если настоящая доля вдруг меньше.
|
||||
|
||||
Сравнение с запасом: константа в исходнике считается в расширенной
|
||||
точности, а значение хранится как Double, и точное равенство между
|
||||
ними не выполняется никогда. }
|
||||
V := AdvanceProgress(0.6, 0.2, 0.25, Step);
|
||||
Check('назад не идёт', V >= 0.6 - 1E-9);
|
||||
|
||||
{ 2. Веха достигнута — прыгаем к ней сразу. }
|
||||
V := AdvanceProgress(0.1, 0.5, 0.75, Step);
|
||||
Check('до настоящей доли догоняет сразу', V >= 0.5);
|
||||
|
||||
{ 3. Между вехами ползёт, но следующей не достигает. }
|
||||
V := 0;
|
||||
for I := 1 to 200 do
|
||||
V := AdvanceProgress(V, 0, 0.25, Step);
|
||||
Check('между вехами движется', V > 0.2);
|
||||
Check('следующую веху не переступает', V < 0.25);
|
||||
|
||||
{ 4. Замедление: вторая половина пути до вехи занимает столько же шагов,
|
||||
сколько первая. Это и значит «чем дольше, тем медленнее». }
|
||||
N1 := StepsFrom(0, 0, 1, 0.5);
|
||||
N2 := StepsFrom(0.5, 0, 1, 0.75);
|
||||
Check('вторая половина пути не быстрее первой', N2 >= N1);
|
||||
WriteLn(Format(' шагов до половины: %d, до трёх четвертей: %d',
|
||||
[N1, N1 + N2]));
|
||||
|
||||
{ 5. В конец не упирается, сколько ни жди. }
|
||||
V := 0;
|
||||
for I := 1 to 100000 do
|
||||
V := AdvanceProgress(V, 0, 1, Step);
|
||||
Check('полной не становится без настоящего конца', V <= 0.999);
|
||||
|
||||
{ 6. Ход монотонный. }
|
||||
V := 0;
|
||||
Prev := -1;
|
||||
for I := 1 to 500 do
|
||||
begin
|
||||
V := AdvanceProgress(V, 0, 1, Step);
|
||||
if V < Prev then
|
||||
Break;
|
||||
Prev := V;
|
||||
end;
|
||||
Check('ход монотонный', V >= Prev);
|
||||
|
||||
WriteLn;
|
||||
if Failed = 0 then
|
||||
WriteLn('ВЕРДИКТ: все проверки пройдены')
|
||||
else
|
||||
begin
|
||||
WriteLn('ВЕРДИКТ: не пройдено проверок: ', Failed);
|
||||
Halt(1);
|
||||
end;
|
||||
end.
|
||||
75
tools/install-desktop.sh
Executable file
|
|
@ -0,0 +1,75 @@
|
|||
#!/bin/bash
|
||||
# Встраивание в рабочий стол: пункт меню, значки и тип файла проекта.
|
||||
#
|
||||
# Что делает:
|
||||
# - кладёт значок программы и значок файла проекта в ~/.local/share/icons
|
||||
# - описывает тип application/x-nlve-project, чтобы *.nlve перестали
|
||||
# считаться просто текстом
|
||||
# - кладёт пункт меню, который открывает такие файлы нашей программой
|
||||
#
|
||||
# Всё в домашнем каталоге: прав администратора не нужно, другим
|
||||
# пользователям системы ничего не меняется.
|
||||
#
|
||||
# Запуск: tools/install-desktop.sh [путь к программе]
|
||||
# По умолчанию берётся nlve рядом с этим репозиторием.
|
||||
set -u
|
||||
|
||||
HERE=$(cd "$(dirname "$0")/.." && pwd)
|
||||
BIN=${1:-$HERE/nlve}
|
||||
PKG=$HERE/packaging
|
||||
|
||||
DATA=${XDG_DATA_HOME:-$HOME/.local/share}
|
||||
APPS=$DATA/applications
|
||||
MIME=$DATA/mime/packages
|
||||
ICONS=$DATA/icons/hicolor
|
||||
|
||||
note() { echo "== $*"; }
|
||||
fail() { echo "$*" >&2; exit 1; }
|
||||
|
||||
[ -x "$BIN" ] || fail "программы нет или она не запускается: $BIN"
|
||||
[ -d "$PKG" ] || fail "нет каталога $PKG"
|
||||
|
||||
note "Значки"
|
||||
for SIZE in 32 48 64 128; do
|
||||
mkdir -p "$ICONS/${SIZE}x${SIZE}/apps" "$ICONS/${SIZE}x${SIZE}/mimetypes"
|
||||
# convert есть не у всех; без него кладём один размер как есть.
|
||||
if command -v convert >/dev/null 2>&1; then
|
||||
convert "$PKG/nlve.png" -resize ${SIZE}x${SIZE} \
|
||||
"$ICONS/${SIZE}x${SIZE}/apps/nlve.png"
|
||||
convert "$PKG/application-x-nlve-project.png" -resize ${SIZE}x${SIZE} \
|
||||
"$ICONS/${SIZE}x${SIZE}/mimetypes/application-x-nlve-project.png"
|
||||
elif [ "$SIZE" = 128 ]; then
|
||||
cp -f "$PKG/nlve.png" "$ICONS/128x128/apps/nlve.png"
|
||||
cp -f "$PKG/application-x-nlve-project.png" \
|
||||
"$ICONS/128x128/mimetypes/application-x-nlve-project.png"
|
||||
fi
|
||||
done
|
||||
|
||||
note "Тип файла проекта"
|
||||
mkdir -p "$MIME"
|
||||
cp -f "$PKG/application-x-nlve-project.xml" "$MIME/nlve.xml"
|
||||
if command -v update-mime-database >/dev/null 2>&1; then
|
||||
update-mime-database "$DATA/mime"
|
||||
else
|
||||
echo "update-mime-database не найдена: тип не зарегистрируется" >&2
|
||||
fi
|
||||
|
||||
note "Пункт меню"
|
||||
mkdir -p "$APPS"
|
||||
sed -e "s|@BIN@|$BIN|g" -e "s|@DIR@|$(dirname "$BIN")|g" \
|
||||
"$PKG/nlve.desktop.in" > "$APPS/nlve.desktop"
|
||||
chmod 644 "$APPS/nlve.desktop"
|
||||
command -v update-desktop-database >/dev/null 2>&1 &&
|
||||
update-desktop-database "$APPS"
|
||||
|
||||
note "Программа по умолчанию для *.nlve"
|
||||
command -v xdg-mime >/dev/null 2>&1 &&
|
||||
xdg-mime default nlve.desktop application/x-nlve-project
|
||||
|
||||
command -v gtk-update-icon-cache >/dev/null 2>&1 &&
|
||||
gtk-update-icon-cache -f -t "$ICONS" >/dev/null 2>&1
|
||||
|
||||
echo
|
||||
echo "готово. Проверить: xdg-mime query filetype <файл>.nlve"
|
||||
echo "Файловый менеджер может показать новый значок не сразу:"
|
||||
echo "ему нужно перечитать базу (обычно достаточно перезапуска)."
|
||||
62
tools/make_file_icon.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Делает значок файла проекта из значка программы.
|
||||
|
||||
Зачем отдельный. Значок программы и значок её файлов — разные вещи: в
|
||||
списке файлов должно быть видно, что это документ, а не сама программа.
|
||||
Поэтому берётся лист с загнутым углом, а знак программы кладётся на него.
|
||||
|
||||
Запуск: python3 tools/make_file_icon.py
|
||||
Итог: packaging/application-x-nlve-project.png
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SRC = ROOT / 'packaging' / 'nlve.png'
|
||||
DST = ROOT / 'packaging' / 'application-x-nlve-project.png'
|
||||
|
||||
SIZE = 128
|
||||
# Лист занимает не всю клетку: значкам документов принято оставлять поля.
|
||||
MARGIN_X = 16
|
||||
MARGIN_Y = 6
|
||||
FOLD = 30
|
||||
|
||||
PAPER = (245, 245, 245, 255)
|
||||
FOLD_COLOR = (205, 205, 205, 255)
|
||||
EDGE = (120, 120, 120, 255)
|
||||
|
||||
|
||||
def main():
|
||||
if not SRC.exists():
|
||||
raise SystemExit(f'нет файла {SRC}')
|
||||
|
||||
img = Image.new('RGBA', (SIZE, SIZE), (0, 0, 0, 0))
|
||||
d = ImageDraw.Draw(img)
|
||||
|
||||
left, right = MARGIN_X, SIZE - MARGIN_X
|
||||
top, bottom = MARGIN_Y, SIZE - MARGIN_Y
|
||||
|
||||
# Лист с срезанным правым верхним углом.
|
||||
body = [
|
||||
(left, top), (right - FOLD, top), (right, top + FOLD),
|
||||
(right, bottom), (left, bottom),
|
||||
]
|
||||
d.polygon(body, fill=PAPER, outline=EDGE)
|
||||
# Сам загнутый угол.
|
||||
d.polygon([(right - FOLD, top), (right, top + FOLD),
|
||||
(right - FOLD, top + FOLD)], fill=FOLD_COLOR, outline=EDGE)
|
||||
|
||||
# Знак программы поверх листа, в нижней его части: сверху угол.
|
||||
mark = Image.open(SRC).convert('RGBA')
|
||||
side = 66
|
||||
mark = mark.resize((side, side), Image.LANCZOS)
|
||||
img.alpha_composite(mark, ((SIZE - side) // 2, bottom - side - 8))
|
||||
|
||||
img.save(DST)
|
||||
print('готов:', DST)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
137
tools/make_icons.py
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Готовит модуль с данными значков из файлов в icons_online.
|
||||
|
||||
Зачем модулем, а не файлами рядом с программой: программа не должна
|
||||
зависеть от того, лежит ли что-то в её каталоге. Значки маленькие, все
|
||||
вместе занимают единицы килобайт.
|
||||
|
||||
Размер выбран под кнопку: у неё двадцать восемь точек стороны, значку
|
||||
достаётся клетка двадцать четыре на двадцать четыре. Уменьшение делает
|
||||
convert с обычным сглаживанием, пропорции при этом сохраняются — все
|
||||
исходники квадратные, растягивать нечего.
|
||||
|
||||
Запуск: python3 tools/make_icons.py
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SRC = ROOT / 'icons_online'
|
||||
OUT = ROOT / 'src' / 'uIconData.pas'
|
||||
|
||||
# Имя в программе -> файл исходника и сторона в точках.
|
||||
# Для «схлопывания» берётся вариант на шестьдесят четыре точки: чем крупнее
|
||||
# исходник, тем чище получается уменьшение.
|
||||
ICONS = [
|
||||
('Snap', 'icons8-магнит-30.png', 24),
|
||||
('Ripple', 'icons8-merge-vertical-64.png', 24),
|
||||
('Log', 'icons8-property-30.png', 24),
|
||||
('Glue', 'icons8-клей-30.png', 24),
|
||||
('Target', 'icons8-цель-30.png', 24),
|
||||
]
|
||||
|
||||
# Значок самой программы: отдельно, потому что окну нужен формат ico —
|
||||
# только его умеет читать TIcon в LCL.
|
||||
APP_ICON = ('icons8-показ-видеокадров-30.png', 32)
|
||||
|
||||
|
||||
def make_ico(path: Path, side: int) -> bytes:
|
||||
out = subprocess.run(
|
||||
['convert', str(path), '-background', 'none', '-alpha', 'on',
|
||||
'-resize', '%dx%d' % (side, side), 'ico:-'],
|
||||
check=True, capture_output=True)
|
||||
return out.stdout
|
||||
|
||||
|
||||
def scale(path: Path, side: int) -> bytes:
|
||||
out = subprocess.run(
|
||||
['convert', str(path), '-background', 'none', '-alpha', 'on',
|
||||
'-resize', '%dx%d' % (side, side), 'png:-'],
|
||||
check=True, capture_output=True)
|
||||
return out.stdout
|
||||
|
||||
|
||||
def pascal_bytes(data: bytes, indent: str = ' ') -> str:
|
||||
parts = []
|
||||
for i in range(0, len(data), 16):
|
||||
chunk = ', '.join('$%02X' % b for b in data[i:i + 16])
|
||||
parts.append(indent + chunk)
|
||||
return ',\n'.join(parts)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not SRC.is_dir():
|
||||
print('нет каталога с исходниками:', SRC, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
blocks = []
|
||||
names = []
|
||||
for name, filename, side in ICONS:
|
||||
path = SRC / filename
|
||||
if not path.is_file():
|
||||
print('нет файла:', path, file=sys.stderr)
|
||||
return 1
|
||||
data = scale(path, side)
|
||||
names.append((name, len(data)))
|
||||
blocks.append(
|
||||
' { %s, %d на %d точек }\n'
|
||||
' Icon%sData: array[0..%d] of Byte = (\n%s\n );'
|
||||
% (filename, side, side, name, len(data) - 1, pascal_bytes(data)))
|
||||
|
||||
app_src, app_side = APP_ICON
|
||||
app_path = SRC / app_src
|
||||
if not app_path.is_file():
|
||||
print('нет файла:', app_path, file=sys.stderr)
|
||||
return 1
|
||||
app_data = make_ico(app_path, app_side)
|
||||
names.append(('App (ico)', len(app_data)))
|
||||
blocks.append(
|
||||
' { %s, значок окна, %d на %d точек, формат ico }\n'
|
||||
' IconAppData: array[0..%d] of Byte = (\n%s\n );'
|
||||
% (app_src, app_side, app_side, len(app_data) - 1,
|
||||
pascal_bytes(app_data)))
|
||||
|
||||
text = '''{
|
||||
uIconData — значки, вшитые в программу.
|
||||
|
||||
Собран из файлов в icons_online командой:
|
||||
|
||||
python3 tools/make_icons.py
|
||||
|
||||
Правится не он, а исходные картинки: этот файл переписывается целиком.
|
||||
|
||||
Значки взяты у Icons8 и требуют указания авторства — оно есть в окне
|
||||
«О программе», вкладка «Библиотеки и авторство». Ссылки на каждый значок
|
||||
лежат в icons_online/attribution.txt.
|
||||
|
||||
Картинки чёрные на прозрачном фоне. Цвет им задаётся при отрисовке:
|
||||
берётся прозрачность точки, а цвет — тот, каким сейчас рисуется кнопка.
|
||||
Поэтому один и тот же значок годится и для тёмной кнопки, и для
|
||||
оранжевой.
|
||||
}
|
||||
unit uIconData;
|
||||
|
||||
{$mode objfpc}{$H+}
|
||||
|
||||
interface
|
||||
|
||||
const
|
||||
%s
|
||||
|
||||
implementation
|
||||
|
||||
end.
|
||||
''' % ('\n\n'.join(blocks))
|
||||
|
||||
OUT.write_text(text, encoding='utf-8')
|
||||
print('записан', OUT)
|
||||
for name, size in names:
|
||||
print(' %-8s %d байт' % (name, size))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||