Своё кадрирование картинки доходит до предпросмотра и ролика
Окно «Кадрирование и поворот» показывало картинку уменьшенной и сдвинутой, а в кадре она шла во весь экран. В уплощении преобразование ставилось только обычному видео: у картинки своя ветка, и там его не было вовсе. Кусок из картинки делался по жёсткой цепочке «вписать целиком плюс поля», про клип она ничего не знала. Кусок и так делается заново, поэтому цепочка запекается прямо в него — тем же TransformFilter, которым считают видео и само окно. Разными путями считать одно и то же нельзя: окно обещает ровно эту строку и показывает её текстом. Кадрирование входит в имя файла заготовки: одна картинка с разным увеличением — разные куски, иначе второй клип получил бы из кэша чужой. Для этого у куска заведено отдельное поле GenFilter: Filter применяется к исходнику при пересборке, и смешать их значило бы кадрировать дважды. Проверки: flattest сверяет, что заявка несёт цепочку и отдельный путь; tests/imagecrop.sh проходит всю дорогу — делает картинку, собирает секунду ролика и смотрит на точки готового кадра. На прежней сборке картинка выходит во весь кадр, обе падают.
This commit is contained in:
parent
95d92a7582
commit
c18b695b89
6 changed files with 220 additions and 8 deletions
|
|
@ -105,6 +105,14 @@ type
|
|||
к самой картинке, у чёрного пусто. Хранится прямо здесь, чтобы потом
|
||||
не восстанавливать источник обратным перебором. }
|
||||
GenSource: string;
|
||||
{ Для куска из картинки — цепочка, которой она вписывается в кадр
|
||||
проекта: своё увеличение, смещение и поворот клипа. Запекается
|
||||
прямо в сгенерированный кусок, поэтому к самому куску потом
|
||||
применять её нельзя — она уже в нём.
|
||||
|
||||
Отдельно от Filter именно поэтому: Filter применяется к исходнику
|
||||
при пересборке, и смешать их значило бы кадрировать дважды. }
|
||||
GenFilter: string;
|
||||
{ Начало куска должно быть ровно там, где указано, а не на ближайшем
|
||||
опорном кадре. Тогда огрызок от начала до следующего опорного кадра
|
||||
перекодируется, а остальное копируется.
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -82,6 +82,10 @@ type
|
|||
TargetPath: string;
|
||||
{ Только для gkLayers: что с чем смешивать. }
|
||||
Layers: TSegmentLayers;
|
||||
{ Только для gkImage: цепочка фильтров, которой картинка вписывается
|
||||
в кадр проекта. Считается из настройки клипа, поэтому две копии
|
||||
одной картинки с разным кадрированием дают разные куски. }
|
||||
Filter: string;
|
||||
{ Место на монтаже, ради которого всё затевается. Нужно только для
|
||||
показа: пока участок пересобирается, монтаж помечает его полосой. }
|
||||
TlStart: Double;
|
||||
|
|
@ -282,7 +286,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,6 +892,21 @@ 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);
|
||||
end
|
||||
else
|
||||
|
|
@ -1113,6 +1135,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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
111
tests/imagecrop.sh
Executable file
111
tests/imagecrop.sh
Executable 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue