Своё кадрирование картинки доходит до предпросмотра и ролика

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

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

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

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

111
tests/imagecrop.sh Executable file
View file

@ -0,0 +1,111 @@
#!/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

View file

@ -589,9 +589,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 +1070,58 @@ 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;
finally
Proj.Free;
end;
WriteLn;
WriteLn;
if FailCount = 0 then
begin