Compare commits
6 commits
95d92a7582
...
af03ec1b93
| Author | SHA1 | Date | |
|---|---|---|---|
| af03ec1b93 | |||
| 567c518a62 | |||
| 766f1ad196 | |||
| 3d3d02368c | |||
| ffd105a8c0 | |||
| c18b695b89 |
19 changed files with 1196 additions and 41 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -92,3 +92,7 @@ shot*.png
|
|||
|
||||
# Мусор компоновщика Lazarus.
|
||||
link*.res
|
||||
|
||||
# Номер сборки и созданный из него файл: про эту машину, не про историю.
|
||||
/build.number
|
||||
/src/uBuildInfo.inc
|
||||
|
|
|
|||
80
README.md
80
README.md
|
|
@ -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`.
|
||||
Оба файла в репозиторий не входят: номер про эту машину, а не про
|
||||
историю проекта.
|
||||
|
||||
## Структура
|
||||
|
||||
|
|
|
|||
29
build.sh
29
build.sh
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -105,6 +105,14 @@ type
|
|||
к самой картинке, у чёрного пусто. Хранится прямо здесь, чтобы потом
|
||||
не восстанавливать источник обратным перебором. }
|
||||
GenSource: string;
|
||||
{ Для куска из картинки — цепочка, которой она вписывается в кадр
|
||||
проекта: своё увеличение, смещение и поворот клипа. Запекается
|
||||
прямо в сгенерированный кусок, поэтому к самому куску потом
|
||||
применять её нельзя — она уже в нём.
|
||||
|
||||
Отдельно от Filter именно поэтому: Filter применяется к исходнику
|
||||
при пересборке, и смешать их значило бы кадрировать дважды. }
|
||||
GenFilter: string;
|
||||
{ Начало куска должно быть ровно там, где указано, а не на ближайшем
|
||||
опорном кадре. Тогда огрызок от начала до следующего опорного кадра
|
||||
перекодируется, а остальное копируется.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
199
src/uFlatten.pas
199
src/uFlatten.pas
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
{ Субтитры подключаются двумя путями, и оба нужны.
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
123
tests/cropdialog.sh
Executable 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
181
tests/imagecrop.sh
Executable 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
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
141
tests/pickcycle.sh
Executable 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue