diff --git a/src/uAppTypes.pas b/src/uAppTypes.pas index 9571e2c..b94806b 100644 --- a/src/uAppTypes.pas +++ b/src/uAppTypes.pas @@ -105,6 +105,14 @@ type к самой картинке, у чёрного пусто. Хранится прямо здесь, чтобы потом не восстанавливать источник обратным перебором. } GenSource: string; + { Для куска из картинки — цепочка, которой она вписывается в кадр + проекта: своё увеличение, смещение и поворот клипа. Запекается + прямо в сгенерированный кусок, поэтому к самому куску потом + применять её нельзя — она уже в нём. + + Отдельно от Filter именно поэтому: Filter применяется к исходнику + при пересборке, и смешать их значило бы кадрировать дважды. } + GenFilter: string; { Начало куска должно быть ровно там, где указано, а не на ближайшем опорном кадре. Тогда огрызок от начала до следующего опорного кадра перекодируется, а остальное копируется. diff --git a/src/uFfmpegCmd.pas b/src/uFfmpegCmd.pas index d9846ca..06a423a 100644 --- a/src/uFfmpegCmd.pas +++ b/src/uFfmpegCmd.pas @@ -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)); diff --git a/src/uFlatten.pas b/src/uFlatten.pas index 7c85c48..c84afec 100644 --- a/src/uFlatten.pas +++ b/src/uFlatten.pas @@ -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 diff --git a/src/uGenerate.pas b/src/uGenerate.pas index ba91621..7594839 100644 --- a/src/uGenerate.pas +++ b/src/uGenerate.pas @@ -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, diff --git a/tests/imagecrop.sh b/tests/imagecrop.sh new file mode 100755 index 0000000..df1a441 --- /dev/null +++ b/tests/imagecrop.sh @@ -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 diff --git a/tests/nlvetool.lpr b/tests/nlvetool.lpr index 9f586e9..427e9f8 100644 --- a/tests/nlvetool.lpr +++ b/tests/nlvetool.lpr @@ -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