From c18b695b89737ca30ae5c7d33c2cebfd333d427b Mon Sep 17 00:00:00 2001 From: 4aiman Date: Fri, 16 Oct 2026 15:42:49 +0300 Subject: [PATCH 1/6] =?UTF-8?q?=D0=A1=D0=B2=D0=BE=D1=91=20=D0=BA=D0=B0?= =?UTF-8?q?=D0=B4=D1=80=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=BA=D0=B0=D1=80=D1=82=D0=B8=D0=BD=D0=BA=D0=B8=20=D0=B4=D0=BE?= =?UTF-8?q?=D1=85=D0=BE=D0=B4=D0=B8=D1=82=20=D0=B4=D0=BE=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B5=D0=B4=D0=BF=D1=80=D0=BE=D1=81=D0=BC=D0=BE=D1=82=D1=80?= =?UTF-8?q?=D0=B0=20=D0=B8=20=D1=80=D0=BE=D0=BB=D0=B8=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Окно «Кадрирование и поворот» показывало картинку уменьшенной и сдвинутой, а в кадре она шла во весь экран. В уплощении преобразование ставилось только обычному видео: у картинки своя ветка, и там его не было вовсе. Кусок из картинки делался по жёсткой цепочке «вписать целиком плюс поля», про клип она ничего не знала. Кусок и так делается заново, поэтому цепочка запекается прямо в него — тем же TransformFilter, которым считают видео и само окно. Разными путями считать одно и то же нельзя: окно обещает ровно эту строку и показывает её текстом. Кадрирование входит в имя файла заготовки: одна картинка с разным увеличением — разные куски, иначе второй клип получил бы из кэша чужой. Для этого у куска заведено отдельное поле GenFilter: Filter применяется к исходнику при пересборке, и смешать их значило бы кадрировать дважды. Проверки: flattest сверяет, что заявка несёт цепочку и отдельный путь; tests/imagecrop.sh проходит всю дорогу — делает картинку, собирает секунду ролика и смотрит на точки готового кадра. На прежней сборке картинка выходит во весь кадр, обе падают. --- src/uAppTypes.pas | 8 ++++ src/uFfmpegCmd.pas | 23 ++++++++-- src/uFlatten.pas | 27 ++++++++++- src/uGenerate.pas | 3 +- tests/imagecrop.sh | 111 +++++++++++++++++++++++++++++++++++++++++++++ tests/nlvetool.lpr | 56 ++++++++++++++++++++++- 6 files changed, 220 insertions(+), 8 deletions(-) create mode 100755 tests/imagecrop.sh 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 From ffd105a8c046647a7ba03542501cd0cb72794fa8 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Fri, 16 Oct 2026 15:43:01 +0300 Subject: [PATCH 2/6] =?UTF-8?q?=D0=A9=D0=B5=D0=BB=D1=87=D0=BE=D0=BA=20?= =?UTF-8?q?=D0=B2=20=D0=BC=D0=B5=D1=81=D1=82=D0=B5=20=D0=BD=D0=B0=D0=B5?= =?UTF-8?q?=D0=B7=D0=B4=D0=B0=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B1=D0=B8=D1=80?= =?UTF-8?q?=D0=B0=D0=B5=D1=82=20=D0=BA=D0=BB=D0=B8=D0=BF=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Наезд двух клипов на одной дорожке — заказ на переход, то есть обычное дело. Нажатие же всегда попадало в верхний по списку, и нижний клип нельзя было ни выделить, ни потащить: чтобы до него добраться, приходилось сперва убирать верхний. Правило такое. Нажатие берёт тот из накрытых клипов, который уже выделен, иначе верхний. Щелчок без движения по уже выделенному переводит выделение на следующий, после последнего снова на верхний. Переключение сделано на отпускании намеренно: пока кнопку не отпустили, неизвестно, щелчок это или начало протяжки, а менять клип под рукой посреди протяжки нельзя. Благодаря этому протяжка тащит именно то, что перед ней выбрали щелчками. Строка состояния говорит, сколько клипов под указателем: перебор, о котором не сказали, — тайное знание. Отбор вынесен в TTrack.ClipsAt, числовая проверка — в lfmcheck. tests/pickcycle.sh щёлкает дважды и тащит: на прежней сборке уезжает верхний клип, а нижний едет за ним схлопыванием. --- README.md | 29 ++++++-- src/uMainForm.pas | 14 +++- src/uProject.pas | 30 +++++++- src/uTimelineCtrl.pas | 107 ++++++++++++++++++++++++++- tests/lfmcheck/lfmcheck.lpr | 53 ++++++++++++++ tests/pickcycle.sh | 141 ++++++++++++++++++++++++++++++++++++ 6 files changed, 363 insertions(+), 11 deletions(-) create mode 100755 tests/pickcycle.sh diff --git a/README.md b/README.md index 3e8fce7..0e44239 100644 --- a/README.md +++ b/README.md @@ -529,6 +529,12 @@ Linux потом искала по нему `.dll`. не переход, а наложение — там по-прежнему побеждает верхний, если он непрозрачен. +В месте наезда под указателем два клипа. Первый щелчок берёт верхний, +повторный по тому же месту переводит выделение на следующий, после +последнего снова на верхний; строка состояния говорит, сколько их там. +Нажатие для перетаскивания берёт именно выделенный — иначе нижний клип +нельзя было бы ни выделить, ни потащить, не убрав сперва верхний. + Различие не косметическое. Если бы кроссфейдом считалось любое перекрытие, то в проекте, где клипы на разных дорожках перекрываются постоянно, каждое такое место пришлось бы пересобирать. @@ -701,6 +707,12 @@ Linux потом искала по нему `.dll`. кусков нельзя, он на этом зависает. Заготовки делаются готовых длин (15 с, 60 с, 5 мин и так далее), а нужный кусок из них вырезается — тогда любое движение клипа не требует новой генерации. + + Своё кадрирование картинки — увеличение, смещение, поворот — запекается + прямо в эту заготовку, той же цепочкой фильтров, что показывает окно + «Кадрирование и поворот». Поэтому в кэше лежит столько заготовок, + сколько разных кадрирований у этой картинки, и предпросмотр с роликом + показывают одно и то же. 3. Границы кусков кладутся на кадровую сетку проекта: между кадрами времени нет, а звук набирается с точностью до отсчёта, и доли кадра разводят их в стороны. @@ -1020,7 +1032,7 @@ Wine с новой библиотекой не грузилось ничего. уход выглядит как накапливающийся рассинхрон, которого нет. На это уже попались один раз. -Ещё девять проверок гоняют саму программу на невидимом экране +Ещё десять проверок гоняют саму программу на невидимом экране (`Xvfb :99`). Рабочий экран они не занимают: ``` @@ -1033,9 +1045,15 @@ Wine с новой библиотекой не грузилось ничего. ./tests/genquiet.sh [<путь к nlve>] тяжёлая сборка ждёт конца правки ./tests/dropgap.sh [<путь к nlve>] клип садится в дыру без наезда ./tests/dragorder.sh [<путь к nlve>] связка переехала — порядок цел +./tests/pickcycle.sh [<путь к nlve>] щелчок перебирает наехавшие клипы ``` -Последним семи нужен монтаж в `test/поток/`: `длинный-фейд.nlve` — один +Отдельно от них `tests/imagecrop.sh` — сквозная, без окна: делает +картинку, кладёт её клипом с уменьшением и смещением, собирает секунду +ролика и смотрит на точки готового кадра. Ей нужны `tests/nlvetool` +(`./build.sh test`), ffmpeg и python с PIL. + +Последним восьми нужен монтаж в `test/поток/`: `длинный-фейд.nlve` — один клип с затуханием секунд на семьдесят (такой кусок сводится около полутора минут, и всё это время человек продолжает работать), `пара.nlve` — два клипа без затуханий. Путь к первому задаётся @@ -1052,12 +1070,13 @@ 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% выходит в ролик во весь кадр. ## Структура diff --git a/src/uMainForm.pas b/src/uMainForm.pas index 1f4a28f..1fb2793 100644 --- a/src/uMainForm.pas +++ b/src/uMainForm.pas @@ -3919,9 +3919,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); diff --git a/src/uProject.pas b/src/uProject.pas index f70c803..c84e76a 100644 --- a/src/uProject.pas +++ b/src/uProject.pas @@ -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); diff --git a/src/uTimelineCtrl.pas b/src/uTimelineCtrl.pas index 65bf250..02e6266 100644 --- a/src/uTimelineCtrl.pas +++ b/src/uTimelineCtrl.pas @@ -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; diff --git a/tests/lfmcheck/lfmcheck.lpr b/tests/lfmcheck/lfmcheck.lpr index 517b997..b52a797 100644 --- a/tests/lfmcheck/lfmcheck.lpr +++ b/tests/lfmcheck/lfmcheck.lpr @@ -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; diff --git a/tests/pickcycle.sh b/tests/pickcycle.sh new file mode 100755 index 0000000..0b40ca9 --- /dev/null +++ b/tests/pickcycle.sh @@ -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 From 3d3d02368c311b4f6dc8908000601bd6cdd353ea Mon Sep 17 00:00:00 2001 From: 4aiman Date: Fri, 16 Oct 2026 16:17:30 +0300 Subject: [PATCH 3/6] =?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)])); From 766f1ad1967753dbf4e7cd0465a4a1f4197e13e3 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Fri, 16 Oct 2026 16:36:11 +0300 Subject: [PATCH 4/6] =?UTF-8?q?=D0=9D=D0=B0=D0=BA=D0=BB=D0=B0=D0=B4=D1=8B?= =?UTF-8?q?=D0=B2=D0=B0=D0=B5=D0=BC=D0=B0=D1=8F=20=D0=BA=D0=B0=D1=80=D1=82?= =?UTF-8?q?=D0=B8=D0=BD=D0=BA=D0=B0=20=D0=BF=D0=BE=D0=BA=D0=B0=D0=B7=D1=8B?= =?UTF-8?q?=D0=B2=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20=D0=B2=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B5=D0=B4=D0=BF=D1=80=D0=BE=D1=81=D0=BC=D0=BE=D1=82=D1=80?= =?UTF-8?q?=D0=B5=20=D0=B3=D0=BE=D1=82=D0=BE=D0=B2=D1=8B=D0=BC=20=D0=BA?= =?UTF-8?q?=D1=83=D1=81=D0=BA=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В окне картинки не было вовсе, хотя в ролик она попадала. Плеер получал сам файл, а его декодер на этом webp спотыкался: «webp: invalid TIFF header in Exif data» — накладывать стало нечего. У неподвижного кадра есть и вторая беда: в списке кусков у него нет длины. Теперь наложению отдаётся заготовка — обычное видео нужной длины, сделанное тем же путём, что и остальные вставки, только цепочкой без полей: поля тут вредны, чёрное закрыло бы нижнее видео. Масштаб уже внутри куска, плееру остаётся поставить его на место. Заготовки наложения собираются отдельным списком: уплощение ниже заполняет список недостающего заново и затёрло бы их. Проверено на том самом файле: в окне картинка легла поверх видео, в ролике слева нижнее видео (253, 0, 0), справа картинка. Числовые проверки в flattest: кусок собирается слоями, у верхнего слоя полей нет, предпросмотру отдан кусок из кэша, и он заказан к созданию. --- README.md | 7 +++++ src/uFlatten.pas | 74 +++++++++++++++++++++++++++++++++++++++++++--- tests/nlvetool.lpr | 29 ++++++++++++++++++ 3 files changed, 106 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 5029e4f..03471bb 100644 --- a/README.md +++ b/README.md @@ -730,6 +730,13 @@ Linux потом искала по нему `.dll`. что-то лежит, участок собирается наложением, и чёрным полям взяться неоткуда. Заготовка с полями остаётся для случая, когда класть не на что. + + В предпросмотре накладываемая картинка тоже показывается заготовкой, + только без полей. Своих декодеров плееру хватает не на всё: настоящий + webp из сети дал «webp: invalid TIFF header in Exif data», и картинка + пропадала из окна, хотя в ролик попадала. У неподвижного кадра есть и + вторая беда — в списке кусков у него нет длины. Заготовка же обычное + видео нужной длины. 3. Границы кусков кладутся на кадровую сетку проекта: между кадрами времени нет, а звук набирается с точностью до отсчёта, и доли кадра разводят их в стороны. diff --git a/src/uFlatten.pas b/src/uFlatten.pas index 264232a..254a527 100644 --- a/src/uFlatten.pas +++ b/src/uFlatten.pas @@ -228,6 +228,18 @@ uses поверх нижнего, а само правило описано ниже, рядом с предпросмотром. } 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 { Длительность округляется до миллисекунд: иначе крошечное расхождение в дробной части плодило бы почти одинаковые файлы. } @@ -1414,6 +1426,10 @@ var RangeA, RangeB: TDoubleArray; RangeCount: Integer; Ov: TPreviewOverlay; + ImgReq: TGenRequest; + { Заготовки картинок для наложения. Отдельным списком, потому что + уплощение ниже заполняет AMissing заново и затёрло бы их. } + MissOv: TGenRequests; { Занято ли это время другим оверлеем. } function OverlapsTaken(const AFrom, ATo: Double): Boolean; @@ -1441,6 +1457,7 @@ begin if Total <= 0 then Exit; + MissOv := nil; Hidden := TFPList.Create; try N := 0; @@ -1513,16 +1530,58 @@ begin 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.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); @@ -1559,6 +1618,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. diff --git a/tests/nlvetool.lpr b/tests/nlvetool.lpr index 2d0487b..a10b681 100644 --- a/tests/nlvetool.lpr +++ b/tests/nlvetool.lpr @@ -1122,6 +1122,35 @@ begin 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) = 1, 'картинка ушла в накладываемые'); + if Length(Overlays) = 1 then + begin + Check(Pos('.nlve-cache', Overlays[0].FilePath) > 0, + 'показывается готовый кусок, а не сам файл'); + Check(Pos('pad=', Overlays[0].Filter) = 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; From 567c518a623af13370c49233d855ecb0a6fde4c2 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Fri, 16 Oct 2026 17:19:57 +0300 Subject: [PATCH 5/6] =?UTF-8?q?=D0=9D=D0=B0=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=B2=20=D0=BF=D1=80=D0=B5=D0=B4=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D1=81=D0=BC=D0=BE=D1=82=D1=80=D0=B5=20=D1=81=D0=B2=D0=BE?= =?UTF-8?q?=D0=B4=D0=B8=D1=82=D1=81=D1=8F=20=D0=B7=D0=B0=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=B5=D0=B5;=20=D0=BD=D0=BE=D0=BC=D0=B5=D1=80=20=D1=81=D0=B1?= =?UTF-8?q?=D0=BE=D1=80=D0=BA=D0=B8=20=D0=B2=20=D0=B7=D0=B0=D0=B3=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D0=BA=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Клип, не заполняющий кадр, стал уходить плееру отдельной дорожкой, и весь показ пошёл через цепочку фильтров. Цепочка работает всё время показа и считается процессором: на кадре 2688x1512 видео начало отставать от звука. Теперь плееру достаются только прозрачные клипы и клипы с ключом — ради них это оправдано, их участки бывают длинными, и пересобирать минуты материала дорого. Клип, который просто не заполняет кадр, сводится заранее вместе с тем, что под ним: предпросмотр играет обычный файл и показывает ровно то, что даст сборка. Попутно, из разбора того же случая: — заполнитель до первого наложения нарезается по длине файла. Одной записью mpv её обрезает («entry 0 uses 7082.687000 seconds, but file has only 15.021000»), дорожка оказывается короче монтажа и не совмещается с ним по времени; — появление заготовки вызывает перезагрузку списка: строка списка при этом не меняется, и без признака готовности плеер так и оставался без дорожки наложения; — в журнал пишется, сколько окон наложения и готовы ли файлы, а также собранная цепочка показа и неудача video-add. Без этого «картинки нет» нечем объяснить. В заголовке окна теперь номер сборки: nlve 42 — фильм.nlve. Он растёт на единицу при каждой сборке. По нему сразу видно, та ли это сборка, где правка уже есть, — сегодня время ушло на то, что проверяли старым двоичным файлом. Счётчик в build.number, в репозиторий не входит. Проверено на монтаже с картинкой на 300-й секунде: в окне она лежит поверх видео, цепочки показа нет. --- .gitignore | 4 +++ README.md | 30 +++++++++++++++---- build.sh | 29 +++++++++++++++++++ src/uFlatten.pas | 33 ++++++++++++++++++++- src/uMainForm.pas | 72 ++++++++++++++++++++++++++++++++++++++++++++-- src/uMpvPlayer.pas | 20 +++++++++++-- tests/nlvetool.lpr | 20 ++++++++++--- 7 files changed, 192 insertions(+), 16 deletions(-) diff --git a/.gitignore b/.gitignore index dc3df30..73060c4 100644 --- a/.gitignore +++ b/.gitignore @@ -92,3 +92,7 @@ shot*.png # Мусор компоновщика Lazarus. link*.res + +# Номер сборки и созданный из него файл: про эту машину, не про историю. +/build.number +/src/uBuildInfo.inc diff --git a/README.md b/README.md index 03471bb..31073c2 100644 --- a/README.md +++ b/README.md @@ -731,12 +731,18 @@ Linux потом искала по нему `.dll`. неоткуда. Заготовка с полями остаётся для случая, когда класть не на что. - В предпросмотре накладываемая картинка тоже показывается заготовкой, - только без полей. Своих декодеров плееру хватает не на всё: настоящий - webp из сети дал «webp: invalid TIFF header in Exif data», и картинка - пропадала из окна, хотя в ролик попадала. У неподвижного кадра есть и - вторая беда — в списке кусков у него нет длины. Заготовка же обычное - видео нужной длины. + Предпросмотр идёт тем же путём: участок с наложением сводится заранее + и играется обычным файлом. Силами плеера рисуются только прозрачные + клипы и клипы с ключом — их участки бывают длинными, и пересобирать + минуты материала ради них дорого. Цепочка плеера работает всё время + показа и считается процессором: на кадре 2688x1512 видео начинает + отставать от звука, поэтому ради клипа, который просто не заполняет + кадр, её не включают. + + Картинка, которую всё же рисует плеер, отдаётся ему заготовкой без + полей, а не самим файлом: своих декодеров ему хватает не на всё + (настоящий webp из сети дал «webp: invalid TIFF header in Exif data»), + да и у неподвижного кадра нет длины в списке кусков. 3. Границы кусков кладутся на кадровую сетку проекта: между кадрами времени нет, а звук набирается с точностью до отсчёта, и доли кадра разводят их в стороны. @@ -1104,6 +1110,18 @@ xdotool не видит ни одного окна и проверка «про времени, второй щелчок по наезду тащит верхний клип вместо нижнего, а картинка с уменьшением 50% выходит в ролик во весь кадр. +## Номер сборки + +В заголовке окна стоит номер: `nlve 42 — фильм.nlve`. Он растёт на +единицу при каждой сборке редактора (`build.sh`), и по нему сразу видно, +та ли это сборка, где нужная правка уже есть. Без него проверяют старым +двоичным файлом, видят прежнее поведение, и время уходит на выяснение, +у кого что не работает. + +Счётчик лежит в `build.number`, из него создаётся `src/uBuildInfo.inc`. +Оба файла в репозиторий не входят: номер про эту машину, а не про +историю проекта. + ## Структура | Файл | Что это | diff --git a/build.sh b/build.sh index 2c2d87d..233d639 100755 --- a/build.sh +++ b/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" < 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; @@ -1471,7 +1496,11 @@ begin for C := 0 to Track.Count - 1 do begin Clip := Track[C]; - if not IsOverlayClip(AProject, Clip) then + { Плееру отдаём только прозрачные и с ключом. Остальное, что не + заполняет кадр, сводится заранее вместе с тем, что под ним: + так предпросмотр совпадает с роликом и не упирается в + процессор. } + if not IsPlayerOverlay(Clip) then Continue; Item := AProject.FindMedia(Clip.MediaId); if Item = nil then @@ -1526,6 +1555,7 @@ 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, @@ -1556,6 +1586,7 @@ begin ImgReq.TlEnd := Ov.TlEnd; Ov.FilePath := ImgReq.TargetPath; Ov.SrcStart := 0; + Ov.SrcLength := ImgReq.Duration; Ov.Filter := ''; if not FileExists(ImgReq.TargetPath) then begin diff --git a/src/uMainForm.pas b/src/uMainForm.pas index 1fb2793..2e146fc 100644 --- a/src/uMainForm.pas +++ b/src/uMainForm.pas @@ -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; diff --git a/src/uMpvPlayer.pas b/src/uMpvPlayer.pas index 9d7ebdd..34c9cd6 100644 --- a/src/uMpvPlayer.pas +++ b/src/uMpvPlayer.pas @@ -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; { Субтитры подключаются двумя путями, и оба нужны. diff --git a/tests/nlvetool.lpr b/tests/nlvetool.lpr index a10b681..d9488b5 100644 --- a/tests/nlvetool.lpr +++ b/tests/nlvetool.lpr @@ -1134,17 +1134,29 @@ begin Check(Pos('pad=', Segs[0].Layers[1].FitFilter) = 0, 'у верхнего слоя полей нет'); - { Предпросмотру картинка отдаётся готовым куском, а не самим файлом: - своих декодеров у плеера хватает не на всё, а у неподвижного кадра - нет длины. } + { Предпросмотр идёт тем же путём, что и сборка: участок сводится + заранее. Плееру его не отдаём — цепочка наложения работает всё + время показа и на большом кадре уводит видео от звука. } FlattenPreview(Proj, Segs, AudioSegs, Overlays, Missing, True); - Check(Length(Overlays) = 1, 'картинка ушла в накладываемые'); + 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 From af03ec1b938c5a6327683a8f3d51839dafea43e7 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Sat, 17 Oct 2026 20:37:15 +0300 Subject: [PATCH 6/6] =?UTF-8?q?=D0=9E=D0=BA=D0=BD=D0=BE=20=D0=BA=D0=B0?= =?UTF-8?q?=D0=B4=D1=80=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=D0=B5=D1=82=20=D0=B8=20?= =?UTF-8?q?=D0=B2=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=B5,=20=D0=B3?= =?UTF-8?q?=D0=B4=D0=B5=20=D0=BA=D1=8D=D1=88=D0=B0=20=D0=B5=D1=89=D1=91=20?= =?UTF-8?q?=D0=BD=D0=B5=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Кадр для окна пишется в кэш проекта, а кэш заводится при первой сборке вставок. Кадрирование открывают и раньше — тогда ffmpeg падал с «No such file or directory», а окно показывало «Не удалось получить кадр» вместе с цепочкой фильтров. Выглядело так, будто дело в цепочке; дело было в отсутствующей папке. Замерено на том же файле и той же цепочке: в существующей папке команда отдаёт кадр в 320 КБ, в несуществующей — код выхода 254. Папка теперь создаётся перед записью. Не создалась — кадр пишется во временный файл, чтобы окно всё равно показало картинку. Проверка tests/cropdialog.sh: проект без папки кэша, щелчок по кнопке кадрирования, смотрим, появился ли файл кадра. Папка убирается прямо перед щелчком: иначе её успевает создать заготовка чёрной вставки, и проверка меряет не то. На прежней сборке падает. --- README.md | 11 ++-- src/uPanCropForm.pas | 9 ++++ tests/cropdialog.sh | 123 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 140 insertions(+), 3 deletions(-) create mode 100755 tests/cropdialog.sh diff --git a/README.md b/README.md index 31073c2..9fd055c 100644 --- a/README.md +++ b/README.md @@ -1062,7 +1062,7 @@ Wine с новой библиотекой не грузилось ничего. уход выглядит как накапливающийся рассинхрон, которого нет. На это уже попались один раз. -Ещё десять проверок гоняют саму программу на невидимом экране +Ещё одиннадцать проверок гоняют саму программу на невидимом экране (`Xvfb :99`). Рабочий экран они не занимают: ``` @@ -1076,6 +1076,7 @@ Wine с новой библиотекой не грузилось ничего. ./tests/dropgap.sh [<путь к nlve>] клип садится в дыру без наезда ./tests/dragorder.sh [<путь к nlve>] связка переехала — порядок цел ./tests/pickcycle.sh [<путь к nlve>] щелчок перебирает наехавшие клипы +./tests/cropdialog.sh [<путь к nlve>] окно кадрирования показывает кадр ``` Отдельно от них `tests/imagecrop.sh` — сквозная, без окна: делает @@ -1085,7 +1086,11 @@ Wine с новой библиотекой не грузилось ничего. а не поля. Ей нужны `tests/nlvetool` (`./build.sh test`), ffmpeg и python с PIL. -Последним восьми нужен монтаж в `test/поток/`: `длинный-фейд.nlve` — один +`cropdialog.sh` строит свой монтаж сам, но ему нужна настоящая съёмка: +кадр для окна берётся из файла. Путь задаётся переменной +`NLVE_CROP_SOURCE`. + +Остальным нужен монтаж в `test/поток/`: `длинный-фейд.nlve` — один клип с затуханием секунд на семьдесят (такой кусок сводится около полутора минут, и всё это время человек продолжает работать), `пара.nlve` — два клипа без затуханий. Путь к первому задаётся @@ -1102,7 +1107,7 @@ xdotool не видит ни одного окна и проверка «про экран шире: `Xvfb :98 -screen 0 1600x1000x24` и `ЭКРАН=:98`. Каждую из них стоит прогонять и на прежней сборке: проверка, которая -проходит везде, не проверяет ничего. Все десять на сборке до своей правки +проходит везде, не проверяет ничего. Все одиннадцать на сборке до своей правки падают. Замерено: клип у кромки уезжает на 0.7 с против 4.2 с, файл из списка падает в конец дорожки вместо курсора, сборка начинается через 0.2 с вместо 1.6 с, клип в дыру садится с наездом 0.007383 с, дорожка diff --git a/src/uPanCropForm.pas b/src/uPanCropForm.pas index b251ca3..c834eb0 100644 --- a/src/uPanCropForm.pas +++ b/src/uPanCropForm.pas @@ -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]); diff --git a/tests/cropdialog.sh b/tests/cropdialog.sh new file mode 100755 index 0000000..fef28e6 --- /dev/null +++ b/tests/cropdialog.sh @@ -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