Compare commits

...

6 commits

Author SHA1 Message Date
af03ec1b93 Окно кадрирования работает и в проекте, где кэша ещё нет
Кадр для окна пишется в кэш проекта, а кэш заводится при первой сборке
вставок. Кадрирование открывают и раньше — тогда ffmpeg падал с «No such
file or directory», а окно показывало «Не удалось получить кадр» вместе с
цепочкой фильтров. Выглядело так, будто дело в цепочке; дело было в
отсутствующей папке.

Замерено на том же файле и той же цепочке: в существующей папке команда
отдаёт кадр в 320 КБ, в несуществующей — код выхода 254.

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

Проверка tests/cropdialog.sh: проект без папки кэша, щелчок по кнопке
кадрирования, смотрим, появился ли файл кадра. Папка убирается прямо
перед щелчком: иначе её успевает создать заготовка чёрной вставки, и
проверка меряет не то. На прежней сборке падает.
2026-10-17 20:37:15 +03:00
567c518a62 Наложение в предпросмотре сводится заранее; номер сборки в заголовке
Клип, не заполняющий кадр, стал уходить плееру отдельной дорожкой, и
весь показ пошёл через цепочку фильтров. Цепочка работает всё время
показа и считается процессором: на кадре 2688x1512 видео начало
отставать от звука.

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

Попутно, из разбора того же случая:

— заполнитель до первого наложения нарезается по длине файла. Одной
  записью mpv её обрезает («entry 0 uses 7082.687000 seconds, but file
  has only 15.021000»), дорожка оказывается короче монтажа и не
  совмещается с ним по времени;
— появление заготовки вызывает перезагрузку списка: строка списка при
  этом не меняется, и без признака готовности плеер так и оставался без
  дорожки наложения;
— в журнал пишется, сколько окон наложения и готовы ли файлы, а также
  собранная цепочка показа и неудача video-add. Без этого «картинки
  нет» нечем объяснить.

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

Проверено на монтаже с картинкой на 300-й секунде: в окне она лежит
поверх видео, цепочки показа нет.
2026-10-16 17:20:52 +03:00
766f1ad196 Накладываемая картинка показывается в предпросмотре готовым куском
В окне картинки не было вовсе, хотя в ролик она попадала. Плеер получал
сам файл, а его декодер на этом webp спотыкался: «webp: invalid TIFF
header in Exif data» — накладывать стало нечего. У неподвижного кадра
есть и вторая беда: в списке кусков у него нет длины.

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

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

Проверено на том самом файле: в окне картинка легла поверх видео, в
ролике слева нижнее видео (253, 0, 0), справа картинка. Числовые
проверки в flattest: кусок собирается слоями, у верхнего слоя полей нет,
предпросмотру отдан кусок из кэша, и он заказан к созданию.
2026-10-16 16:36:11 +03:00
3d3d02368c Клип, не заполняющий кадр, ложится поверх нижнего, а не закрывает его
Уменьшенная и сдвинутая картинка выходила в ролик с чёрными полями, и
нижнюю дорожку закрывал не сам клип, а эти поля. Так же вело себя и
уменьшенное видео. Между тем уменьшают клип ровно затем, чтобы под ним
было видно другое.

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

Решение одно на оба пути: IsOverlayClip, и в нём TransformFillsFrame
считает по тем же числам, что уходят в фильтр.

Заодно две вещи, всплывшие по дороге:

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

Проверка tests/imagecrop.sh получила второй случай: под картинкой видео,
вокруг неё обязано быть видно его. На прежней сборке там чёрное.
2026-10-16 16:17:30 +03:00
ffd105a8c0 Щелчок в месте наезда перебирает клипы
Наезд двух клипов на одной дорожке — заказ на переход, то есть обычное
дело. Нажатие же всегда попадало в верхний по списку, и нижний клип
нельзя было ни выделить, ни потащить: чтобы до него добраться,
приходилось сперва убирать верхний.

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

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

Отбор вынесен в TTrack.ClipsAt, числовая проверка — в lfmcheck.
tests/pickcycle.sh щёлкает дважды и тащит: на прежней сборке уезжает
верхний клип, а нижний едет за ним схлопыванием.
2026-10-16 15:43:01 +03:00
c18b695b89 Своё кадрирование картинки доходит до предпросмотра и ролика
Окно «Кадрирование и поворот» показывало картинку уменьшенной и
сдвинутой, а в кадре она шла во весь экран. В уплощении преобразование
ставилось только обычному видео: у картинки своя ветка, и там его не
было вовсе. Кусок из картинки делался по жёсткой цепочке «вписать целиком
плюс поля», про клип она ничего не знала.

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

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

Проверки: flattest сверяет, что заявка несёт цепочку и отдельный путь;
tests/imagecrop.sh проходит всю дорогу — делает картинку, собирает
секунду ролика и смотрит на точки готового кадра. На прежней сборке
картинка выходит во весь кадр, обе падают.
2026-10-16 15:42:49 +03:00
19 changed files with 1196 additions and 41 deletions

4
.gitignore vendored
View file

@ -92,3 +92,7 @@ shot*.png
# Мусор компоновщика Lazarus.
link*.res
# Номер сборки и созданный из него файл: про эту машину, не про историю.
/build.number
/src/uBuildInfo.inc

View file

@ -527,7 +527,25 @@ Linux потом искала по нему `.dll`.
Два клипа **на одной дорожке**, наползшие друг на друга, дают переход:
один плавно перетекает в другой. На разных дорожках перекрытие означает
не переход, а наложение — там по-прежнему побеждает верхний, если он
непрозрачен.
сплошной.
Сплошным клип перестаёт быть по трём причинам: выбит цвет, задана
прозрачность или **он не заполняет кадр**. Последнее — то же самое,
сказанное иначе: уменьшенный или сдвинутый клип занимает часть кадра, а
вокруг остаётся пустое место, которое сборка обкладывает чёрными полями.
Поле непрозрачно, и нижнюю дорожку закрывает не сам клип, а его поля —
поэтому такой клип кладётся поверх нижнего, а не вместо него. Уменьшают
клип ровно затем, чтобы увидеть под ним другое. Класть не на что —
остаются поля.
Отсюда же берётся картинка в углу кадра и два видео рядом: достаточно
уменьшить и сдвинуть клипы на разных дорожках.
В месте наезда под указателем два клипа. Первый щелчок берёт верхний,
повторный по тому же месту переводит выделение на следующий, после
последнего снова на верхний; строка состояния говорит, сколько их там.
Нажатие для перетаскивания берёт именно выделенный — иначе нижний клип
нельзя было бы ни выделить, ни потащить, не убрав сперва верхний.
Различие не косметическое. Если бы кроссфейдом считалось любое
перекрытие, то в проекте, где клипы на разных дорожках перекрываются
@ -701,6 +719,30 @@ Linux потом искала по нему `.dll`.
кусков нельзя, он на этом зависает. Заготовки делаются готовых длин
(15 с, 60 с, 5 мин и так далее), а нужный кусок из них вырезается —
тогда любое движение клипа не требует новой генерации.
Своё кадрирование картинки — увеличение, смещение, поворот — запекается
прямо в эту заготовку, той же цепочкой фильтров, что показывает окно
«Кадрирование и поворот». Поэтому в кэше лежит столько заготовок,
сколько разных кадрирований у этой картинки, и предпросмотр с роликом
показывают одно и то же.
Заготовка нужна не всегда: если картинка не заполняет кадр, а под ней
что-то лежит, участок собирается наложением, и чёрным полям взяться
неоткуда. Заготовка с полями остаётся для случая, когда класть не на
что.
Предпросмотр идёт тем же путём: участок с наложением сводится заранее
и играется обычным файлом. Силами плеера рисуются только прозрачные
клипы и клипы с ключом — их участки бывают длинными, и пересобирать
минуты материала ради них дорого. Цепочка плеера работает всё время
показа и считается процессором: на кадре 2688x1512 видео начинает
отставать от звука, поэтому ради клипа, который просто не заполняет
кадр, её не включают.
Картинка, которую всё же рисует плеер, отдаётся ему заготовкой без
полей, а не самим файлом: своих декодеров ему хватает не на всё
(настоящий webp из сети дал «webp: invalid TIFF header in Exif data»),
да и у неподвижного кадра нет длины в списке кусков.
3. Границы кусков кладутся на кадровую сетку проекта: между кадрами
времени нет, а звук набирается с точностью до отсчёта, и доли кадра
разводят их в стороны.
@ -1020,7 +1062,7 @@ Wine с новой библиотекой не грузилось ничего.
уход выглядит как накапливающийся рассинхрон, которого нет. На это уже
попались один раз.
Ещё девять проверок гоняют саму программу на невидимом экране
Ещё одиннадцать проверок гоняют саму программу на невидимом экране
(`Xvfb :99`). Рабочий экран они не занимают:
```
@ -1033,9 +1075,22 @@ Wine с новой библиотекой не грузилось ничего.
./tests/genquiet.sh [<путь к nlve>] тяжёлая сборка ждёт конца правки
./tests/dropgap.sh [<путь к nlve>] клип садится в дыру без наезда
./tests/dragorder.sh [<путь к nlve>] связка переехала — порядок цел
./tests/pickcycle.sh [<путь к nlve>] щелчок перебирает наехавшие клипы
./tests/cropdialog.sh [<путь к nlve>] окно кадрирования показывает кадр
```
Последним семи нужен монтаж в `test/поток/`: `длинный-фейд.nlve` — один
Отдельно от них `tests/imagecrop.sh` — сквозная, без окна: делает
картинку, кладёт её клипом с уменьшением и смещением, собирает секунду
ролика и смотрит на точки готового кадра. Случая два: класть не на что —
вокруг картинки чёрные поля; под картинкой видео — вокруг неё видно его,
а не поля. Ей нужны `tests/nlvetool` (`./build.sh test`), ffmpeg и python
с PIL.
`cropdialog.sh` строит свой монтаж сам, но ему нужна настоящая съёмка:
кадр для окна берётся из файла. Путь задаётся переменной
`NLVE_CROP_SOURCE`.
Остальным нужен монтаж в `test/поток/`: `длинный-фейд.nlve` — один
клип с затуханием секунд на семьдесят (такой кусок сводится около
полутора минут, и всё это время человек продолжает работать),
`пара.nlve` — два клипа без затуханий. Путь к первому задаётся
@ -1052,12 +1107,25 @@ xdotool не видит ни одного окна и проверка «про
экран шире: `Xvfb :98 -screen 0 1600x1000x24` и `ЭКРАН=:98`.
Каждую из них стоит прогонять и на прежней сборке: проверка, которая
проходит везде, не проверяет ничего. Все девять на сборке до своей правки
проходит везде, не проверяет ничего. Все одиннадцать на сборке до своей правки
падают. Замерено: клип у кромки уезжает на 0.7 с против 4.2 с, файл из
списка падает в конец дорожки вместо курсора, сборка начинается через
0.2 с вместо 1.6 с, клип в дыру садится с наездом 0.007383 с, а дорожка
0.2 с вместо 1.6 с, клип в дыру садится с наездом 0.007383 с, дорожка
картинки после переноса связки остаётся в порядке 0, 14, 7.991 — не по
времени.
времени, второй щелчок по наезду тащит верхний клип вместо нижнего, а
картинка с уменьшением 50% выходит в ролик во весь кадр.
## Номер сборки
В заголовке окна стоит номер: `nlve 42 — фильм.nlve`. Он растёт на
единицу при каждой сборке редактора (`build.sh`), и по нему сразу видно,
та ли это сборка, где нужная правка уже есть. Без него проверяют старым
двоичным файлом, видят прежнее поведение, и время уходит на выяснение,
у кого что не работает.
Счётчик лежит в `build.number`, из него создаётся `src/uBuildInfo.inc`.
Оба файла в репозиторий не входят: номер про эту машину, а не про
историю проекта.
## Структура

