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 3e8fce7..9fd055c 100644 --- a/README.md +++ b/README.md @@ -527,7 +527,25 @@ Linux потом искала по нему `.dll`. Два клипа **на одной дорожке**, наползшие друг на друга, дают переход: один плавно перетекает в другой. На разных дорожках перекрытие означает не переход, а наложение — там по-прежнему побеждает верхний, если он -непрозрачен. +сплошной. + +Сплошным клип перестаёт быть по трём причинам: выбит цвет, задана +прозрачность или **он не заполняет кадр**. Последнее — то же самое, +сказанное иначе: уменьшенный или сдвинутый клип занимает часть кадра, а +вокруг остаётся пустое место, которое сборка обкладывает чёрными полями. +Поле непрозрачно, и нижнюю дорожку закрывает не сам клип, а его поля — +поэтому такой клип кладётся поверх нижнего, а не вместо него. Уменьшают +клип ровно затем, чтобы увидеть под ним другое. Класть не на что — +остаются поля. + +Отсюда же берётся картинка в углу кадра и два видео рядом: достаточно +уменьшить и сдвинуть клипы на разных дорожках. + +В месте наезда под указателем два клипа. Первый щелчок берёт верхний, +повторный по тому же месту переводит выделение на следующий, после +последнего снова на верхний; строка состояния говорит, сколько их там. +Нажатие для перетаскивания берёт именно выделенный — иначе нижний клип +нельзя было бы ни выделить, ни потащить, не убрав сперва верхний. Различие не косметическое. Если бы кроссфейдом считалось любое перекрытие, то в проекте, где клипы на разных дорожках перекрываются @@ -701,6 +719,30 @@ Linux потом искала по нему `.dll`. кусков нельзя, он на этом зависает. Заготовки делаются готовых длин (15 с, 60 с, 5 мин и так далее), а нужный кусок из них вырезается — тогда любое движение клипа не требует новой генерации. + + Своё кадрирование картинки — увеличение, смещение, поворот — запекается + прямо в эту заготовку, той же цепочкой фильтров, что показывает окно + «Кадрирование и поворот». Поэтому в кэше лежит столько заготовок, + сколько разных кадрирований у этой картинки, и предпросмотр с роликом + показывают одно и то же. + + Заготовка нужна не всегда: если картинка не заполняет кадр, а под ней + что-то лежит, участок собирается наложением, и чёрным полям взяться + неоткуда. Заготовка с полями остаётся для случая, когда класть не на + что. + + Предпросмотр идёт тем же путём: участок с наложением сводится заранее + и играется обычным файлом. Силами плеера рисуются только прозрачные + клипы и клипы с ключом — их участки бывают длинными, и пересобирать + минуты материала ради них дорого. Цепочка плеера работает всё время + показа и считается процессором: на кадре 2688x1512 видео начинает + отставать от звука, поэтому ради клипа, который просто не заполняет + кадр, её не включают. + + Картинка, которую всё же рисует плеер, отдаётся ему заготовкой без + полей, а не самим файлом: своих декодеров ему хватает не на всё + (настоящий webp из сети дал «webp: invalid TIFF header in Exif data»), + да и у неподвижного кадра нет длины в списке кусков. 3. Границы кусков кладутся на кадровую сетку проекта: между кадрами времени нет, а звук набирается с точностью до отсчёта, и доли кадра разводят их в стороны. @@ -1020,7 +1062,7 @@ Wine с новой библиотекой не грузилось ничего. уход выглядит как накапливающийся рассинхрон, которого нет. На это уже попались один раз. -Ещё девять проверок гоняют саму программу на невидимом экране +Ещё одиннадцать проверок гоняют саму программу на невидимом экране (`Xvfb :99`). Рабочий экран они не занимают: ``` @@ -1033,9 +1075,22 @@ Wine с новой библиотекой не грузилось ничего. ./tests/genquiet.sh [<путь к nlve>] тяжёлая сборка ждёт конца правки ./tests/dropgap.sh [<путь к nlve>] клип садится в дыру без наезда ./tests/dragorder.sh [<путь к nlve>] связка переехала — порядок цел +./tests/pickcycle.sh [<путь к nlve>] щелчок перебирает наехавшие клипы +./tests/cropdialog.sh [<путь к nlve>] окно кадрирования показывает кадр ``` -Последним семи нужен монтаж в `test/поток/`: `длинный-фейд.nlve` — один +Отдельно от них `tests/imagecrop.sh` — сквозная, без окна: делает +картинку, кладёт её клипом с уменьшением и смещением, собирает секунду +ролика и смотрит на точки готового кадра. Случая два: класть не на что — +вокруг картинки чёрные поля; под картинкой видео — вокруг неё видно его, +а не поля. Ей нужны `tests/nlvetool` (`./build.sh test`), ffmpeg и python +с PIL. + +`cropdialog.sh` строит свой монтаж сам, но ему нужна настоящая съёмка: +кадр для окна берётся из файла. Путь задаётся переменной +`NLVE_CROP_SOURCE`. + +Остальным нужен монтаж в `test/поток/`: `длинный-фейд.nlve` — один клип с затуханием секунд на семьдесят (такой кусок сводится около полутора минут, и всё это время человек продолжает работать), `пара.nlve` — два клипа без затуханий. Путь к первому задаётся @@ -1052,12 +1107,25 @@ 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% выходит в ролик во весь кадр. + +## Номер сборки + +В заголовке окна стоит номер: `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" < 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/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..0db7966 100644 --- a/src/uFlatten.pas +++ b/src/uFlatten.pas @@ -57,6 +57,16 @@ type Y: Integer; Width: Integer; Height: Integer; + { Сколько секунд можно взять из этого файла. + + Список кусков оверлея обязан покрывать монтаж от самого начала, + иначе плееру нечем совместить его по времени с основой. Промежуток + до первого показа заполняется тем же файлом, и просить у него + больше, чем в нём есть, нельзя: mpv обрезает такую запись + («entry uses 7082.687000 seconds, but file has only 15.021000») — + дорожка оказывается короче монтажа, и оверлей не показывается + вовсе. Поэтому заполнитель нарезается на куски по этой длине. } + SrcLength: Double; end; TPreviewOverlays = array of TPreviewOverlay; @@ -82,6 +92,10 @@ type TargetPath: string; { Только для gkLayers: что с чем смешивать. } Layers: TSegmentLayers; + { Только для gkImage: цепочка фильтров, которой картинка вписывается + в кадр проекта. Считается из настройки клипа, поэтому две копии + одной картинки с разным кадрированием дают разные куски. } + Filter: string; { Место на монтаже, ради которого всё затевается. Нужно только для показа: пока участок пересобирается, монтаж помечает его полосой. } TlStart: Double; @@ -220,6 +234,22 @@ implementation uses Math, md5; +{ Объявлено заранее: уплощение решает по этому признаку, класть ли клип + поверх нижнего, а само правило описано ниже, рядом с предпросмотром. } +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 { Длительность округляется до миллисекунд: иначе крошечное расхождение в дробной части плодило бы почти одинаковые файлы. } @@ -282,7 +312,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,7 +918,41 @@ 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); + + { Картинка, не заполняющая кадр, ложится поверх нижнего видео, а не + подменяет его собой. Иначе нижнюю дорожку закрывают не картинкой, + а её чёрными полями: сам кусок — сплошной кадр проекта, в котором + картинка занимает свой прямоугольник, а остальное залито чёрным. + + Слои считает тот же код, что и для полупрозрачных клипов: у + верхнего слоя берётся цепочка без полей и место, куда он встаёт. } + 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 @@ -948,11 +1015,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); { Ручное затухание: кусок попал в зону появления или ухода. Слой @@ -1101,6 +1167,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; @@ -1113,6 +1183,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 @@ -1318,14 +1391,50 @@ begin end; end; -{ Клип кладётся поверх, а не побеждает целиком, если он не сплошной: - с выбитым цветом или полупрозрачный. } -function IsOverlayClip(AClip: TClip): Boolean; +{ Клип кладётся поверх, а не побеждает целиком, если он не сплошной. + + Несплошных случая три: выбитый цвет, прозрачность и клип, который не + заполняет кадр. Последний — то же самое, только другими словами: + уменьшенная или сдвинутая картинка занимает часть кадра, а остальное + сборка обкладывает чёрными полями. Поле непрозрачно, и нижнее видео + закрывает собой не картинка, а её поля. Человек же, уменьшая клип, + хочет увидеть под ним то, что на дорожке ниже, — иначе зачем уменьшать. + + Если под клипом ничего нет, поля остаются полями: класть не на что. } +{ Кого плеер рисует поверх основы сам, своей цепочкой. + + Только прозрачные клипы и клипы с ключом. Цепочка идёт через процессор + и работает всё время показа, а не только там, где лежит оверлей: на + 2688x1512 видео начинает отставать от звука. Ради длинного + полупрозрачного слоя это оправдано — иначе пришлось бы пересобирать + минуты материала. Ради клипа, который просто не заполняет кадр, — нет: + его участок дешевле свести заранее, как для ролика, и тогда + предпросмотр играет обычный файл. } +function IsPlayerOverlay(AClip: TClip): Boolean; begin Result := (AClip <> 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; +begin + 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; out AVideo, AAudio: TFlatSegmentArray; out AOverlays: TPreviewOverlays; @@ -1342,6 +1451,10 @@ var RangeA, RangeB: TDoubleArray; RangeCount: Integer; Ov: TPreviewOverlay; + ImgReq: TGenRequest; + { Заготовки картинок для наложения. Отдельным списком, потому что + уплощение ниже заполняет AMissing заново и затёрло бы их. } + MissOv: TGenRequests; { Занято ли это время другим оверлеем. } function OverlapsTaken(const AFrom, ATo: Double): Boolean; @@ -1369,6 +1482,7 @@ begin if Total <= 0 then Exit; + MissOv := nil; Hidden := TFPList.Create; try N := 0; @@ -1382,7 +1496,11 @@ begin for C := 0 to Track.Count - 1 do begin Clip := Track[C]; - if not IsOverlayClip(Clip) then + { Плееру отдаём только прозрачные и с ключом. Остальное, что не + заполняет кадр, сводится заранее вместе с тем, что под ним: + так предпросмотр совпадает с роликом и не упирается в + процессор. } + if not IsPlayerOverlay(Clip) then Continue; Item := AProject.FindMedia(Clip.MediaId); if Item = nil then @@ -1420,7 +1538,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; @@ -1437,20 +1555,64 @@ 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, 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.SrcLength := ImgReq.Duration; + 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); @@ -1487,6 +1649,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/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/src/uMainForm.pas b/src/uMainForm.pas index 1f4a28f..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; @@ -3919,9 +3985,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/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/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/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/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/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 diff --git a/tests/imagecrop.sh b/tests/imagecrop.sh new file mode 100755 index 0000000..5672fa9 --- /dev/null +++ b/tests/imagecrop.sh @@ -0,0 +1,181 @@ +#!/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 + +# Второй случай: под картинкой лежит видео. Тогда пустое место вокруг неё +# не чёрное поле, а это самое видео: клип, не заполняющий кадр, ложится +# поверх нижнего, а не подменяет его собой. +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/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/nlvetool.lpr b/tests/nlvetool.lpr index 9f586e9..d9488b5 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)])); @@ -589,9 +594,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 +1075,99 @@ 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; + + { Под картинкой появляется видео — и она должна лечь поверх него, а + не закрыть его своими чёрными полями. } + 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) = 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 + PlainPath := Missing[I].TargetPath; + Check(PlainPath <> '', 'этот кусок заказан к созданию'); + end; + finally + Proj.Free; + end; + WriteLn; + WriteLn; if FailCount = 0 then begin 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