From 3d3d02368c311b4f6dc8908000601bd6cdd353ea Mon Sep 17 00:00:00 2001 From: 4aiman Date: Fri, 16 Oct 2026 16:17:30 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9A=D0=BB=D0=B8=D0=BF,=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=BF=D0=BE=D0=BB=D0=BD=D1=8F=D1=8E=D1=89=D0=B8?= =?UTF-8?q?=D0=B9=20=D0=BA=D0=B0=D0=B4=D1=80,=20=D0=BB=D0=BE=D0=B6=D0=B8?= =?UTF-8?q?=D1=82=D1=81=D1=8F=20=D0=BF=D0=BE=D0=B2=D0=B5=D1=80=D1=85=20?= =?UTF-8?q?=D0=BD=D0=B8=D0=B6=D0=BD=D0=B5=D0=B3=D0=BE,=20=D0=B0=20=D0=BD?= =?UTF-8?q?=D0=B5=20=D0=B7=D0=B0=D0=BA=D1=80=D1=8B=D0=B2=D0=B0=D0=B5=D1=82?= =?UTF-8?q?=20=D0=B5=D0=B3=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Уменьшенная и сдвинутая картинка выходила в ролик с чёрными полями, и нижнюю дорожку закрывал не сам клип, а эти поля. Так же вело себя и уменьшенное видео. Между тем уменьшают клип ровно затем, чтобы под ним было видно другое. Теперь «не заполняет кадр» означает то же, что «не сплошной»: наравне с выбитым цветом и прозрачностью. Такой клип уходит в накладываемые — предпросмотр рисует его поверх нижнего силами плеера, сборка собирает участок слоями, где у верхнего слоя цепочка без полей и место, куда он встаёт. Класть не на что — поля остаются полями, как и были. Решение одно на оба пути: IsOverlayClip, и в нём TransformFillsFrame считает по тем же числам, что уходят в фильтр. Заодно две вещи, всплывшие по дороге: — у картинки своя ветка уплощения, и слои там не строились вовсе: клип с прозрачностью в окне накладывался, а в ролике нет. Теперь строятся. — в сборке ветка «готовая вставка» стояла раньше ветки слоёв, и кусок со слоями уходил в неё за несуществующей заготовкой. Заготовка для такого куска больше и не заказывается: это были бы минуты впустую. Проверка tests/imagecrop.sh получила второй случай: под картинкой видео, вокруг неё обязано быть видно его. На прежней сборке там чёрное. --- README.md | 25 ++++++++++++++-- src/uExport.pas | 10 +++++-- src/uFlatten.pas | 71 ++++++++++++++++++++++++++++++++++++++-------- src/uTransform.pas | 29 +++++++++++++++++++ tests/imagecrop.sh | 70 +++++++++++++++++++++++++++++++++++++++++++++ tests/nlvetool.lpr | 5 ++++ 6 files changed, 193 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 0e44239..5029e4f 100644 --- a/README.md +++ b/README.md @@ -527,7 +527,19 @@ Linux потом искала по нему `.dll`. Два клипа **на одной дорожке**, наползшие друг на друга, дают переход: один плавно перетекает в другой. На разных дорожках перекрытие означает не переход, а наложение — там по-прежнему побеждает верхний, если он -непрозрачен. +сплошной. + +Сплошным клип перестаёт быть по трём причинам: выбит цвет, задана +прозрачность или **он не заполняет кадр**. Последнее — то же самое, +сказанное иначе: уменьшенный или сдвинутый клип занимает часть кадра, а +вокруг остаётся пустое место, которое сборка обкладывает чёрными полями. +Поле непрозрачно, и нижнюю дорожку закрывает не сам клип, а его поля — +поэтому такой клип кладётся поверх нижнего, а не вместо него. Уменьшают +клип ровно затем, чтобы увидеть под ним другое. Класть не на что — +остаются поля. + +Отсюда же берётся картинка в углу кадра и два видео рядом: достаточно +уменьшить и сдвинуть клипы на разных дорожках. В месте наезда под указателем два клипа. Первый щелчок берёт верхний, повторный по тому же месту переводит выделение на следующий, после @@ -713,6 +725,11 @@ Linux потом искала по нему `.dll`. «Кадрирование и поворот». Поэтому в кэше лежит столько заготовок, сколько разных кадрирований у этой картинки, и предпросмотр с роликом показывают одно и то же. + + Заготовка нужна не всегда: если картинка не заполняет кадр, а под ней + что-то лежит, участок собирается наложением, и чёрным полям взяться + неоткуда. Заготовка с полями остаётся для случая, когда класть не на + что. 3. Границы кусков кладутся на кадровую сетку проекта: между кадрами времени нет, а звук набирается с точностью до отсчёта, и доли кадра разводят их в стороны. @@ -1050,8 +1067,10 @@ Wine с новой библиотекой не грузилось ничего. Отдельно от них `tests/imagecrop.sh` — сквозная, без окна: делает картинку, кладёт её клипом с уменьшением и смещением, собирает секунду -ролика и смотрит на точки готового кадра. Ей нужны `tests/nlvetool` -(`./build.sh test`), ffmpeg и python с PIL. +ролика и смотрит на точки готового кадра. Случая два: класть не на что — +вокруг картинки чёрные поля; под картинкой видео — вокруг неё видно его, +а не поля. Ей нужны `tests/nlvetool` (`./build.sh test`), ffmpeg и python +с PIL. Последним восьми нужен монтаж в `test/поток/`: `длинный-фейд.nlve` — один клип с затуханием секунд на семьдесят (такой кусок сводится около diff --git a/src/uExport.pas b/src/uExport.pas index 13a8a6f..c682dac 100644 --- a/src/uExport.pas +++ b/src/uExport.pas @@ -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; diff --git a/src/uFlatten.pas b/src/uFlatten.pas index c84afec..264232a 100644 --- a/src/uFlatten.pas +++ b/src/uFlatten.pas @@ -224,6 +224,10 @@ implementation uses Math, md5; +{ Объявлено заранее: уплощение решает по этому признаку, класть ли клип + поверх нижнего, а само правило описано ниже, рядом с предпросмотром. } +function IsOverlayClip(AProject: TProject; AClip: TClip): Boolean; forward; + const { Длительность округляется до миллисекунд: иначе крошечное расхождение в дробной части плодило бы почти одинаковые файлы. } @@ -908,6 +912,25 @@ begin 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 @@ -970,11 +993,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); { Ручное затухание: кусок попал в зону появления или ухода. Слой @@ -1123,6 +1145,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; @@ -1343,12 +1369,33 @@ begin end; end; -{ Клип кладётся поверх, а не побеждает целиком, если он не сплошной: - с выбитым цветом или полупрозрачный. } -function IsOverlayClip(AClip: TClip): Boolean; +{ Клип кладётся поверх, а не побеждает целиком, если он не сплошной. + + Несплошных случая три: выбитый цвет, прозрачность и клип, который не + заполняет кадр. Последний — то же самое, только другими словами: + уменьшенная или сдвинутая картинка занимает часть кадра, а остальное + сборка обкладывает чёрными полями. Поле непрозрачно, и нижнее видео + закрывает собой не картинка, а её поля. Человек же, уменьшая клип, + хочет увидеть под ним то, что на дорожке ниже, — иначе зачем уменьшать. + + Если под клипом ничего нет, поля остаются полями: класть не на что. } +function IsOverlayClip(AProject: TProject; AClip: TClip): Boolean; +var + Item: TMediaItem; begin - Result := (AClip <> nil) and (AClip.Kind in [ckVideo, ckImage]) and - (AClip.ColorKey.Enabled or (AClip.Opacity < 0.999)); + 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; @@ -1407,7 +1454,7 @@ begin for C := 0 to Track.Count - 1 do begin Clip := Track[C]; - if not IsOverlayClip(Clip) then + if not IsOverlayClip(AProject, Clip) then Continue; Item := AProject.FindMedia(Clip.MediaId); if Item = nil then @@ -1445,7 +1492,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; diff --git a/src/uTransform.pas b/src/uTransform.pas index bbb07b1..cd71c99 100644 --- a/src/uTransform.pas +++ b/src/uTransform.pas @@ -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 diff --git a/tests/imagecrop.sh b/tests/imagecrop.sh index df1a441..5672fa9 100755 --- a/tests/imagecrop.sh +++ b/tests/imagecrop.sh @@ -109,3 +109,73 @@ 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 diff --git a/tests/nlvetool.lpr b/tests/nlvetool.lpr index 427e9f8..2d0487b 100644 --- a/tests/nlvetool.lpr +++ b/tests/nlvetool.lpr @@ -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)]));