View file

@ -72,8 +72,37 @@ run_lazbuild() {
return "$rc"
}
# Номер сборки. Растёт на единицу при каждой сборке редактора и попадает
# в заголовок окна.
#
# Зачем: по заголовку сразу видно, та ли это сборка, где правка уже есть.
# Без него человек проверяет старым двоичным файлом и видит прежнее
# поведение — так уже случалось, и время ушло на выяснение, у кого что
# не работает.
#
# Счётчик лежит рядом с исходниками, но в репозиторий не входит: он про
# эту машину, а не про историю проекта.
bump_version() {
local file="$HERE/build.number" n
n=0
[ -f "$file" ] && n=$(cat "$file" 2>/dev/null || echo 0)
case "$n" in
''|*[!0-9]*) n=0 ;;
esac
n=$((n + 1))
echo "$n" > "$file"
cat > "$HERE/src/uBuildInfo.inc" <<EOF
{ Создаётся build.sh при каждой сборке. В репозиторий не входит. }
const
BuildNumber = $n;
BuildStamp = '$(date '+%d.%m.%Y %H:%M')';
EOF
echo "сборка номер $n"
}
build_current() {
check_toolchain
bump_version
if run_lazbuild "Редактор под эту систему ($LCL_LINUX)" \
--ws="$LCL_LINUX" "$HERE/nlve.lpi"; then
echo "готово: $HERE/nlve"

View file

@ -105,6 +105,14 @@ type
к самой картинке, у чёрного пусто. Хранится прямо здесь, чтобы потом
не восстанавливать источник обратным перебором. }
GenSource: string;
{ Для куска из картинки — цепочка, которой она вписывается в кадр
проекта: своё увеличение, смещение и поворот клипа. Запекается
прямо в сгенерированный кусок, поэтому к самому куску потом
применять её нельзя — она уже в нём.
Отдельно от Filter именно поэтому: Filter применяется к исходнику
при пересборке, и смешать их значило бы кадрировать дважды. }
GenFilter: string;
{ Начало куска должно быть ровно там, где указано, а не на ближайшем
опорном кадре. Тогда огрызок от начала до следующего опорного кадра
перекодируется, а остальное копируется.

View file

@ -969,8 +969,14 @@ begin
{ Сгенерированные вставки берутся из файла, который заведомо длиннее
нужного: так одна и та же чёрная вставка годится для дыр любой
длины. Значит вырезать надо и из них, но притягивать к опорному
кадру нечего — они начинаются с нуля, а ноль всегда опорный. }
if (FSegments[I].Kind <> skVideo) and (FSegments[I].SubFile = '') then
кадру нечего — они начинаются с нуля, а ноль всегда опорный.
Кусок со слоями сюда не попадает, какого бы он ни был вида:
заготовки у него нет и быть не может, он собирается наложением
ниже. Картинка, уменьшенная и положенная поверх нижнего видео, —
как раз такой случай. }
if (FSegments[I].Kind <> skVideo) and (FSegments[I].SubFile = '') and
(Length(FSegments[I].Layers) = 0) then
begin
Dst := PartDst('a', I);
Rep.Index := I;

View file

@ -262,10 +262,14 @@ function ArgsNormalize(const ASrc, ADst: string;
ASampleRate, AChannels: Integer; const AFilter: string = ''): TArgs;
{ Сделать видеоклип заданной длины из картинки. }
{ AFilter — готовая цепочка вписывания, посчитанная по настройке клипа.
Пустая означает «вписать целиком с полями», как было до кадрирования
картинок. }
function ArgsImageClip(const ASrc, ADst: string; ADuration: Double;
AWidth, AHeight, AFpsNum, AFpsDen: Integer;
const AVideoCodec, APixFmt, AAudioCodec: string;
ASampleRate, AChannels: Integer; ABitrate: Int64 = 0): TArgs;
ASampleRate, AChannels: Integer; ABitrate: Int64 = 0;
const AFilter: string = ''): TArgs;
{ Сделать чёрный клип с тишиной заданной длины.
@ -1324,7 +1328,10 @@ end;
function ArgsImageClip(const ASrc, ADst: string; ADuration: Double;
AWidth, AHeight, AFpsNum, AFpsDen: Integer;
const AVideoCodec, APixFmt, AAudioCodec: string;
ASampleRate, AChannels: Integer; ABitrate: Int64): TArgs;
ASampleRate, AChannels: Integer; ABitrate: Int64;
const AFilter: string): TArgs;
var
Chain: string;
begin
{ -loop 1 повторяет единственный кадр, -t задаёт длину.
Картинка вписывается в кадр целиком с чёрными полями по краям:
@ -1342,9 +1349,15 @@ begin
Format('anullsrc=channel_layout=%s:sample_rate=%d',
[IfThen(AChannels = 1, 'mono', 'stereo'), ASampleRate])]);
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 := AFilter;
if Chain = '' then
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]);
Result := Concat(Result, ['-vf', Chain]);
Result := Concat(Result, MasterOutputArgs(0, 0, AFpsNum, AFpsDen,
AVideoCodec, APixFmt, AAudioCodec, ASampleRate, AChannels, ABitrate));

View file

@ -57,6 +57,16 @@ type
Y: Integer;
Width: Integer;
Height: Integer;
{ Сколько секунд можно взять из этого файла.
Список кусков оверлея обязан покрывать монтаж от самого начала,
иначе плееру нечем совместить его по времени с основой. Промежуток
до первого показа заполняется тем же файлом, и просить у него
больше, чем в нём есть, нельзя: mpv обрезает такую запись
(«entry uses 7082.687000 seconds, but file has only 15.021000») —
дорожка оказывается короче монтажа, и оверлей не показывается
вовсе. Поэтому заполнитель нарезается на куски по этой длине. }
SrcLength: Double;
end;
TPreviewOverlays = array of TPreviewOverlay;
@ -82,6 +92,10 @@ type
TargetPath: string;
{ Только для gkLayers: что с чем смешивать. }
Layers: TSegmentLayers;
{ Только для gkImage: цепочка фильтров, которой картинка вписывается
в кадр проекта. Считается из настройки клипа, поэтому две копии
одной картинки с разным кадрированием дают разные куски. }
Filter: string;
{ Место на монтаже, ради которого всё затевается. Нужно только для
показа: пока участок пересобирается, монтаж помечает его полосой. }
TlStart: Double;
@ -220,6 +234,22 @@ implementation
uses
Math, md5;
{ Объявлено заранее: уплощение решает по этому признаку, класть ли клип
поверх нижнего, а само правило описано ниже, рядом с предпросмотром. }
function IsOverlayClip(AProject: TProject; AClip: TClip): Boolean; forward;
{ Сцепить две цепочки фильтров через запятую. Пустые не дают ни лишней
запятой, ни пустого звена: и то, и другое ffmpeg не прощает. }
function JoinFilters(const A, B: string): string;
begin
if A = '' then
Result := B
else if B = '' then
Result := A
else
Result := A + ',' + B;
end;
const
{ Длительность округляется до миллисекунд: иначе крошечное расхождение
в дробной части плодило бы почти одинаковые файлы. }
@ -282,7 +312,10 @@ begin
[Copy(MD5Print(MD5String(AReq.SourcePath)), 1, 12), Ms]);
gkImage:
begin
Key := AReq.SourcePath + '|' + IntToStr(Ms);
{ Кадрирование входит в ключ: одна и та же картинка с разным
увеличением и смещением — разные куски. Иначе второй клип
получил бы из кэша чужой, посчитанный для первого. }
Key := AReq.SourcePath + '|' + IntToStr(Ms) + '|' + AReq.Filter;
Name := Format('img_%s_%d.mkv', [Copy(MD5Print(MD5String(Key)), 1, 12), Ms]);
end;
else
@ -885,7 +918,41 @@ begin
Req.Kind := gkImage;
Req.SourcePath := Item.EffectivePath;
Req.Duration := BucketFor(Seg.Duration);
{ Кадрирование картинки запекается прямо в кусок.
Кусок и так делается заново, поэтому второй проход не нужен, а
предпросмотр с роликом получают одно и то же. Считается той же
цепочкой, что у видео и в окне кадрирования: разными путями
считать одно и то же нельзя — окно обещает ровно эту строку и
показывает её текстом.
Раньше картинка шла мимо: преобразование ставилось только
обычному видео, и клип с увеличением 58% и смещением выходил в
ролик на весь кадр. }
Req.Filter := TransformFilter(Winner.Transform,
Item.Info.Width, Item.Info.Height,
AProject.Master.Width, AProject.Master.Height);
Seg.GenFilter := Req.Filter;
Seg.FilePath := GeneratedPath(AProject.CacheDir, Req);
{ Картинка, не заполняющая кадр, ложится поверх нижнего видео, а не
подменяет его собой. Иначе нижнюю дорожку закрывают не картинкой,
а её чёрными полями: сам кусок — сплошной кадр проекта, в котором
картинка занимает свой прямоугольник, а остальное залито чёрным.
Слои считает тот же код, что и для полупрозрачных клипов: у
верхнего слоя берётся цепочка без полей и место, куда он встаёт. }
Under := nil;
if IsOverlayClip(AProject, Winner) then
Under := BelowAt(Winner, Mid);
if Under <> nil then
begin
SetLength(Seg.Layers, 2);
Seg.Layers[0] := LayerOf(Under, SegStart, 1, 1);
Seg.Layers[1] := LayerOf(Winner, SegStart,
Winner.Opacity, Winner.Opacity);
Seg.Transcode := True;
end;
end
else
begin
@ -948,11 +1015,10 @@ begin
Partner := OverlapPartner(Winner, Mid);
Under := nil;
{ Ключ работает так же, как полупрозрачность: клип перестаёт быть
сплошным, и участок приходится собирать вместе с тем, что под
ним. }
if (Partner = nil) and
((Winner.Opacity < 0.999) or Winner.ColorKey.Enabled) then
{ Ключ, прозрачность и неполный кадр — три способа перестать быть
сплошным. В любом из них участок собирается вместе с тем, что
лежит под ним: иначе нижнее видео закроют чёрные поля. }
if (Partner = nil) and IsOverlayClip(AProject, Winner) then
Under := BelowAt(Winner, Mid);
{ Ручное затухание: кусок попал в зону появления или ухода. Слой
@ -1101,6 +1167,10 @@ begin
begin
if ASegments[I].Kind = skVideo then
Continue;
{ Кусок собирается слоями — заготовка ему не нужна, и делать её
значило бы считать минуты впустую. }
if Length(ASegments[I].Layers) > 0 then
Continue;
Path := ASegments[I].FilePath;
if (Path = '') or FileExists(Path) then
Continue;
@ -1113,6 +1183,9 @@ begin
AMissing[N].TargetPath := Path;
AMissing[N].Duration := BucketFor(ASegments[I].Duration);
AMissing[N].SourcePath := ASegments[I].GenSource;
{ Кадрирование едет с куском: заявка собирается здесь, а посчитано
оно там, где известен клип. }
AMissing[N].Filter := ASegments[I].GenFilter;
if ASegments[I].Kind = skBlack then
AMissing[N].Kind := gkBlack
else
@ -1318,14 +1391,50 @@ begin
end;
end;
{ Клип кладётся поверх, а не побеждает целиком, если он не сплошной:
с выбитым цветом или полупрозрачный. }
function IsOverlayClip(AClip: TClip): Boolean;
{ Клип кладётся поверх, а не побеждает целиком, если он не сплошной.
Несплошных случая три: выбитый цвет, прозрачность и клип, который не
заполняет кадр. Последний — то же самое, только другими словами:
уменьшенная или сдвинутая картинка занимает часть кадра, а остальное
сборка обкладывает чёрными полями. Поле непрозрачно, и нижнее видео
закрывает собой не картинка, а её поля. Человек же, уменьшая клип,
хочет увидеть под ним то, что на дорожке ниже, — иначе зачем уменьшать.
Если под клипом ничего нет, поля остаются полями: класть не на что. }
{ Кого плеер рисует поверх основы сам, своей цепочкой.
Только прозрачные клипы и клипы с ключом. Цепочка идёт через процессор
и работает всё время показа, а не только там, где лежит оверлей: на
2688x1512 видео начинает отставать от звука. Ради длинного
полупрозрачного слоя это оправдано — иначе пришлось бы пересобирать
минуты материала. Ради клипа, который просто не заполняет кадр, — нет:
его участок дешевле свести заранее, как для ролика, и тогда
предпросмотр играет обычный файл. }
function IsPlayerOverlay(AClip: TClip): Boolean;
begin
Result := (AClip <> nil) and (AClip.Kind in [ckVideo, ckImage]) and
(AClip.ColorKey.Enabled or (AClip.Opacity < 0.999));
end;
function IsOverlayClip(AProject: TProject; AClip: TClip): Boolean;
var
Item: TMediaItem;
begin
Result := False;
if (AClip = nil) or not (AClip.Kind in [ckVideo, ckImage]) then
Exit;
if AClip.ColorKey.Enabled or (AClip.Opacity < 0.999) then
Exit(True);
if AProject = nil then
Exit;
Item := AProject.FindMedia(AClip.MediaId);
if Item = nil then
Exit;
Result := not TransformFillsFrame(AClip.Transform,
Item.Info.Width, Item.Info.Height,
AProject.Master.Width, AProject.Master.Height);
end;
function FlattenPreview(AProject: TProject;
out AVideo, AAudio: TFlatSegmentArray;
out AOverlays: TPreviewOverlays;
@ -1342,6 +1451,10 @@ var
RangeA, RangeB: TDoubleArray;
RangeCount: Integer;
Ov: TPreviewOverlay;
ImgReq: TGenRequest;
{ Заготовки картинок для наложения. Отдельным списком, потому что
уплощение ниже заполняет AMissing заново и затёрло бы их. }
MissOv: TGenRequests;
{ Занято ли это время другим оверлеем. }
function OverlapsTaken(const AFrom, ATo: Double): Boolean;
@ -1369,6 +1482,7 @@ begin
if Total <= 0 then
Exit;
MissOv := nil;
Hidden := TFPList.Create;
try
N := 0;
@ -1382,7 +1496,11 @@ begin
for C := 0 to Track.Count - 1 do
begin
Clip := Track[C];
if not IsOverlayClip(Clip) then
{ Плееру отдаём только прозрачные и с ключом. Остальное, что не
заполняет кадр, сводится заранее вместе с тем, что под ним:
так предпросмотр совпадает с роликом и не упирается в
процессор. }
if not IsPlayerOverlay(Clip) then
Continue;
Item := AProject.FindMedia(Clip.MediaId);
if Item = nil then
@ -1420,7 +1538,7 @@ begin
Continue;
for J := 0 to Above.Count - 1 do
{ Клип с ключом или прозрачностью сам ничего не закрывает. }
if not IsOverlayClip(Above[J]) then
if not IsOverlayClip(AProject, Above[J]) then
CutRange(RangeA, RangeB, RangeCount,
Above[J].TlStart, Above[J].TlEnd);
end;
@ -1437,20 +1555,64 @@ begin
Ov.TlEnd := RangeB[J];
Ov.FilePath := Item.EffectivePath;
Ov.SrcStart := Clip.SrcIn + (RangeA[J] - Clip.TlStart);
Ov.SrcLength := Item.Info.Duration;
Ov.Filter := OverlayFilter(Clip.Transform,
Item.Info.Width, Item.Info.Height,
AProject.Master.Width, AProject.Master.Height,
Ov.X, Ov.Y, Ov.Width, Ov.Height);
{ Картинку плееру отдаём не файлом, а готовым куском.
Своих декодеров у плеера хватает не на всё: настоящий webp из
сети вызвал «webp: invalid TIFF header in Exif data», и
накладывать стало нечего — картинка пропала из окна, хотя в
ролик попадала. У неподвижного кадра есть и вторая беда: в
списке кусков у него нет длины. Кусок же — обычное видео
нужной длины, которое играется как всё остальное.
Масштаб уже внутри куска, поэтому плееру остаётся только
поставить его на место; ключ и приглушение добавятся ниже. }
if (Clip.Kind = ckImage) or (Item.Info.Kind = mkImage) then
begin
ImgReq := Default(TGenRequest);
ImgReq.Kind := gkImage;
ImgReq.SourcePath := Item.EffectivePath;
ImgReq.Duration := BucketFor(Ov.TlEnd - Ov.TlStart);
{ Цепочка без полей: поля тут и вредны — чёрное поле закрыло
бы нижнее видео, ради которого всё и затевается. }
ImgReq.Filter := Ov.Filter;
ImgReq.TargetPath := GeneratedPath(AProject.CacheDir, ImgReq);
ImgReq.TlStart := Ov.TlStart;
ImgReq.TlEnd := Ov.TlEnd;
Ov.FilePath := ImgReq.TargetPath;
Ov.SrcStart := 0;
Ov.SrcLength := ImgReq.Duration;
Ov.Filter := '';
if not FileExists(ImgReq.TargetPath) then
begin
{ Одна и та же картинка под одним и тем же кадрированием —
один кусок, сколько бы раз она ни лежала на монтаже. }
I := 0;
while (I < Length(MissOv)) and
(MissOv[I].TargetPath <> ImgReq.TargetPath) do
Inc(I);
if I >= Length(MissOv) then
begin
SetLength(MissOv, Length(MissOv) + 1);
MissOv[High(MissOv)] := ImgReq;
end;
end;
end;
{ Порядок тот же, что при сборке: сперва вписали в кадр, потом
выбили цвет, потом приглушили. Иначе смягчение края разошлось
бы с тем, что даст готовый ролик. }
if AWithKey then
begin
if Clip.ColorKey.Enabled then
Ov.Filter := Ov.Filter + ',' + ColorKeyFilter(Clip.ColorKey.Color,
Clip.ColorKey.Similarity, Clip.ColorKey.Blend);
if OpacityFilter(Clip.Opacity) <> '' then
Ov.Filter := Ov.Filter + ',' + OpacityFilter(Clip.Opacity);
Ov.Filter := JoinFilters(Ov.Filter,
ColorKeyFilter(Clip.ColorKey.Color,
Clip.ColorKey.Similarity, Clip.ColorKey.Blend));
Ov.Filter := JoinFilters(Ov.Filter, OpacityFilter(Clip.Opacity));
end;
SetLength(AOverlays, N + 1);
@ -1487,6 +1649,13 @@ begin
SetLength(AMissing, Length(AMissing) + 1);
AMissing[High(AMissing)] := MissA[I];
end;
{ Заготовки накладываемых картинок добавляются здесь, а не на месте:
уплощение выше заполняет AMissing с нуля и затёрло бы их. }
for I := 0 to High(MissOv) do
begin
SetLength(AMissing, Length(AMissing) + 1);
AMissing[High(AMissing)] := MissOv[I];
end;
end;
end.

View file

@ -309,7 +309,8 @@ begin
FRequests[I].Duration,
FMaster.Width, FMaster.Height, FMaster.FpsNum, FMaster.FpsDen,
FMaster.VideoCodec, FMaster.PixFmt, AudioCodec,
FMaster.SampleRate, FMaster.Channels, FMaster.VideoBitrate);
FMaster.SampleRate, FMaster.Channels, FMaster.VideoBitrate,
FRequests[I].Filter);
gkSilence:
Args := ArgsSilenceClip(FRequests[I].TargetPath,
FRequests[I].Duration, AudioCodec,

View file

@ -33,6 +33,10 @@ uses
uIconData, uSubtitles, uTextMeasure, uTextForm, uExportForm,
uColorKeyForm;
{ Номер сборки и время. Файл создаётся build.sh при каждой сборке и в
репозиторий не входит. }
{$I uBuildInfo.inc}
const
{ Номера системных сообщений о перерисовке рамки окна. Берём числами, а
не из модуля Windows: тот переопределяет TRect и Point, и обычные
@ -130,6 +134,9 @@ type
FCheckMisplacedOnShow: Boolean;
FPreviewOverlayUrl: string;
FPreviewOverlayWindows: TOverlayWindows;
{ Лежат ли на диске все файлы наложения. Входит в сравнение списков:
появление заготовки обязано вызвать перезагрузку. }
FPreviewOverlayReady: Boolean;
{ Списки кусков звуковых дорожек для плеера. }
FPreviewAudioUrls: array of string;
{ Звуковые дорожки предпросмотра — их номера в проекте, а не сами
@ -1578,7 +1585,11 @@ begin
ProjectName := ExtractFileName(FProject.FileName);
if FProject.Modified then
ProjectName := ProjectName + ' *';
Caption := 'nlve — ' + ProjectName;
{ Номер сборки в заголовке: по нему сразу видно, та ли это сборка, где
правка уже есть. Без него проверяют старым двоичным файлом, видят
прежнее поведение, и время уходит на выяснение, у кого что не
работает. }
Caption := Format('nlve %d — %s', [BuildNumber, ProjectName]);
end;
procedure TMainForm.UpdateTimeLabel;
@ -2392,6 +2403,29 @@ var
Inc(N);
end;
{ Заполнить промежуток повторами файла, не прося у него больше, чем в
нём есть. Длина неизвестна — кладём одной записью, как было. }
procedure FillGap(const AOv: TPreviewOverlay; const AFrom, ATo: Double);
var
Left, Step: Double;
begin
Left := ATo - AFrom;
if AOv.SrcLength <= 0.001 then
begin
Push(AOv.FilePath, 0, Left);
Exit;
end;
{ Полсекунды запаса: длина файла у ffmpeg и у mpv расходится в
последнем знаке, и запись ровно во весь файл даёт то же самое
предупреждение об обрезке. }
Step := Max(0.5, AOv.SrcLength - 0.5);
while Left > 0.001 do
begin
Push(AOv.FilePath, 0, Min(Step, Left));
Left := Left - Step;
end;
end;
begin
FPreviewOverlayUrl := '';
FPreviewOverlayWindows := nil;
@ -2402,9 +2436,16 @@ begin
At := 0;
for I := 0 to High(AOverlays) do
begin
{ Промежуток до этого куска — заполнитель из того же файла. }
{ Промежуток до этого куска — заполнитель из того же файла, нарезанный
по его длине.
Одной записью нельзя: mpv обрезает запись, которая просит больше,
чем есть в файле («entry uses 7082.687000 seconds, but file has only
15.021000»), дорожка оверлея оказывается короче монтажа и не
совмещается с ним по времени — наложение просто не показывается.
Замерено на монтаже в два часа с картинкой на 7082-й секунде. }
if AOverlays[I].TlStart > At + 0.001 then
Push(AOverlays[I].FilePath, 0, AOverlays[I].TlStart - At);
FillGap(AOverlays[I], At, AOverlays[I].TlStart);
Push(AOverlays[I].FilePath, AOverlays[I].SrcStart,
AOverlays[I].TlEnd - AOverlays[I].TlStart);
At := AOverlays[I].TlEnd;
@ -2429,6 +2470,23 @@ begin
Exit;
end;
FPreviewOverlayUrl := BuildEdlUri(Segs);
{ Все ли файлы наложения уже есть на диске.
Картинка показывается заготовкой, а она делается не мгновенно. Пока
её нет, плееру нечего добавлять, и дорожки наложения он не заводит.
Когда заготовка появится, список кусков останется прежним — строка в
строку, — и перезагрузки не случится: предпросмотр сравнивает именно
строки. Поэтому готовность входит в сравнение наравне с ними.
Проверено на монтаже, где картинка так и не показывалась до первой
правки. }
FPreviewOverlayReady := True;
for I := 0 to N - 1 do
if not FileExists(Segs[I].FilePath) then
begin
FPreviewOverlayReady := False;
Break;
end;
end;
{ Делает ли идущая генерация то, что монтажу ещё нужно.
@ -2524,6 +2582,8 @@ begin
{ Наложение входит в сравнение наравне со звуком: сдвинули оверлей —
список кусков основы мог не измениться, а показывать надо иначе. }
AudioEdl := AudioEdl + '#' + FPreviewOverlayUrl;
if not FPreviewOverlayReady then
AudioEdl := AudioEdl + '@ждём';
for I := 0 to High(FPreviewOverlayWindows) do
AudioEdl := AudioEdl + Format('|%s@%d:%d:%s-%s',
[FPreviewOverlayWindows[I].Filter,
@ -2552,6 +2612,12 @@ begin
{ Внешний звук назначается до загрузки: он часть того, что открывается. }
FPlayer.SetAudioSources(FPreviewAudioUrls);
FPlayer.SetVideoOverlay(FPreviewOverlayUrl, FPreviewOverlayWindows);
{ Наложение — самая хрупкая часть предпросмотра: оно живёт отдельной
дорожкой, которой может не оказаться. Пишем в журнал, сколько окон
показа и готовы ли файлы, иначе «картинки нет» нечем объяснить. }
if FPreviewOverlayUrl <> '' then
Log(Format('Наложение: окон %d, файлы %s', [Length(FPreviewOverlayWindows),
BoolToStr(FPreviewOverlayReady, 'готовы', 'ещё делаются')]));
FPlayer.LoadUrl(Edl, Format('start=%s,pause=%s',
[SecondsToStr(Pos), BoolToStr(WasPaused, 'yes', 'no')]));
FLastLoadedEdl := Edl;
@ -3919,9 +3985,19 @@ begin
end;
procedure TMainForm.TimelineSelectionChange(Sender: TObject);
var
Line: string;
begin
SetStatus(Format('Выделено %d %s', [FProject.SelectedCount,
Plural(FProject.SelectedCount, 'клип', 'клипа', 'клипов')]), True);
Line := Format('Выделено %d %s', [FProject.SelectedCount,
Plural(FProject.SelectedCount, 'клип', 'клипа', 'клипов')]);
{ В месте наезда под указателем несколько клипов, и щелчок перебирает
их по очереди. Молчать об этом нельзя: человек видит, что выделился
не тот клип, и не знает, что второй щелчок возьмёт следующий. }
if FTimeline.PickedCount > 1 then
Line := Line + Format('. Под указателем %d %s — щелчок берёт следующий',
[FTimeline.PickedCount,
Plural(FTimeline.PickedCount, 'клип', 'клипа', 'клипов')]);
SetStatus(Line, True);
end;
procedure TMainForm.PlayerLog(Sender: TObject; const APrefix, ALevel, AText: string);

View file

@ -1093,6 +1093,10 @@ begin
if Graph = FLastGraph then
Exit;
FLastGraph := Graph;
{ В журнал пишется всегда, в том числе пустая: по ней видно, собралось
ли наложение. Пустая цепочка при заявленном наложении означает, что
дорожки для него у плеера нет. }
Note('Цепочка показа: ' + Graph);
mpv_set_property_string(FHandle, 'lavfi-complex', PAnsiChar(Graph));
end;
@ -1103,7 +1107,7 @@ end;
она заменила бы картинку. Её дело — быть входом цепочки. }
procedure TMpvPlayer.AttachOverlay;
var
Count, I: Integer;
Count, I, Code: Integer;
Kind, Ext, Id: string;
begin
if FHandle = nil then
@ -1113,7 +1117,12 @@ begin
FOverlayVid := '';
if (FOverlayUrl <> '') and (Length(FOverlayWindows) > 0) then
Command(['video-add', FOverlayUrl, 'auto']);
begin
Code := Command(['video-add', FOverlayUrl, 'auto']);
if Code < 0 then
Note(Format('Дорожку наложения добавить не удалось: video-add вернул %d',
[Code]));
end;
Count := StrToIntDef(GetPropString('track-list/count'), 0);
for I := 0 to Count - 1 do
@ -1133,6 +1142,13 @@ begin
else if FBaseVid = '' then
FBaseVid := Id;
end;
{ Пустая дорожка наложения при заявленном наложении — самый частый
способ остаться без картинки в окне. Пишем, что увидели. }
if (FOverlayUrl <> '') and (Length(FOverlayWindows) > 0) and
((FOverlayVid = '') or (FBaseVid = '')) then
Note(Format('Наложение не подключилось: дорожек %d, основа "%s", ' +
'наложение "%s"', [Count, FBaseVid, FOverlayVid]));
end;
{ Субтитры подключаются двумя путями, и оба нужны.

View file

@ -247,6 +247,15 @@ begin
FTransform := ATransform;
FPreviewFile := APreviewFile;
{ Кадр пишется в кэш проекта, а его может ещё не быть: кэш заводится
при первой сборке вставок, а кадрирование открывают и раньше. Тогда
ffmpeg падал с «No such file or directory», и окно показывало «Не
удалось получить кадр» с цепочкой фильтров — выглядело так, будто
дело в самой цепочке. }
if (FPreviewFile <> '') and
(not ForceDirectories(ExtractFilePath(FPreviewFile))) then
FPreviewFile := GetTempFileName;
lblSource.Caption := Format('%s: %dx%d → кадр проекта %dx%d',
[ExtractFileName(APath), AInfo.Width, AInfo.Height, ADstWidth, ADstHeight]);

View file

@ -221,6 +221,8 @@ type
property Selected: Boolean read FSelected write FSelected;
end;
TClipArray = array of TClip;
{ Что живёт на дорожке. Картинка и звук разделены, потому что их можно
двигать порознь: звук из одного места монтажа под картинкой из
другого — обычное дело. }
@ -244,6 +246,13 @@ type
procedure Extract(AClip: TClip);
procedure Sort;
function ClipAt(const ATime: Double): TClip;
{ Все клипы дорожки, накрывающие это время, сверху вниз.
Обычно он один: клипы дорожки стоят встык. Но наезд двух клипов —
это заказ на переход, и в месте наезда их два. Щелчок по такому
месту должен уметь добраться до каждого, иначе нижний не взять ни
выделением, ни перетаскиванием. }
function ClipsAt(const ATime: Double): TClipArray;
function IndexOf(AClip: TClip): Integer;
function EndTime: Double;
property Clips[AIndex: Integer]: TClip read GetClip; default;
@ -632,15 +641,30 @@ function TTrack.ClipAt(const ATime: Double): TClip;
var
I: Integer;
begin
{ Клипы отсортированы и не перекрываются в пределах дорожки, так что
достаточно линейного прохода. Дорожка редко содержит столько клипов,
чтобы двоичный поиск дал заметную выгоду. }
{ Клипы отсортированы, так что достаточно линейного прохода. Дорожка
редко содержит столько клипов, чтобы двоичный поиск дал заметную
выгоду. Верхним считается первый по списку: в наезде побеждает он. }
for I := 0 to FClips.Count - 1 do
if TClip(FClips[I]).Covers(ATime) then
Exit(TClip(FClips[I]));
Result := nil;
end;
function TTrack.ClipsAt(const ATime: Double): TClipArray;
var
I, N: Integer;
begin
Result := nil;
N := 0;
for I := 0 to FClips.Count - 1 do
if TClip(FClips[I]).Covers(ATime) then
begin
SetLength(Result, N + 1);
Result[N] := TClip(FClips[I]);
Inc(N);
end;
end;
function TTrack.IndexOf(AClip: TClip): Integer;
begin
Result := FClips.IndexOf(AClip);

View file

@ -159,6 +159,14 @@ type
FOnSelectionModeChange: TNotifyEvent;
FGroupClips: array of TClip;
FGroupOrigStarts: array of Double;
{ Клипы, накрытые последним нажатием, и выбранный из них. По ним
щелчок по месту наезда переходит к следующему клипу. }
FPickCands: TClipArray;
FPickClip: TClip;
FPickCount: Integer;
{ Нажатие попало в клип, который уже был выделен. Значит это второй
щелчок по тому же месту, и он означает «покажи следующий». }
FPickWasSelected: Boolean;
{ Дорожки, чьи клипы поехали вместе с перетаскиваемым: связка ведёт
звук за картинкой, схлопывание ведёт соседей, и лежат они на чужих
дорожках. Список нужен, чтобы после каждого сдвига вернуть им
@ -197,6 +205,13 @@ type
function TrackTop(AIndex: Integer): Integer;
function TrackIndexAt(const AY: Integer): Integer;
function ClipAtPoint(const AX, AY: Integer; out ATrack: TTrack): TClip;
{ Тот же выбор, но с перебором: повторный щелчок в то же место берёт
следующий клип из накрывающих эту точку. Только для нажатия — от
наведения перебирать нельзя. }
function PickClipAt(const AX, AY: Integer; out ATrack: TTrack): TClip;
{ Перейти к следующему из наехавших клипов. Зовётся при отпускании,
когда стало ясно, что это щелчок, а не протяжка. }
procedure CycleToNextPicked;
function EdgeAtPoint(const AX: Integer; AClip: TClip): Integer;
{ Что под указателем в полосе заголовков и на какой дорожке. }
function HeaderPartAt(const AX, AY: Integer; out ATrackIndex: Integer): THeaderPart;
@ -452,6 +467,10 @@ type
для строки состояния: дыра могла найтись не на той дорожке, где
стоял указатель, и молча двигать чужую дорожку нельзя. }
property LastGapTrack: string read FLastGapTrack;
{ Сколько клипов накрывал последний щелчок. Больше одного — значит
там наезд, и следующий щелчок в то же место возьмёт следующий клип.
Нужно окну: перебор, о котором не сказали, — тайное знание. }
property PickedCount: Integer read FPickCount;
{ Правка идёт прямо сейчас: клип держат мышью, тянут за край или за
ручку. Окно по этому признаку откладывает тяжёлую сборку — считать
наложение, пока расстановка меняется каждым движением руки, значит
@ -694,6 +713,87 @@ begin
Result := ATrack.ClipAt(XToTime(AX));
end;
{ Какой из наехавших клипов берёт нажатие.
Наезд двух клипов на одной дорожке — это заказ на переход, то есть
обычное дело. Нажатие всегда попадало в верхний по списку, и нижний
нельзя было ни выделить, ни потащить: чтобы до него добраться,
приходилось сперва убирать верхний.
Правило простое: если один из накрытых клипов уже выделен — берём его.
Тогда протяжка тащит именно то, что человек перед этим выбрал. Иначе
берём верхний, как и раньше.
Переход к следующему происходит не здесь, а при отпускании
(`CycleToNextPicked`): пока кнопка не отпущена, неизвестно, щелчок это
или начало протяжки, а менять клип под рукой посреди протяжки нельзя. }
function TTimelineControl.PickClipAt(const AX, AY: Integer;
out ATrack: TTrack): TClip;
var
I, Idx: Integer;
begin
Result := nil;
ATrack := nil;
FPickCands := nil;
FPickClip := nil;
FPickCount := 0;
FPickWasSelected := False;
if (FProject = nil) or (AX < FHeaderWidth) then
Exit;
Idx := TrackIndexAt(AY);
if Idx < 0 then
Exit;
ATrack := FProject.Tracks[Idx];
FPickCands := ATrack.ClipsAt(XToTime(AX));
FPickCount := Length(FPickCands);
if FPickCount = 0 then
Exit;
Result := FPickCands[0];
for I := 0 to High(FPickCands) do
if FPickCands[I].Selected then
begin
Result := FPickCands[I];
FPickWasSelected := True;
Break;
end;
FPickClip := Result;
end;
{ Щелчок по уже выделенному клипу в месте наезда показывает следующий.
Второй щелчок по тому же месту означает «не этот, покажи другой». После
последнего снова верхний — так перебираются хоть три клипа.
Делается на отпускании и только если мышь не двигали: иначе это была
протяжка, и клип менять нельзя. }
procedure TTimelineControl.CycleToNextPicked;
var
I: Integer;
Next: TClip;
begin
if (FProject = nil) or (FPickCount < 2) or (not FPickWasSelected) then
Exit;
Next := nil;
for I := 0 to High(FPickCands) do
if FPickCands[I] = FPickClip then
begin
Next := FPickCands[(I + 1) mod FPickCount];
Break;
end;
if (Next = nil) or (Next = FPickClip) then
Exit;
FProject.ClearSelection;
Next.Selected := True;
FPickClip := Next;
if Assigned(FOnSelectionChange) then
FOnSelectionChange(Self);
Invalidate;
end;
{ Минус один — левый край, плюс один — правый, ноль — середина. }
function TTimelineControl.EdgeAtPoint(const AX: Integer; AClip: TClip): Integer;
var
@ -2337,7 +2437,7 @@ begin
Exit;
end;
Clip := ClipAtPoint(X, Y, Track);
Clip := PickClipAt(X, Y, Track);
if Clip = nil then
begin
@ -3070,7 +3170,12 @@ begin
if FProject <> nil then
FProject.CancelChange;
if WasClipPress then
begin
Playhead := Max(0, SnapPlayhead(XToTime(FPressX)));
{ Щёлкнули по тому же клипу второй раз — значит хотят соседний,
который лежит под ним. }
CycleToNextPicked;
end;
Exit;
end;

View file

@ -57,6 +57,15 @@ function TransformIsIdentity(const AT: TMediaTransform;
function TransformFilter(const AT: TMediaTransform;
ASrcWidth, ASrcHeight, ADstWidth, ADstHeight: Integer): string;
{ Заполняет ли клип кадр проекта целиком.
Нет — значит вокруг него остаётся пустое место, и сборка обкладывает
его чёрными полями. Поле непрозрачно и закрывает собой всё, что лежит
на дорожках ниже, поэтому такой клип надо не подставлять вместо
нижнего, а класть поверх него. }
function TransformFillsFrame(const AT: TMediaTransform;
ASrcWidth, ASrcHeight, ADstWidth, ADstHeight: Integer): Boolean;
{ Цепочка для предпросмотра: вписать любой кадр в кадр проекта.
От TransformFilter отличается двумя вещами. Во-первых, размеры исходника
@ -406,6 +415,26 @@ begin
Result := Chain;
end;
function TransformFillsFrame(const AT: TMediaTransform;
ASrcWidth, ASrcHeight, ADstWidth, ADstHeight: Integer): Boolean;
var
X, Y, W, H: Integer;
begin
{ Размеры неизвестны — считаем, что заполняет: иначе каждый клип без
разобранного файла объявлялся бы накладываемым. }
Result := True;
if (ASrcWidth <= 0) or (ASrcHeight <= 0) or
(ADstWidth <= 0) or (ADstHeight <= 0) then
Exit;
if BuildTransform(AT, ASrcWidth, ASrcHeight, ADstWidth, ADstHeight,
X, Y, W, H) = '' then
Exit;
{ Считаем по тем же числам, что уходят в фильтр: именно их ffmpeg и
обкладывает полями, и ровно эти поля закрывают нижнее видео. }
Result := (X <= 0) and (Y <= 0) and
(X + W >= ADstWidth) and (Y + H >= ADstHeight);
end;
function TransformFilter(const AT: TMediaTransform;
ASrcWidth, ASrcHeight, ADstWidth, ADstHeight: Integer): string;
var

123
tests/cropdialog.sh Executable file
View file

@ -0,0 +1,123 @@
#!/bin/sh
# Окно кадрирования показывает кадр и в проекте, где кэша ещё нет.
#
# Кадр для окна пишется в кэш проекта, а кэш заводится при первой сборке
# вставок. Кадрирование открывают и раньше — тогда ffmpeg падал с «No
# such file or directory», а окно писало «Не удалось получить кадр» и
# показывало цепочку фильтров. Выглядело так, будто дело в цепочке,
# хотя дело было в отсутствующей папке.
#
# Проверяется так: проект без папки кэша, щелчок по кнопке кадрирования
# на клипе, затем смотрим, появился ли файл кадра. Появился — окну было
# что показать.
#
# Нужен настоящий файл съёмки: кадр берётся из него. Путь задаётся
# переменной NLVE_CROP_SOURCE.
#
# Запуск (экран :99 поднят):
# tests/cropdialog.sh [<путь к nlve>]
set -e
cd "$(dirname "$0")/.."
BIN=${1:-$PWD/nlve}
DISP=${NLVE_DISPLAY:-:99}
SRC=${NLVE_CROP_SOURCE:-/mnt/02F6E7F6F6E7E7BD/writable2/kamenolomni2/DJI_20261010143437_0044_D.MP4}
WORK=$PWD/test/кадр
PROJ=$WORK/проверка.nlve
[ -f "$SRC" ] || { echo "нет съёмки $SRC — задай NLVE_CROP_SOURCE"; exit 2; }
export DISPLAY=$DISP
reachable() { timeout 5 xdotool getdisplaygeometry >/dev/null 2>&1; }
if ! reachable; then
for p in $(pgrep -x Xvfb); do
A=$(tr '\0' '\n' < /proc/$p/cmdline | grep -A1 -x -- -auth | tail -1)
[ -f "$A" ] || continue
XAUTHORITY=$A; export XAUTHORITY
reachable && break
done
fi
reachable ||
{ echo "нет доступа к экрану $DISP: подними его (xvfb-run) и повтори"; exit 2; }
rm -rf "$WORK"
mkdir -p "$WORK"
python3 - "$SRC" "$PROJ" <<'PY'
import json, sys
исходник, куда = sys.argv[1], sys.argv[2]
инфо = {'valid': True, 'kind': 'video', 'has_video': True,
'video_codec': 'hevc', 'width': 2688, 'height': 1512,
'fps_num': 60000, 'fps_den': 1001, 'pix_fmt': 'yuv420p',
'duration': 2466.0, 'has_audio': True, 'audio_codec': 'aac',
'sample_rate': 48000, 'channels': 2}
json.dump({
'version': 1, 'next_id': 20,
'master': dict(инфо, video_bitrate=55000000),
'pool': [{'id': 'm1', 'path': исходник, 'normalized_path': '',
'info': инфо}],
'tracks': [
{'id': 't1', 'name': 'V1', 'kind': 'video', 'clips': [
{'id': 'c1', 'media': 'm1', 'kind': 'video', 'src_in': 447.0,
'tl_start': 0.0, 'duration': 20.0, 'edge_in': 'snap',
'edge_out': 'snap', 'group': '', 'volume': 1.0,
'opacity': 1.0, 'fade_in': 0.0, 'fade_out': 0.0,
# Увеличение со смещением: цепочка выходит с crop, как в
# разобранном случае.
'transform': {'fit': 'fit', 'rotation': 0, 'flip_h': False,
'flip_v': False, 'zoom': 1.98,
'offset_x': 0.49, 'offset_y': -0.49},
'text': ''}]},
{'id': 't2', 'name': 'A1', 'kind': 'audio', 'clips': []}],
'settings': {}
}, open(куда, 'w'), ensure_ascii=False)
print(' проект на 20 секунд, увеличение 198%, папки кэша нет')
PY
for p in $(pgrep -x nlve); do
{ tr "\0" "\n" < /proc/$p/environ; } 2>/dev/null | grep -qx "DISPLAY=$DISP" && kill $p
done
sleep 1
SP=$WORK/профиль
mkdir -p "$SP"
XDG_CONFIG_HOME=$SP XDG_CACHE_HOME=$SP XDG_DATA_HOME=$SP \
setsid "$BIN" "$PROJ" >/dev/null 2>&1 < /dev/null &
sleep 18
X="timeout 8 xdotool"
WID=
for w in $($X search --name "проверка.nlve" 2>/dev/null); do
xwininfo -id "$w" 2>/dev/null | grep -q IsViewable && { WID=$w; break; }
done
[ -n "$WID" ] || { echo "живое окно редактора не найдено"; exit 2; }
$X windowmove "$WID" 0 0 windowsize "$WID" 1270 990
sleep 3
# Папку кэша убираем прямо перед открытием окна. Иначе её успевает
# создать заготовка чёрной вставки, которая делается при открытии
# проекта, и проверка меряет не то: нужен именно случай, когда к моменту
# открытия кадрирования папки нет.
rm -rf "$WORK/проверка.nlve-cache"
# Кнопка кадрирования — квадратик в правом нижнем углу клипа. Клип занят
# всю ширину окна, поэтому кнопка у правого края дорожки.
$X mousemove 1257 531 click 1
sleep 7
DLG=
for w in $($X search --name "Кадрирование" 2>/dev/null); do
xwininfo -id "$w" 2>/dev/null | grep -q IsViewable && { DLG=$w; break; }
done
SHOT=$WORK/проверка.nlve-cache/pancrop.png
if [ -f "$SHOT" ]; then
echo " ok: кадр получен ($(stat -c %s "$SHOT") байт), папка кэша создана"
RC=0
else
echo " ПЛОХО: кадра нет — окну нечего показать"
[ -n "$DLG" ] || echo " (окно кадрирования вообще не открылось)"
RC=1
fi
for p in $(pgrep -x nlve); do
{ tr "\0" "\n" < /proc/$p/environ; } 2>/dev/null | grep -qx "DISPLAY=$DISP" && kill $p
done
exit $RC

181
tests/imagecrop.sh Executable file
View file

@ -0,0 +1,181 @@
#!/bin/sh
# Своё кадрирование картинки доходит до готового ролика.
#
# Окно «Кадрирование и поворот» показывало картинку уменьшенной и
# сдвинутой, а в предпросмотре и в ролике она шла во весь кадр. Причина
# была в уплощении: преобразование ставилось только обычному видео, а у
# картинки своя ветка, и там его не было вовсе. Кусок из картинки делался
# по жёсткой цепочке «вписать целиком плюс поля», про клип она ничего не
# знала.
#
# Проверяется сквозь всю дорогу: картинка делается на месте, кладётся
# одним клипом с уменьшением и смещением вправо, собирается секунда
# ролика. В готовом кадре смотрим на точки: слева картинки быть не
# должно, справа — должна.
#
# Запуск: tests/imagecrop.sh [<путь к nlvetool>]
set -e
cd "$(dirname "$0")/.."
TOOL=${1:-$PWD/tests/nlvetool}
WORK=$PWD/test/картинка
FF=${FFMPEG:-/home/chaiman/bin/ffmpeg}
[ -x "$TOOL" ] || { echo "нет $TOOL — собери: ./build.sh test"; exit 2; }
[ -x "$FF" ] || FF=ffmpeg
rm -rf "$WORK"
mkdir -p "$WORK"
# Картинка 900x1200, как у настоящей рекламной вертикали: сплошной цвет,
# чтобы по точке было видно, попала она в кадр или нет.
"$FF" -hide_banner -loglevel error -y -f lavfi -i color=c=green:s=900x1200 \
-frames:v 1 "$WORK/кадр.png"
python3 - "$WORK" <<'PY'
import json, sys, os
W = sys.argv[1]
кадр = {'valid': True, 'kind': 'video', 'has_video': True,
'width': 1920, 'height': 1080, 'fps_num': 30, 'fps_den': 1,
'video_codec': 'h264', 'pix_fmt': 'yuv420p',
'has_audio': True, 'audio_codec': 'aac',
'sample_rate': 48000, 'channels': 2, 'video_bitrate': 8000000}
проект = {
'version': 1, 'next_id': 100, 'master': кадр,
'pool': [{'id': 'm1', 'path': os.path.join(W, 'кадр.png'),
'normalized_path': '',
'info': {'valid': True, 'kind': 'image', 'has_video': True,
'video_codec': 'png', 'width': 900, 'height': 1200,
'fps_num': 25, 'fps_den': 1, 'pix_fmt': 'rgb24',
'duration': 0.0, 'has_audio': False}}],
'tracks': [
{'id': 't1', 'name': 'V1', 'kind': 'video', 'clips': [
{'id': 'c1', 'media': 'm1', 'kind': 'image',
'src_in': 0.0, 'tl_start': 0.0, 'duration': 1.0,
'edge_in': 'snap', 'edge_out': 'snap', 'group': '',
'volume': 1.0, 'opacity': 1.0, 'fade_in': 0.0, 'fade_out': 0.0,
# Уменьшить вдвое и сдвинуть вправо — как в окне кадрирования.
'transform': {'fit': 'fit', 'rotation': 0, 'flip_h': False,
'flip_v': False, 'zoom': 0.5, 'offset_x': 0.6,
'offset_y': 0.0},
'text': ''}]},
{'id': 't2', 'name': 'A1', 'kind': 'audio', 'clips': []}
],
'settings': {}
}
json.dump(проект, open(os.path.join(W, 'картинка.nlve'), 'w'),
ensure_ascii=False)
print(' картинка 900x1200, кадр проекта 1920x1080, увеличение 50%,'
' смещение вправо 60%')
PY
OUT=$WORK/ролик.mkv
"$TOOL" render "$WORK/картинка.nlve" "$OUT" >"$WORK/сборка.log" 2>&1 ||
{ echo " сборка не прошла, смотри $WORK/сборка.log"; tail -5 "$WORK/сборка.log"; exit 1; }
"$FF" -hide_banner -loglevel error -y -i "$OUT" -frames:v 1 "$WORK/готовый.png"
python3 - "$WORK" <<'PY'
import sys, os
from PIL import Image
W = sys.argv[1]
im = Image.open(os.path.join(W, 'готовый.png')).convert('RGB')
ш, в = im.size
print(' кадр ролика: %dx%d' % (ш, в))
def зелено(точка):
r, g, b = точка
return g > 80 and g > r + 40 and g > b + 40
# При увеличении 50% и смещении вправо 60% картинка занимает примерно
# правую пятую часть кадра по ширине и среднюю половину по высоте
# (цепочка сборки: scale=406:540,pad=1920:1080:1514:270). Поэтому слева и
# у самого верха обязано быть чёрное поле, а в правой части — картинка.
слева = im.getpixel((int(ш * 0.12), в // 2))
справа = im.getpixel((int(ш * 0.90), в // 2))
верх = im.getpixel((ш // 2, 6))
print(' слева %s, справа %s, вверху %s' % (слева, справа, верх))
плохо = 0
if зелено(слева):
print(' ПЛОХО: картинка слева — смещение не применилось')
плохо = 1
if not зелено(справа):
print(' ПЛОХО: справа картинки нет — она встала не туда')
плохо = 1
if зелено(верх):
print(' ПЛОХО: картинка достаёт до верха — уменьшение не применилось')
плохо = 1
if плохо:
raise SystemExit(1)
print(' ok: картинка уменьшена и сдвинута вправо, как в окне кадрирования')
PY
# Второй случай: под картинкой лежит видео. Тогда пустое место вокруг неё
# не чёрное поле, а это самое видео: клип, не заполняющий кадр, ложится
# поверх нижнего, а не подменяет его собой.
echo
echo " то же, но с видео на дорожке ниже"
"$FF" -hide_banner -loglevel error -y -f lavfi -i color=c=red:s=1920x1080:rate=30 \
-t 2 -c:v libx264 -pix_fmt yuv420p -g 15 "$WORK/низ.mp4"
python3 - "$WORK" <<'PY'
import json, os, sys
W = sys.argv[1]
d = json.load(open(os.path.join(W, 'картинка.nlve')))
d['pool'].append({'id': 'm2', 'path': os.path.join(W, 'низ.mp4'),
'normalized_path': '',
'info': {'valid': True, 'kind': 'video', 'has_video': True,
'video_codec': 'h264', 'width': 1920,
'height': 1080, 'fps_num': 30, 'fps_den': 1,
'pix_fmt': 'yuv420p', 'duration': 2.0,
'has_audio': False}})
низ = {'id': 'c2', 'media': 'm2', 'kind': 'video', 'src_in': 0.0,
'tl_start': 0.0, 'duration': 1.0, 'edge_in': 'snap',
'edge_out': 'snap', 'group': '', 'volume': 1.0, 'opacity': 1.0,
'fade_in': 0.0, 'fade_out': 0.0,
'transform': {'fit': 'fit', 'rotation': 0, 'flip_h': False,
'flip_v': False, 'zoom': 1.0, 'offset_x': 0.0,
'offset_y': 0.0},
'text': ''}
# Вторая видеодорожка идёт в списке после первой, то есть ниже неё.
d['tracks'].insert(1, {'id': 't3', 'name': 'V2', 'kind': 'video',
'clips': [низ]})
json.dump(d, open(os.path.join(W, 'поверх.nlve'), 'w'), ensure_ascii=False)
PY
OUT2=$WORK/поверх.mkv
"$TOOL" render "$WORK/поверх.nlve" "$OUT2" >"$WORK/сборка2.log" 2>&1 ||
{ echo " сборка не прошла, смотри $WORK/сборка2.log"; tail -5 "$WORK/сборка2.log"; exit 1; }
"$FF" -hide_banner -loglevel error -y -i "$OUT2" -frames:v 1 "$WORK/готовый2.png"
python3 - "$WORK" <<'PY'
import sys, os
from PIL import Image
W = sys.argv[1]
im = Image.open(os.path.join(W, 'готовый2.png')).convert('RGB')
ш, в = im.size
def зелено(т):
r, g, b = т
return g > 80 and g > r + 40 and g > b + 40
def красно(т):
r, g, b = т
return r > 80 and r > g + 40 and r > b + 40
слева = im.getpixel((int(ш * 0.12), в // 2))
справа = im.getpixel((int(ш * 0.90), в // 2))
print(' слева %s, справа %s' % (слева, справа))
плохо = 0
if not красно(слева):
print(' ПЛОХО: слева не нижнее видео — картинка закрыла его полями')
плохо = 1
if not зелено(справа):
print(' ПЛОХО: справа нет картинки')
плохо = 1
if плохо:
raise SystemExit(1)
print(' ok: картинка легла поверх видео, чёрных полей нет')
PY

View file

@ -736,6 +736,58 @@ begin
end;
end;
{ В месте наезда под указателем несколько клипов.
Наезд двух клипов на одной дорожке — заказ на переход, то есть обычное
дело. Пока отбор возвращал только первый по списку, нижний клип нельзя
было ни выделить, ни потащить. }
procedure CheckClipsAt;
var
Proj: TProject;
Info: TMediaInfo;
Media: TMediaItem;
A, B: TClip;
Got: TClipArray;
begin
WriteLn;
WriteLn('Клипы под одной точкой');
WriteLn;
Info := TestInfo;
Proj := TProject.Create;
try
Proj.Clear;
Proj.AddTrack('V1');
Media := Proj.AddMedia('/несуществующий/видео.mp4', Info);
Proj.Master := Info;
{ Второй наехал на первый на две секунды: 0..10 и 8..18. }
A := Proj.AddClip(Proj.Tracks[0], Media, 0, 10, 0);
B := Proj.AddClip(Proj.Tracks[0], Media, 0, 10, 8);
Got := Proj.Tracks[0].ClipsAt(9);
CheckTrue('в наезде под точкой двое: ' + IntToStr(Length(Got)),
Length(Got) = 2);
if Length(Got) = 2 then
begin
CheckTrue('первым идёт верхний по списку', Got[0] = A);
CheckTrue('вторым — тот, что под ним', Got[1] = B);
end;
Got := Proj.Tracks[0].ClipsAt(3);
CheckTrue('вне наезда клип один: ' + IntToStr(Length(Got)),
(Length(Got) = 1) and (Got[0] = A));
Got := Proj.Tracks[0].ClipsAt(100);
CheckTrue('за концом монтажа никого', Length(Got) = 0);
{ Прежний отбор остаётся прежним: он берёт верхний. }
CheckTrue('ClipAt по-прежнему даёт верхний',
Proj.Tracks[0].ClipAt(9) = A);
finally
Proj.Free;
end;
end;
{ Клип притягивается к границе и хвостом, а не только головой.
Подводят кусок к началу соседа слева: голова встаёт в свободное место,
@ -1155,6 +1207,7 @@ begin
CheckFrameGridRepair;
CheckSplitAndPlayhead;
CheckMakeRoom;
CheckClipsAt;
CheckSnapTail;
CheckSplitAllTracks;
CheckGapTolerance;

View file

@ -573,6 +573,11 @@ begin
else
KindName := 'видео';
end;
{ Слои видно сразу: по ним отличается кусок, который собирается
наложением, от куска, который просто копируется. }
if Length(ASegments[I].Layers) > 0 then
KindName := KindName + Format(' (слоёв: %d)',
[Length(ASegments[I].Layers)]);
WriteLn(Format(' [%d] %s из %s длина %s',
[I, KindName, SecondsToStr(ASegments[I].SrcStart),
SecondsToStr(ASegments[I].Duration)]));
@ -589,9 +594,11 @@ var
Clip: TClip;
Key: TColorKey;
Tr: TMediaTransform;
Info: TMediaInfo;
Pic: TMediaItem;
I, N: Integer;
Step, At, Total: Double;
TempFile, Err: string;
TempFile, Err, PlainPath: string;
begin
WriteLn('Проверка уплощения монтажа');
WriteLn;
@ -1068,6 +1075,99 @@ begin
end;
WriteLn;
{ --- Кадрирование картинки доходит до куска --- }
WriteLn(' Кадрирование картинки');
Proj := MakeProject(1, Media);
try
{ Кадр проекта обязателен: без него вписывать не во что, и цепочка
выходит пустой. }
Proj.Master := Media.Info;
Info := Default(TMediaInfo);
Info.Valid := True;
Info.Kind := mkImage;
Info.HasVideo := True;
Info.VideoCodec := 'webp';
Info.Width := 900;
Info.Height := 1200;
Pic := Proj.AddMedia('/несуществующий/картинка.webp', Info);
{ Сначала без настройки: как лежала, так и вписывается. }
Clip := Proj.AddClip(Proj.Tracks[0], Pic, 0, 5, 0);
Clip.Kind := ckImage;
FlattenStream(Proj, tkVideo, 0, Segs, Missing);
Check(Length(Missing) = 1, 'заявка на кусок из картинки одна');
if Length(Missing) = 1 then
begin
PlainPath := Missing[0].TargetPath;
WriteLn(' без кадрирования: ', Missing[0].Filter);
end;
{ Теперь с увеличением и смещением — как в окне кадрирования. }
Tr := DefaultTransform;
Tr.Zoom := 0.58;
Tr.OffsetX := 0.45;
Clip.Transform := Tr;
FlattenStream(Proj, tkVideo, 0, Segs, Missing);
Check(Length(Missing) = 1, 'заявка по-прежнему одна');
if Length(Missing) = 1 then
begin
WriteLn(' с кадрированием: ', Missing[0].Filter);
Check(Missing[0].Filter <> '',
'кадрирование попало в заявку');
Check(Pos('pad=', Missing[0].Filter) > 0,
'уменьшенная картинка обкладывается полями');
{ Путь выводится из содержимого заявки: иначе второй клип получил
бы из кэша чужой кусок, посчитанный для первого. }
Check(Missing[0].TargetPath <> PlainPath,
'кусок с кадрированием — отдельный файл');
end;
{ Под картинкой появляется видео — и она должна лечь поверх него, а
не закрыть его своими чёрными полями. }
Proj.AddTrack('V2');
Proj.AddClip(Proj.Tracks[1], Media, 0, 5, 0);
FlattenStream(Proj, tkVideo, 0, Segs, Missing);
Check((Length(Segs) = 1) and (Length(Segs[0].Layers) = 2),
'кусок собирается слоями: нижнее видео и картинка поверх');
if (Length(Segs) = 1) and (Length(Segs[0].Layers) = 2) then
Check(Pos('pad=', Segs[0].Layers[1].FitFilter) = 0,
'у верхнего слоя полей нет');
{ Предпросмотр идёт тем же путём, что и сборка: участок сводится
заранее. Плееру его не отдаём — цепочка наложения работает всё
время показа и на большом кадре уводит видео от звука. }
FlattenPreview(Proj, Segs, AudioSegs, Overlays, Missing, True);
Check(Length(Overlays) = 0,
'непрозрачная картинка плееру не отдаётся');
Check((Length(Segs) = 1) and (Length(Segs[0].Layers) = 2),
'в предпросмотре тот же кусок со слоями, что и в сборке');
{ А вот прозрачная уходит плееру — и тоже готовым куском, а не самим
файлом: своих декодеров у него хватает не на всё, а у неподвижного
кадра нет длины. }
Clip.Opacity := 0.5;
FlattenPreview(Proj, Segs, AudioSegs, Overlays, Missing, True);
Check(Length(Overlays) = 1, 'прозрачная картинка ушла в накладываемые');
if Length(Overlays) = 1 then
begin
Check(Pos('.nlve-cache', Overlays[0].FilePath) > 0,
'показывается готовый кусок, а не сам файл');
Check(Pos('pad=', Overlays[0].Filter) = 0,
'полей в цепочке наложения нет');
Check(Overlays[0].SrcLength > 0,
'длина куска известна: заполнитель нарежут по ней');
PlainPath := '';
for I := 0 to High(Missing) do
if Missing[I].TargetPath = Overlays[0].FilePath then
PlainPath := Missing[I].TargetPath;
Check(PlainPath <> '', 'этот кусок заказан к созданию');
end;
finally
Proj.Free;
end;
WriteLn;
WriteLn;
if FailCount = 0 then
begin

141
tests/pickcycle.sh Executable file
View file

@ -0,0 +1,141 @@
#!/bin/sh
# Повторный щелчок в месте наезда берёт следующий клип.
#
# Наезд двух клипов на одной дорожке — это заказ на переход, то есть
# обычное дело. Щелчок же всегда попадал в один и тот же клип, верхний по
# списку, и до нижнего было не добраться: ни выделить, ни потащить. Чтобы
# взять его, приходилось сначала убирать верхний.
#
# Проверяется так: два клипа внахлёст, щелчок по месту наезда, второй
# щелчок туда же, затем протяжка. Уехать обязан ВТОРОЙ клип. Выделение в
# файл проекта не пишется, а позиция пишется, поэтому смотрим по ней.
#
# Запуск (экран :99 поднят):
# tests/pickcycle.sh [<путь к nlve>]
set -e
cd "$(dirname "$0")/.."
BIN=${1:-$PWD/nlve}
DISP=${NLVE_DISPLAY:-:99}
SRC=$PWD/test/поток/пара.nlve
PROJ=$PWD/test/поток/перебор.nlve
[ -f "$SRC" ] || { echo "нет монтажа $SRC"; exit 2; }
export DISPLAY=$DISP
reachable() { timeout 5 xdotool getdisplaygeometry >/dev/null 2>&1; }
if ! reachable; then
for p in $(pgrep -x Xvfb); do
A=$(tr '\0' '\n' < /proc/$p/cmdline | grep -A1 -x -- -auth | tail -1)
[ -f "$A" ] || continue
XAUTHORITY=$A; export XAUTHORITY
reachable && break
done
fi
reachable ||
{ echo "нет доступа к экрану $DISP: подними его (xvfb-run) и повтори"; exit 2; }
# Два клипа внахлёст: 0..10 и 8..18. Наезд с 8 по 10.
python3 - "$SRC" "$PROJ" <<'PY'
import json, sys, copy
d = json.load(open(sys.argv[1]))
V = next(t for t in d['tracks'] if t['name'] == 'V1')
A = next(t for t in d['tracks'] if t['name'] == 'A1')
ов, оа = copy.deepcopy(V['clips'][0]), copy.deepcopy(A['clips'][0])
def клип(обр, ид, группа, старт, длина, вход):
c = copy.deepcopy(обр)
c.update(id=ид, group=группа, tl_start=старт, duration=длина,
src_in=вход, fade_in=0.0, fade_out=0.0)
return c
V['clips'] = [клип(ов, 'a_v', 'ga', 0.0, 10.0, 100.0),
клип(ов, 'b_v', 'gb', 8.0, 10.0, 200.0)]
A['clips'] = [клип(оа, 'a_a', 'ga', 0.0, 10.0, 100.0),
клип(оа, 'b_a', 'gb', 8.0, 10.0, 200.0)]
json.dump(d, open(sys.argv[2], 'w'), ensure_ascii=False)
print(' клипы 0..10 и 8..18, наезд с 8 по 10')
PY
for p in $(pgrep -x nlve); do
tr '\0' '\n' < /proc/$p/environ 2>/dev/null | grep -qx "DISPLAY=$DISP" && kill $p
done
sleep 1
SP=$PWD/test/поток/профиль-перебор
rm -rf "$SP"; mkdir -p "$SP"
rm -rf "$PROJ-cache"
rm -f "$PROJ.autosave"
XDG_CONFIG_HOME=$SP XDG_CACHE_HOME=$SP XDG_DATA_HOME=$SP \
setsid "$BIN" "$PROJ" >/dev/null 2>&1 < /dev/null &
sleep 14
X="timeout 8 xdotool"
WID=
for w in $($X search --name "перебор.nlve" 2>/dev/null); do
xwininfo -id "$w" 2>/dev/null | grep -q IsViewable && { WID=$w; break; }
done
[ -n "$WID" ] || { echo "живое окно редактора не найдено"; exit 2; }
$X windowmove "$WID" 0 0 windowsize "$WID" 1270 990
sleep 2
ROW=${NLVE_ROW:-505}
starts() {
$X key ctrl+s
sleep 2
python3 -c "
import json
d = json.load(open('$PROJ'))
for t in d['tracks']:
if t['name'] != 'V1': continue
print(' '.join('%s=%.3f' % (c['id'], c['tl_start'])
for c in sorted(t['clips'], key=lambda c: c['id'])))
"
}
echo " до правок: $(starts)"
# Место наезда: середина отрезка 8..10 с. Замерено по линейке в окне
# 1270x990 на этом монтаже: ноль в точке 592, в секунде 56.3 точки.
PX=${NLVE_PX:-1098}
# Первый щелчок берёт верхний, второй — переводит выделение на нижний.
# Мышь между щелчками не двигаем.
$X mousemove "$PX" "$ROW" click 1; sleep 1
$X click 1; sleep 1
# Теперь тащим выбранный клип вправо на заметную величину. Нажатие берёт
# именно выделенный — иначе протяжкой нельзя было бы взять то, что
# только что выбрали щелчками.
$X mousedown 1; sleep 0.3
$X mousemove $((PX + 60)) "$ROW"; sleep 0.3
$X mousemove $((PX + 120)) "$ROW"; sleep 0.4
$X mouseup 1; sleep 1.5
AFTER=$(starts)
echo " после второго щелчка и протяжки: $AFTER"
for p in $(pgrep -x nlve); do
tr '\0' '\n' < /proc/$p/environ 2>/dev/null | grep -qx "DISPLAY=$DISP" && kill $p
done
rm -f "$PROJ.autosave"
python3 - "$PROJ" <<'PY'
import json, sys
d = json.load(open(sys.argv[1]))
V = next(t for t in d['tracks'] if t['name'] == 'V1')
старт = {c['id']: c['tl_start'] for c in V['clips']}
a, b = старт['a_v'], старт['b_v']
# Верхний обязан стоять там, где стоял. Если уехал он — значит второй
# щелчок не переключил, потащили верхний, а нижний уехал следом за ним
# схлопыванием, и выглядит это обманчиво похоже на успех.
if abs(a - 0.0) > 0.05:
print(' ПЛОХО: второй щелчок не переключил — потащили верхний '
'(a_v: 0.000 -> %.3f, b_v: 8.000 -> %.3f)' % (a, b))
raise SystemExit(1)
if abs(b - 8.0) <= 0.05:
print(' ПЛОХО: никто никуда не уехал — проверка ничего не значит')
raise SystemExit(1)
print(' ok: потащили нижний клип (b_v: 8.000 -> %.3f), верхний на месте'
% b)
PY