From 019d016db9c1e007be231d6e3e60d45238c0c6ea Mon Sep 17 00:00:00 2001 From: 4aiman Date: Wed, 16 Sep 2026 18:28:29 +0300 Subject: [PATCH] =?UTF-8?q?=D0=97=D0=B2=D1=83=D0=BA=20=D0=B1=D0=BE=D0=BB?= =?UTF-8?q?=D1=8C=D1=88=D0=B5=20=D0=BD=D0=B5=20=D0=BE=D1=82=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D1=91=D1=82=20=D0=BE=D1=82=20=D0=BA=D0=B0=D1=80=D1=82?= =?UTF-8?q?=D0=B8=D0=BD=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Рез всегда точный. Раньше начало куска притягивалось к ближайшему опорному кадру, а звук брался из непритянутой точки: по журналам сборки средняя притяжка 0.24 с, худшая 0.99 с, и на длинном куске этот сдвиг держится все его минуты. Плата за точность — доли секунды перекодирования на рез. Второй вид реза (Shift+S) стал тем же самым и убран. Разбор заголовка MP4 не учитывал список правок (elst): метки опорных кадров уезжали на задержку показа, на пробе два кадра. Сверка быстрого пути с ffprobe теперь совпадает точно, в том числе на записи с B-кадрами. Куску заказывается точное число кадров, а не только время: при копировании ffmpeg отмеряет конец по времени декодирования, и у записи с B-кадрами кусок выходил длиннее. Склейке длительность каждого куска задаётся явно. Последний пакет скопированного куска показывается позже прочих, контейнер считал его в длительность, и время картинки растягивалось — 0.05 с на кусок. Звука в кусках картинки больше нет: он не используется, а в MPEG-TS ещё и сбивает выравнивание склейки на 36 мс. Границы кусков ложатся на кадровую сетку: картинка выходит целым числом кадров, звук набирается до отсчёта, и доли кадра разводили их в стороны. Разогрев кодировщика звука компенсируется: сжатый AAC начинается со служебных отсчётов, MP4 помечает их пропускаемыми, Matroska — нет, и звук ехал на 21.3 мс позже. Величина меряется пробным сжатием. Продолжение прерванной сборки чинится заодно: готовность проверяется у каждой части, а не у куска целиком. При точном резе частей две, и вторая при продолжении терялась. В журнал сборки добавлена проверка синхронности: сколько кадров вышло и сколько просил монтаж, с вердиктом и худшей частью. Замер (tests/syncprobe.sh, исходник со вспышкой и щелчком на каждой секунде): было до 0.6 с и росло по ходу ролика, стало 0.02-0.04 с без накопления при точности измерения 0.017 с. --- README.md | 77 +++++++-- src/uExport.pas | 355 +++++++++++++++++++++++++++++------------ src/uFfmpegCmd.pas | 86 ++++++++-- src/uFlatten.pas | 65 +++++++- src/uKeyframeIndex.pas | 54 ++++++- src/uMainForm.pas | 79 +++++++-- tests/clicktimes.py | 27 ++++ tests/nlvetool.lpr | 141 ++++++++++++++++ tests/syncprobe.sh | 123 ++++++++++++++ 9 files changed, 849 insertions(+), 158 deletions(-) create mode 100644 tests/clicktimes.py create mode 100755 tests/syncprobe.sh diff --git a/README.md b/README.md index ceb0769..b32216d 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,6 @@ xdg-mime query default application/x-nlve-project | В начало / конец монтажа | Home / End | | Показать, где курсор (окно едет к нему) | \ | | Разрезать по курсору (все дорожки) | S | -| Разрезать точно (перекодирует стык) | Shift+S | | Удалить выделенное | Delete | | Выделить всё | Ctrl+A | | Выделить областью | протяжка по пустому месту дорожек | @@ -542,13 +541,26 @@ Linux потом искала по нему `.dll`. кусков нельзя, он на этом зависает. Заготовки делаются готовых длин (15 с, 60 с, 5 мин и так далее), а нужный кусок из них вырезается — тогда любое движение клипа не требует новой генерации. -3. Начало каждого куска притягивается к опорному кадру, потому что копировать - можно только с него. Конец притягивать не нужно — при копировании он и так - получается кадроточным. -4. При точном резе огрызок от точки реза до следующего опорного кадра - перекодируется, остальное копируется. -5. Куски склеиваются concat-демультиплексором, результат сверяется: сумма - пакетов кусков обязана совпасть с числом пакетов в готовом файле. +3. Границы кусков кладутся на кадровую сетку проекта: между кадрами + времени нет, а звук набирается с точностью до отсчёта, и доли кадра + разводят их в стороны. +4. Рез всегда точный: огрызок от точки реза до ближайшего опорного кадра + перекодируется, остальное копируется. Копировать можно только с + опорного кадра, поэтому «резать где угодно и ничего не перекодировать» + не бывает — но платить приходится долями секунды на рез. +5. Каждому куску заказывается точное число кадров (`-frames:v`), а не + только время: при копировании ffmpeg отмеряет конец по времени + декодирования, и у записи с B-кадрами кусок выходит длиннее + запрошенного. +6. Куски склеиваются concat-демультиплексором с явно указанной + длительностью каждого. Результат сверяется дважды: сумма пакетов кусков + обязана совпасть с числом пакетов в готовом файле, а число кадров — с + тем, что просил монтаж. Второе и есть проверка синхронности звука с + картинкой; вердикт пишется в журнал сборки. + +Звук в кусках картинки не хранится: звуковая дорожка собирается отдельно и +точнее, а лишний поток в куске MPEG-TS ещё и сбивает тайминг склейки — +замерено 36 мс на стык. Напрашивающийся способ — один проход с директивами `inpoint` и `outpoint`, без временных файлов — проверен и отвергнут: он давал 574 кадра вместо 600 @@ -579,7 +591,8 @@ Linux потом искала по нему `.dll`. числе кадров. Лечится склейкой через промежуточный MPEG-TS, где параметры повторяются перед каждым опорным кадром — стало 0 ошибок. Способ выбирается автоматически: один источник и никаких точных резов — прямая склейка, -иначе MPEG-TS. +иначе MPEG-TS. Резы теперь всегда точные, так что на деле это почти всегда +MPEG-TS. **Лишний кадр на стыке точного реза.** При копировании `-to` работает как «строго меньше», при перекодировании — как «меньше либо равно». Кадр на @@ -591,6 +604,33 @@ Matroska давали метки декодирования, идущие наз меток на шестьсот кадров. Коротким вставкам B-кадры не нужны, поэтому они запрещены (`-bf 0`). +**Звук отставал от картинки — четыре причины сразу.** Разбор занял день, и +каждая находка мерялась, а не угадывалась (`tests/syncprobe.sh`: исходник +со вспышкой и щелчком на каждой секунде, монтаж с резами не на опорных +кадрах, поиск обоих следов в готовом ролике). + +1. *Притянутый рез.* Начало куска картинки сдвигалось на ближайший опорный + кадр, а звук брался из непритянутой точки: в журналах пользователя + средняя притяжка 0.24 с, худшая 0.99 с. Лечится точным резом всегда. +2. *Список правок MP4.* Разбор заголовка не учитывал блок `elst`, и метки + опорных кадров уезжали на задержку показа — на пробе два кадра. Рез + просил перекодировать до метки, которая опорным кадром не была. +3. *Конец куска при копировании.* `-to` отмеряет по времени декодирования, + и у записи с B-кадрами кусок выходил длиннее запрошенного. Лечится + явным числом кадров (`-frames:v`). +4. *Длительность куска в склейке.* Последний пакет скопированного куска + показывается позже всех прочих, контейнер считает его в длительность, и + склейка растягивала время — 0.05 с на кусок. Лечится директивой + `duration` в списке склейки. Туда же: звук внутри кусков картинки сбивал + выравнивание на 36 мс, теперь его там нет. + +Плюс «разогрев» кодировщика звука: сжатый AAC начинается со служебных +отсчётов, MP4 помечает их пропускаемыми, Matroska такой пометки не хранит — +звук ехал на 21.3 мс позже. Величина меряется пробным сжатием и вычитается. + +Итог замера на образце: было до 0.6 с и росло по ходу ролика, стало 0.02-0.04 с +без накопления, при точности самого измерения 0.017 с. + **Вставка под каждую длину дыры.** Сначала под каждую дыру делался чёрный клип ровно её длины. Любое движение клипа меняло длину дыры, требовало новой генерации, и до её готовности предпросмотр играл прежнюю расстановку. @@ -703,11 +743,30 @@ Wine с новой библиотекой не грузилось ничего. ./tests/nlvetool build <проект> <видео> [<картинка>] ./tests/nlvetool buildcut <проект> <видео> ./tests/nlvetool render <проект> <выход> +./tests/nlvetool syncproj <проект> <видео> монтаж для замера синхронности ``` Команды возвращают ненулевой код при расхождении, поэтому годятся для автоматической проверки. +Отдельно — замер синхронности звука и картинки: + +``` +./tests/syncprobe.sh весь прогон: образец, сборка, замер +./tests/syncprobe.sh measure ФАЙЛ замер готового файла +``` + +Как он устроен. Делается исходник, где ровно на каждой секунде в кадре +белая вспышка, а в звуке щелчок. Из него собирается монтаж, у которого +начала кусков заведомо не попадают на опорные кадры. В готовом ролике +ищутся времена вспышек и щелчков: если они разошлись — звук уехал от +картинки, и видно, на сколько и с какого куска. + +Времена щелчков считаются по самим отсчётам (`tests/clicktimes.py`), а не +оконным анализом: окно двигается кратно своему размеру, и его собственный +уход выглядит как накапливающийся рассинхрон, которого нет. На это уже +попались один раз. + ## Структура | Файл | Что это | diff --git a/src/uExport.pas b/src/uExport.pas index d7f4beb..b13c9c7 100644 --- a/src/uExport.pas +++ b/src/uExport.pas @@ -79,6 +79,10 @@ type Duration: Double; TempPath: string; Packets: Int64; + { Сколько кадров в этой части должно быть по замыслу монтажа. Отсюда + берётся её длительность для склейки и с этим же числом сверяется то, + что вышло на самом деле. } + WantFrames: Integer; Encoded: Boolean; end; @@ -135,6 +139,8 @@ type FStepPosition: Double; { Приёмник числа, которое печатает ffprobe при подсчёте пакетов. } FPacketCount: Int64; + { Приёмник метки первого звукового пакета при замере разогрева. } + FFirstAudioPts: Double; FMaster: TMediaInfo; FProfile: TEncodeProfile; @@ -157,8 +163,6 @@ type procedure LoadDoneList; function IsDone(const AName: string): Boolean; procedure MarkDone(const AName: string); - { Готов ли кусок с прошлого раза. Заполняет отчёт по нему. } - function TakeReadyPart(AIndex: Integer; out ARep: TSegmentReport): Boolean; procedure SetStage(const AText: string); procedure PublishProgress; @@ -177,6 +181,10 @@ type проходом. Сырой поток нужен для точности — стык получается с точностью до отсчёта, а не до звукового кадра. } function BuildAudio: Boolean; + { Разогрев кодировщика звука: сколько служебных отсчётов он добавляет + в начало. Меряется коротким пробным сжатием. } + function EncoderPriming(ARate, AChannels: Integer): Double; + procedure FirstPtsLine(const ALine: string); function MixLayers(const ASeg: TFlatSegment; ADest: TStream; ANeed: Int64; ARate, AChannels: Integer): Boolean; function MuxFinal: Boolean; @@ -442,42 +450,6 @@ begin end; end; -function TExportThread.TakeReadyPart(AIndex: Integer; - out ARep: TSegmentReport): Boolean; -var - Names: array[0..2] of string; - I: Integer; -begin - Result := False; - ARep := Default(TSegmentReport); - if (FDone = nil) or (FDone.Count = 0) then - Exit; - - { Имя куска зависит от того, каким путём он делался: обычный, с - наложением, с точным резом. Проверяем все три. } - Names[0] := Format('part_%.3d_a%s', [AIndex, FIntermediateExt]); - Names[1] := Format('part_%.3d_x%s', [AIndex, FIntermediateExt]); - Names[2] := Format('part_%.3d_b%s', [AIndex, FIntermediateExt]); - - for I := 0 to 1 do - if IsDone(Names[I]) then - begin - { Кусок с точным резом состоит из двух частей; если вторая тоже - готова, её возьмёт следующий заход — здесь достаточно первой. } - ARep.Index := AIndex; - ARep.SourcePath := FSegments[AIndex].FilePath; - ARep.RequestedStart := FSegments[AIndex].SrcStart; - ARep.ActualStart := FSegments[AIndex].SrcStart; - ARep.Duration := FSegments[AIndex].Duration; - ARep.Packets := -1; - ARep.Encoded := I = 1; - ARep.TempPath := FCacheDir + Names[I]; - Inc(FReusedParts); - Log(Format('Кусок %d уже собран — беру готовый.', [AIndex + 1])); - Exit(True); - end; -end; - procedure TExportThread.Log(const AText: string); begin WriteLogLine(AText); @@ -667,11 +639,23 @@ var Index: TKeyframeIndex; IndexError: string; HasIndex: Boolean; - Snapped, NextKey, EncodeUntil: Double; - SegEnd: Double; + NextKey, EncodeUntil: Double; + SegEnd, HalfFrame: Double; + WantFrames, HeadFrames, TailFrames: Integer; + WithAudio: Boolean; Dst: string; Rep: TSegmentReport; + { Сколько кадров занимает это время в кадровой сетке проекта. } + function FrameCount(const ASeconds: Double): Integer; + begin + if (FMaster.FpsNum <= 0) or (FMaster.FpsDen <= 0) then + Exit(0); + Result := Round(ASeconds * FMaster.FpsNum / FMaster.FpsDen); + if Result < 0 then + Result := 0; + end; + procedure AddPart(const APart: TSegmentReport); begin if PartCount >= Length(FReport.Segments) then @@ -680,11 +664,40 @@ var Inc(PartCount); end; + { Надо ли делать эту часть заново. + + Часть, доведённая до конца в прошлый раз, берётся готовой: на длинном + ролике это разница между «собрать заново всё» и «доделать хвост». + Проверка стоит у каждой части отдельно, а не у куска целиком: при + точном резе кусок состоит из двух частей, и прошлая сборка могла + оборваться между ними. Раньше проверялся только кусок, и вторая часть + при продолжении просто терялась. } + function NeedRun(const ADst: string): Boolean; + begin + Result := not (IsDone(ExtractFileName(ADst)) and FileExists(ADst)); + if not Result then + begin + Inc(FReusedParts); + Log('Часть уже собрана, беру готовую: ' + ExtractFileName(ADst)); + end; + end; + begin Result := False; PartCount := 0; SetLength(FReport.Segments, Length(FSegments) + 8); + { Звук в кусках картинки нужен только тогда, когда своей звуковой + дорожки у ролика нет: обычно она собрана отдельно и точнее, а лишний + поток в куске ещё и сбивает тайминг склейки. } + WithAudio := Length(FAudio) = 0; + + { Полкадра — мера «попали в тот же кадр». Меньше неё разницы во времени + не существует: между кадрами времени нет. } + HalfFrame := 0.001; + if (FMaster.FpsNum > 0) and (FMaster.FpsDen > 0) then + HalfFrame := FMaster.FpsDen / FMaster.FpsNum / 2; + for I := 0 to High(FSegments) do begin if CancelQuery then @@ -694,16 +707,6 @@ begin Exit; end; - { Кусок, доведённый до конца в прошлый раз, берём готовым. На длинном - ролике это разница между «собрать заново всё» и «доделать хвост». } - if TakeReadyPart(I, Rep) then - begin - AddPart(Rep); - FDoneWork := FDoneWork + FSegments[I].Duration; - PublishProgress; - Continue; - end; - SetStage(Format('Вырезаю кусок %d из %d', [I + 1, Length(FSegments)])); { Сгенерированные вставки берутся из файла, который заведомо длиннее @@ -719,14 +722,16 @@ begin Rep.ActualStart := 0; Rep.Duration := FSegments[I].Duration; Rep.Packets := -1; + Rep.WantFrames := FrameCount(FSegments[I].Duration); Rep.Encoded := False; FStepWork := FSegments[I].Duration; - if not RunFfmpeg(ArgsExtract(FSegments[I].FilePath, Dst, - 0, FSegments[I].Duration, FIntermediateFormat, - FSegments[I].Volume, FMaster.AudioCodec), - 'Подготовка вставки ' + IntToStr(I + 1)) then - Exit; + if NeedRun(Dst) then + if not RunFfmpeg(ArgsExtract(FSegments[I].FilePath, Dst, + 0, FSegments[I].Duration, FIntermediateFormat, + FSegments[I].Volume, FMaster.AudioCodec, Rep.WantFrames, WithAudio), + 'Подготовка вставки ' + IntToStr(I + 1)) then + Exit; Rep.TempPath := Dst; @@ -749,9 +754,11 @@ begin Rep.ActualStart := Rep.RequestedStart; Rep.Duration := FSegments[I].Duration; Rep.Packets := -1; + Rep.WantFrames := FrameCount(FSegments[I].Duration); Rep.Encoded := True; FStepWork := FSegments[I].Duration; + if NeedRun(Dst) then if not RunFfmpeg(ArgsRenderLayers(FSegments[I].Layers, Dst, FSegments[I].Duration, FMaster.Width, FMaster.Height, FMaster.FpsNum, FMaster.FpsDen, FMaster.PixFmt, @@ -768,6 +775,16 @@ begin Continue; end; + { Индекс опорных кадров нужен резу: точно отрезать можно только зная, + где ближайший опорный кадр. Строим его до разбора веток, чтобы + кусок без индекса ушёл на пересборку целиком, а не резался наугад: + наугад — это уехавшее начало и разошедшийся с ним звук. } + HasIndex := BuildKeyframeIndex(FSegments[I].FilePath, Index, IndexError); + if not HasIndex then + Log(Format('Кусок %d: индекс опорных кадров недоступен (%s), ' + + 'кусок пересобирается целиком — без индекса точный рез невозможен.', + [I + 1, IndexError])); + { Кусок от файла, не совпадающего с параметрами проекта, или с вжигаемым текстом. Скопировать его нельзя — пересобираем. Но только его: соседние куски, снятые тем же оборудованием, что и весь @@ -778,7 +795,8 @@ begin параметрах проекта. Раньше они уходили в ветку выше и копировались вместе с текстом мимо: галка вжигания стояла, а надписи на фотографии не появлялось. } - if FSegments[I].Transcode or (FSegments[I].SubFile <> '') then + if FSegments[I].Transcode or (FSegments[I].SubFile <> '') or + (not HasIndex) then begin SetStage(Format('Пересобираю кусок %d из %d (файл не совпадает с проектом)', [I + 1, Length(FSegments)])); @@ -789,6 +807,7 @@ begin Rep.ActualStart := FSegments[I].SrcStart; Rep.Duration := FSegments[I].Duration; Rep.Packets := -1; + Rep.WantFrames := FrameCount(FSegments[I].Duration); Rep.Encoded := True; FStepWork := FSegments[I].Duration; @@ -800,6 +819,7 @@ begin Rep.RequestedStart := FSegments[I].SrcStart; Rep.ActualStart := Rep.RequestedStart; + if NeedRun(Dst) then if not RunFfmpeg(ArgsTranscodeSegment(FSegments[I].FilePath, Dst, Rep.RequestedStart, Rep.RequestedStart + FSegments[I].Duration, FMaster.Width, FMaster.Height, FMaster.FpsNum, FMaster.FpsDen, @@ -817,100 +837,114 @@ begin Continue; end; - HasIndex := BuildKeyframeIndex(FSegments[I].FilePath, Index, IndexError); - if not HasIndex then - Log('Кусок ' + IntToStr(I + 1) + - ': индекс опорных кадров недоступен (' + IndexError + - '), режу как есть — начало может оказаться повреждённым.'); - SegEnd := FSegments[I].SrcStart + FSegments[I].Duration; + { Сколько кадров должно быть в куске. Дальше это число и правит бал: + ffmpeg получает его прямо, а не вычисляет из времён. } + WantFrames := FrameCount(FSegments[I].Duration); Rep.Index := I; Rep.SourcePath := FSegments[I].FilePath; Rep.RequestedStart := FSegments[I].SrcStart; Rep.Packets := -1; - if FSegments[I].PreciseIn and HasIndex then + { Начало уже на опорном кадре — перекодировать нечего, копируем прямо + с него. Это не поблажка точности: результат ровно тот же, просто + достался даром. Так же выходит у материала, где опорный каждый кадр. + + Кадры предыдущей группы за опорным не тянутся — проверено замером + на записи с B-кадрами: первым кадром копии выходит именно он. } + if Abs(SnapNearest(Index, FSegments[I].SrcStart) - + FSegments[I].SrcStart) < HalfFrame then + begin + Dst := FCacheDir + Format('part_%.3d_a%s', [I, FIntermediateExt]); + FStepWork := FSegments[I].Duration; + if NeedRun(Dst) then + if not RunFfmpeg(ArgsExtract(FSegments[I].FilePath, Dst, + FSegments[I].SrcStart, SegEnd, FIntermediateFormat, + FSegments[I].Volume, FMaster.AudioCodec, WantFrames, WithAudio), + 'Вырезание куска ' + IntToStr(I + 1)) then + Exit; + + Rep.ActualStart := FSegments[I].SrcStart; + Rep.Duration := FSegments[I].Duration; + Rep.WantFrames := WantFrames; + Rep.TempPath := Dst; + Rep.Encoded := False; + MarkDone(ExtractFileName(Dst)); + AddPart(Rep); + Continue; + end; + + { Точный рез. Копировать можно только с опорного кадра, поэтому + отрезок от запрошенной точки до следующего опорного кадра + пересобираем заново, а всё, что дальше, копируем как обычно. + Перекодируются доли секунды, а не весь материал. } begin - { Точный рез. Копировать можно только с опорного кадра, поэтому - отрезок от запрошенной точки до следующего опорного кадра - пересобираем заново, а всё, что дальше, копируем как обычно. - Перекодируются доли секунды, а не весь материал. } NextKey := NextKeyframeAfter(Index, FSegments[I].SrcStart); if (NextKey <= 0) or (NextKey >= SegEnd) then EncodeUntil := SegEnd else EncodeUntil := NextKey; - Log(Format('Кусок %d: точный рез, перекодирую %s..%s (%s с), остальное копирую', + { Кадры делятся между огрызком и копией без остатка: сколько не + достанется перекодированию, столько возьмёт копирование. Так стык + не даёт ни дырки, ни задвоенного кадра, а весь кусок выходит ровно + той длины, которую просил монтаж. } + HeadFrames := FrameCount(EncodeUntil - FSegments[I].SrcStart); + if HeadFrames > WantFrames then + HeadFrames := WantFrames; + TailFrames := WantFrames - HeadFrames; + + Log(Format('Кусок %d: точный рез, перекодирую %s..%s (%s с, %d кадров), ' + + 'копирую %d кадров', [I + 1, SecondsToStr(FSegments[I].SrcStart), SecondsToStr(EncodeUntil), - SecondsToStr(EncodeUntil - FSegments[I].SrcStart)])); + SecondsToStr(EncodeUntil - FSegments[I].SrcStart), + HeadFrames, TailFrames])); Dst := FCacheDir + Format('part_%.3d_a%s', [I, FIntermediateExt]); FStepWork := EncodeUntil - FSegments[I].SrcStart; + if NeedRun(Dst) then if not RunFfmpeg(ArgsEncodeFragment(FSegments[I].FilePath, Dst, FSegments[I].SrcStart, EncodeUntil, FMaster.FpsNum, FMaster.FpsDen, FMaster.VideoCodec, FMaster.PixFmt, FMaster.AudioCodec, - FMaster.SampleRate, FMaster.Channels, FIntermediateFormat), + FMaster.SampleRate, FMaster.Channels, FIntermediateFormat, + HeadFrames, WithAudio), 'Перекодирование стыка куска ' + IntToStr(I + 1)) then Exit; Rep.ActualStart := FSegments[I].SrcStart; Rep.Duration := EncodeUntil - FSegments[I].SrcStart; + Rep.WantFrames := HeadFrames; Rep.TempPath := Dst; Rep.Encoded := True; MarkDone(ExtractFileName(Dst)); AddPart(Rep); - if EncodeUntil < SegEnd - 0.0005 then + if TailFrames > 0 then begin Dst := FCacheDir + Format('part_%.3d_b%s', [I, FIntermediateExt]); FStepWork := SegEnd - EncodeUntil; - if not RunFfmpeg(ArgsExtract(FSegments[I].FilePath, Dst, - EncodeUntil, SegEnd, FIntermediateFormat, - FSegments[I].Volume, FMaster.AudioCodec), - 'Вырезание куска ' + IntToStr(I + 1)) then - Exit; + { Копия начинается ровно с опорного кадра: проверено замером — + первым кадром выходит именно он, кадры предыдущей группы за ним + не тянутся. Значит стык с огрызком получается кадр в кадр. } + if NeedRun(Dst) then + if not RunFfmpeg(ArgsExtract(FSegments[I].FilePath, Dst, + EncodeUntil, SegEnd, FIntermediateFormat, + FSegments[I].Volume, FMaster.AudioCodec, TailFrames, WithAudio), + 'Вырезание куска ' + IntToStr(I + 1)) then + Exit; Rep.ActualStart := EncodeUntil; Rep.Duration := SegEnd - EncodeUntil; + Rep.WantFrames := TailFrames; Rep.TempPath := Dst; Rep.Encoded := False; MarkDone(ExtractFileName(Dst)); AddPart(Rep); end; - Continue; end; - - { Обычный рез: начало притягивается к ближайшему опорному кадру, - длина сохраняется. } - Snapped := FSegments[I].SrcStart; - if HasIndex then - begin - Snapped := SnapNearest(Index, FSegments[I].SrcStart); - if Abs(Snapped - FSegments[I].SrcStart) > 0.0005 then - Log(Format('Кусок %d: начало притянуто к опорному кадру, %s вместо %s (сдвиг %s с)', - [I + 1, SecondsToStr(Snapped), SecondsToStr(FSegments[I].SrcStart), - SecondsToStr(Snapped - FSegments[I].SrcStart)])); - end; - - Dst := FCacheDir + Format('part_%.3d_a%s', [I, FIntermediateExt]); - - FStepWork := FSegments[I].Duration; - if not RunFfmpeg(ArgsExtract(FSegments[I].FilePath, Dst, - Snapped, Snapped + FSegments[I].Duration, FIntermediateFormat, - FSegments[I].Volume, FMaster.AudioCodec), - 'Вырезание куска ' + IntToStr(I + 1)) then - Exit; - - Rep.ActualStart := Snapped; - Rep.Duration := FSegments[I].Duration; - Rep.TempPath := Dst; - Rep.Encoded := False; - MarkDone(ExtractFileName(Dst)); - AddPart(Rep); end; SetLength(FReport.Segments, PartCount); @@ -921,12 +955,35 @@ function TExportThread.WriteConcatList(out AListPath: string): Boolean; var List: TStringList; I: Integer; + FrameSeconds: Double; begin + FrameSeconds := 0; + if (FMaster.FpsNum > 0) and (FMaster.FpsDen > 0) then + FrameSeconds := FMaster.FpsDen / FMaster.FpsNum; + AListPath := FCacheDir + 'concat.txt'; List := TStringList.Create; try for I := 0 to High(FReport.Segments) do + begin List.Add(ConcatListLine(FReport.Segments[I].TempPath)); + + { Длительность куска задаётся явно, а не берётся из его контейнера. + + Зачем. У скопированного куска последний пакет в порядке + декодирования может показываться позже всех прочих — так устроено + межкадровое сжатие, и оборванная на резе группа кадров оставляет + такой хвост. Контейнер честно считает его в длительность, склейка + сдвигает на неё следующий кусок, и время картинки растягивается: + замерено 0.05 с лишку на кусок, а к концу ролика — секунды. Звук + при этом набран точно по монтажу и никуда не растягивается, вот + картинка от него и уезжает. + + Число кадров нам известно точно — от него и считаем. } + if (FrameSeconds > 0) and (FReport.Segments[I].WantFrames > 0) then + List.Add('duration ' + + SecondsToStr(FReport.Segments[I].WantFrames * FrameSeconds)); + end; try { Концы строк только LF: список читает разборщик ffmpeg, а не текстовый редактор, и лишний возврат каретки попадёт в имя файла. } @@ -1006,6 +1063,91 @@ end; { --- Звук --- } +{ «Разогрев» кодировщика звука в секундах. + + Сжатый звук начинается с нескольких сотен служебных отсчётов: кодировщик + не может выдать первый кадр, не заглянув вперёд. Контейнер MP4 помечает + их отрицательной меткой времени и проигрыватель их пропускает, а + Matroska такой пометки не хранит — при копировании звук целиком уезжает + позже картинки. Замерено: 21.3 мс, ровно 1024 отсчёта. + + Величина не угадывается и не зашивается числом: кодируется короткая + тишина тем же кодировщиком, и у полученного файла спрашивается метка + первого пакета. Не вышло — возвращаем ноль и ничего не компенсируем: + лучше известная малая погрешность, чем случайный сдвиг. } +function TExportThread.EncoderPriming(ARate, AChannels: Integer): Double; +var + RawPath, OutPath: string; + Stream: TFileStream; + Zero: array[0..4095] of Byte; + I: Integer; + Res: TProcResult; + Value: Double; +begin + Result := 0; + RawPath := FCacheDir + 'priming.raw'; + OutPath := FCacheDir + 'priming.m4a'; + try + try + Stream := TFileStream.Create(RawPath, fmCreate); + try + FillChar(Zero, SizeOf(Zero), 0); + { Полсекунды тишины: кодировщику хватает, а времени это не стоит. } + for I := 1 to Round(ARate * AChannels * 2 * 0.5) div SizeOf(Zero) do + Stream.WriteBuffer(Zero, SizeOf(Zero)); + finally + Stream.Free; + end; + + Res := RunProcess(FfmpegExe, + ArgsEncodeAudio(RawPath, OutPath, ARate, AChannels, + FMaster.AudioCodec), nil, nil, nil); + if (not Res.Started) or (Res.ExitCode <> 0) or (not FileExists(OutPath)) then + Exit; + + FFirstAudioPts := 0; + Res := RunProcess(FfprobeExe, ArgsFirstPacketTime(OutPath), + @FirstPtsLine, nil, nil); + if (not Res.Started) or (Res.ExitCode <> 0) then + Exit; + + Value := -FFirstAudioPts; + { Разумные пределы: отрицательная метка бывает от долей миллисекунды + до кадра звука. Всё, что вне, — не разогрев, а недоразумение. } + if (Value > 0) and (Value < 0.1) then + begin + Result := Value; + Log(Format('Разогрев кодировщика звука: %s с — на столько сдвинулся бы звук, компенсирую.', + [SecondsToStr(Value)])); + end; + except + { Не вышло — просто не компенсируем. } + end; + finally + SysUtils.DeleteFile(RawPath); + SysUtils.DeleteFile(OutPath); + end; +end; + +procedure TExportThread.FirstPtsLine(const ALine: string); +var + Value: Double; + Text: string; +begin + Text := Trim(ALine); + { ffprobe печатает строку с висящей запятой — «-0.021333,». Отрезаем + всё с неё: иначе разбор числа не удаётся, и замер молча не работает. } + if Pos(',', Text) > 0 then + Text := Trim(Copy(Text, 1, Pos(',', Text) - 1)); + if Text = '' then + Exit; + { Первая же разобранная строка и есть метка первого пакета. } + if FFirstAudioPts <> 0 then + Exit; + if TryStrToFloat(Text, Value, InvariantFormat) then + FFirstAudioPts := Value; +end; + function TExportThread.BuildAudio: Boolean; var RawPath, PartPath: string; @@ -1105,7 +1247,8 @@ begin SetStage('Сжимаю звук'); FAudioPath := FCacheDir + 'audio.m4a'; Res := RunProcess(FfmpegExe, - ArgsEncodeAudio(RawPath, FAudioPath, Rate, Channels, FMaster.AudioCodec), + ArgsEncodeAudio(RawPath, FAudioPath, Rate, Channels, FMaster.AudioCodec, + EncoderPriming(Rate, Channels)), @ProgressLine, @ErrorLine, @CancelQuery); if Res.Cancelled then Exit; diff --git a/src/uFfmpegCmd.pas b/src/uFfmpegCmd.pas index 8da9507..7d05609 100644 --- a/src/uFfmpegCmd.pas +++ b/src/uFfmpegCmd.pas @@ -57,10 +57,21 @@ function ToolsAvailable(out AError: string): Boolean; AVolume — множитель громкости. При единице звук копируется как есть; при любом другом значении его приходится пересобрать, потому что громкость нельзя изменить, не тронув сам звук. Видео при этом - по-прежнему копируется — перекодируется только звуковая дорожка. } + по-прежнему копируется — перекодируется только звуковая дорожка. + + AFrames — сколько кадров картинки должно оказаться в куске. Ноль — + сколько выйдет. + + Зачем считать кадры, когда есть время начала и конца. При копировании + потока ffmpeg отмеряет конец по времени декодирования, а не показа, и у + записи с B-кадрами это разные вещи: кусок выходит на пару кадров длиннее + запрошенного. Куски идут один за другим, и эта пара кадров копится — + замерено 0.42 с лишней картинки на шести кусках. Число кадров такой + двусмысленности не имеет. } function ArgsExtract(const ASrc, ADst: string; AStart, AEnd: Double; const AFormat: string; AVolume: Double = 1; - const AAudioCodec: string = ''): TArgs; + const AAudioCodec: string = ''; AFrames: Integer = 0; + AWithAudio: Boolean = True): TArgs; { Перепаковать файл целиком в заданный формат, копируя потоки. } function ArgsCopyTo(const ASrc, ADst, AFormat: string): TArgs; @@ -75,7 +86,8 @@ function ArgsCopyTo(const ASrc, ADst, AFormat: string): TArgs; function ArgsEncodeFragment(const ASrc, ADst: string; AStart, AEnd: Double; AFpsNum, AFpsDen: Integer; const AVideoCodec, APixFmt, AAudioCodec: string; - ASampleRate, AChannels: Integer; const AFormat: string): TArgs; + ASampleRate, AChannels: Integer; const AFormat: string; + AFrames: Integer = 0; AWithAudio: Boolean = True): TArgs; { Пересобрать кусок в параметры проекта. @@ -151,9 +163,17 @@ function ArgsExtractAudioRaw(const ASource, ATarget: string; const AStart, ADuration: Double; ARate, AChannels: Integer; const AVolume: Double): TArgs; -{ Сжатие собранного сырого звука. Один проход на весь ролик. } +{ Сжатие собранного сырого звука. Один проход на весь ролик. + + ASkipSeconds — сколько отбросить от начала. Нужно, чтобы погасить + «разогрев» кодировщика: сжатый звук начинается с нескольких сотен + отсчётов служебного мусора, контейнер MP4 помечает их как пропускаемые, + а Matroska такой пометки не хранит — и при копировании звук уезжает + позже картинки на время разогрева (замерено 21.3 мс). Отбросив ровно + столько же от начала, мы возвращаем содержимое на место. } function ArgsEncodeAudio(const ASource, ATarget: string; - ARate, AChannels: Integer; const ACodec: string): TArgs; + ARate, AChannels: Integer; const ACodec: string; + ASkipSeconds: Double = 0): TArgs; { Пересборка участка с наложением: два слоя сводятся в один кадр. @@ -190,6 +210,10 @@ function ArgsProbeDuration(const ASrc: string): TArgs; декодирования. Читает файл целиком, но не распаковывает. } function ArgsCountVideoPackets(const ASrc: string): TArgs; +{ Метка времени первого звукового пакета. У сжатого звука она + отрицательная — на величину разогрева кодировщика. } +function ArgsFirstPacketTime(const ASrc: string): TArgs; + { Метки времени и признаки всех видеопакетов. Запасной путь построения индекса опорных кадров для контейнеров, кроме MP4. } function ArgsPacketFlags(const ASrc: string): TArgs; @@ -289,7 +313,8 @@ begin end; function ArgsEncodeAudio(const ASource, ATarget: string; - ARate, AChannels: Integer; const ACodec: string): TArgs; + ARate, AChannels: Integer; const ACodec: string; + ASkipSeconds: Double): TArgs; var Codec: string; begin @@ -297,9 +322,11 @@ begin if Codec = '' then Codec := 'aac'; Result := ['-hide_banner', '-nostdin', '-loglevel', 'error', '-y', - '-f', 's16le', '-ar', IntToStr(ARate), '-ac', IntToStr(AChannels), - '-i', ASource, '-c:a', Codec, '-b:a', '192k', - '-progress', 'pipe:1', '-nostats', ATarget]; + '-f', 's16le', '-ar', IntToStr(ARate), '-ac', IntToStr(AChannels)]; + if ASkipSeconds > 0 then + Result := Concat(Result, ['-ss', SecondsToStr(ASkipSeconds)]); + Result := Concat(Result, ['-i', ASource, '-c:a', Codec, '-b:a', '192k', + '-progress', 'pipe:1', '-nostats', ATarget]); end; function ArgsRenderLayers(const ALayers: array of TSegmentLayer; @@ -566,17 +593,25 @@ begin end; function ArgsExtract(const ASrc, ADst: string; AStart, AEnd: Double; - const AFormat: string; AVolume: Double; const AAudioCodec: string): TArgs; + const AFormat: string; AVolume: Double; const AAudioCodec: string; + AFrames: Integer; AWithAudio: Boolean): TArgs; begin Result := Concat(CommonFfmpegPrefix, [ '-ss', SecondsToStr(AStart), '-to', SecondsToStr(AEnd), '-i', ASrc, - '-map', '0:v:0', - '-map', '0:a:0?' + '-map', '0:v:0' ]); - if Abs(AVolume - 1) < 0.001 then + { Звук в куске картинки обычно лишний: звуковая дорожка ролика + собирается отдельно и точнее. Хуже того, он вредит: у кусков + MPEG-TS звуковой поток начинается не там же, где картинка, склейка + равняется по нему, и куски встают со сдвигом — замерено 36 мс на + стык. } + if AWithAudio then + Result := Concat(Result, ['-map', '0:a:0?']); + + if (not AWithAudio) or (Abs(AVolume - 1) < 0.001) then Result := Concat(Result, ['-c', 'copy']) else begin @@ -591,6 +626,12 @@ begin Result := Concat(Result, ['-c:a', 'aac']); end; + { Ровно столько кадров картинки, сколько просили. Время конца остаётся + как было: оно ограничивает звук куска и служит запасом, если кадров в + исходнике не хватит. } + if AFrames > 0 then + Result := Concat(Result, ['-frames:v', IntToStr(AFrames)]); + { Метки времени вырезанного куска должны начинаться с нуля, иначе при склейке куски разъедутся по времени. } Result := Concat(Result, ['-avoid_negative_ts', 'make_zero']); @@ -613,7 +654,8 @@ end; function ArgsEncodeFragment(const ASrc, ADst: string; AStart, AEnd: Double; AFpsNum, AFpsDen: Integer; const AVideoCodec, APixFmt, AAudioCodec: string; - ASampleRate, AChannels: Integer; const AFormat: string): TArgs; + ASampleRate, AChannels: Integer; const AFormat: string; + AFrames: Integer; AWithAudio: Boolean): TArgs; var HalfFrame, StopAt: Double; begin @@ -641,9 +683,10 @@ begin '-ss', SecondsToStr(AStart), '-to', SecondsToStr(StopAt), '-i', ASrc, - '-map', '0:v:0', - '-map', '0:a:0?' + '-map', '0:v:0' ]); + if AWithAudio then + Result := Concat(Result, ['-map', '0:a:0?']); { Ни размер, ни частота кадров намеренно не задаются. Отрезок берётся из того же файла, что и соседние куски, так что менять ему нечего. Хуже того: принудительная частота включает пересчёт кадров, и на @@ -651,6 +694,10 @@ begin Result := Concat(Result, MasterOutputArgs(0, 0, 0, 0, AVideoCodec, APixFmt, AAudioCodec, ASampleRate, AChannels)); Result := Concat(Result, NoBFramesArgs); + { Столько кадров, сколько просили: огрызок стыкуется с копией кадр в + кадр, и лишнему взяться неоткуда. } + if AFrames > 0 then + Result := Concat(Result, ['-frames:v', IntToStr(AFrames)]); Result := Concat(Result, ['-avoid_negative_ts', 'make_zero']); Result := Concat(Result, ProgressArgs); Result := Concat(Result, ['-f', AFormat, ADst]); @@ -882,6 +929,13 @@ begin '-show_entries', 'stream=nb_read_packets', '-of', 'csv=p=0', ASrc]; end; +function ArgsFirstPacketTime(const ASrc: string): TArgs; +begin + Result := ['-v', 'error', '-select_streams', 'a:0', + '-show_entries', 'packet=pts_time', '-of', 'csv=p=0', + '-read_intervals', '%+0.2', ASrc]; +end; + function ArgsPacketFlags(const ASrc: string): TArgs; begin Result := ['-v', 'error', '-select_streams', 'v:0', diff --git a/src/uFlatten.pas b/src/uFlatten.pas index 6d5c53f..3011292 100644 --- a/src/uFlatten.pas +++ b/src/uFlatten.pas @@ -166,6 +166,7 @@ function FlattenPreview(AProject: TProject; правку в готовом файле почти невозможно. Возвращает, сколько секунд осталось. } + function ClipSegmentsToRange(var ASegments: TFlatSegmentArray; const AFrom, ATo: Double): Double; @@ -304,6 +305,42 @@ begin ALayers[I].KeySimilarity, ALayers[I].KeyBlend]); end; +{ Границы кусков — на кадровую сетку проекта. + + Между кадрами времени не существует, и монтаж не должен просить долей + кадра: картинка всё равно выйдет целым числом кадров, а звук набирается + с точностью до отсчёта — и они разойдутся. Каждая доля кадра, округлённая + в свою сторону, копится от куска к куску. + + Квантуются именно границы, а не длины: накопленное начало кладётся на + сетку, а длина считается разностью соседних границ. Тогда куски остаются + встык и невязка не накапливается. Начало в исходнике двигается на ту же + величину — значит показывается тот же материал. + + Картинка и звук проходят через одну и ту же меру, поэтому их границы + совпадают до отсчёта. } +procedure QuantizeSegments(var ASegments: TFlatSegmentArray; + AFpsNum, AFpsDen: Integer); +var + I: Integer; + Step, At, Next: Double; +begin + if (AFpsNum <= 0) or (AFpsDen <= 0) then + Exit; + Step := AFpsDen / AFpsNum; + + At := 0; + for I := 0 to High(ASegments) do + begin + Next := Round((At + ASegments[I].Duration) / Step) * Step; + if Next <= At then + Next := At + Step; + ASegments[I].SrcStart := Round(ASegments[I].SrcStart / Step) * Step; + ASegments[I].Duration := Next - At; + At := Next; + end; +end; + function ClipSegmentsToRange(var ASegments: TFlatSegmentArray; const AFrom, ATo: Double): Double; var @@ -815,12 +852,21 @@ begin исходнике. } Seg.SrcStart := Winner.SrcIn + (SegStart - Winner.TlStart); - { Точность требуется только там, где кусок начинается с собственного - левого края клипа. Границы, возникшие из-за перекрытия соседней - дорожкой, притягиваются к опорному кадру: помечать их точными - значило бы перекодировать стык при каждом наложении. } - Seg.PreciseIn := (Winner.EdgeIn = edPrecise) and - (Abs(SegStart - Winner.TlStart) < 0.0005); + { Начало куска всегда точное. + + Раньше точность просили только у резов, помеченных точными, а + остальные начала притягивались к ближайшему опорному кадру. Звук + при этом брался из непритянутой точки — и картинка внутри куска + показывала материал на величину притяжки позже, чем он звучал. + Замерено по журналам сборки: средняя притяжка 0.24 с, худшая 0.99 с, + и на длинном куске этот сдвиг держится все его минуты. + + Плата за точность — перекодирование от точки реза до ближайшего + опорного кадра, доля секунды на рез. Декодер не умеет начинать с + середины группы кадров, поэтому «резать где угодно и ничего не + перекодировать» не бывает: выбор только между точным резом и + уехавшей точкой реза. } + Seg.PreciseIn := True; { Кусок нельзя скопировать по двум причинам: файл не совпадает с параметрами проекта, либо у клипа своё кадрирование. И то, и @@ -1028,6 +1074,10 @@ begin Seen.Free; end; + { Границы — на кадровую сетку: картинка и звук должны просить одно и то + же время, иначе округление разведёт их на доли кадра за кусок. } + QuantizeSegments(ASegments, AProject.Master.FpsNum, AProject.Master.FpsDen); + Result := True; end; @@ -1134,6 +1184,9 @@ begin if ATotal > At then PushSilence(ATotal - At); + { Той же мерой, что и картинка. } + QuantizeSegments(ASegments, AProject.Master.FpsNum, AProject.Master.FpsDen); + Result := N > 0; end; diff --git a/src/uKeyframeIndex.pas b/src/uKeyframeIndex.pas index 58cc2e5..cf05252 100644 --- a/src/uKeyframeIndex.pas +++ b/src/uKeyframeIndex.pas @@ -20,10 +20,13 @@ Запасной путь: опрос ffprobe. Медленно, но работает с любым контейнером. - Чего этот разбор пока не учитывает: блок elst (список правок), который - способен сдвинуть всю дорожку по времени. У обычных записей с камер его - нет. Если появится материал, где метки разъезжаются, начинать копать - надо отсюда. + Блок elst (список правок) разбор учитывает — и это не мелочь. У любой + записи с B-кадрами первый кадр показывается позже, чем декодируется, и + контейнер выправляет это списком правок: «показ начинать с такого-то + места дорожки». ffmpeg правку применяет, а мы раньше нет — и весь + индекс оказывался сдвинут на задержку B-кадров, на пробе это два кадра. + Дальше по цепочке: рез просил перекодировать до метки, которая опорным + кадром не была, копия начиналась раньше, и кадры на стыке удваивались. } unit uKeyframeIndex; @@ -311,6 +314,9 @@ var DtsCursor, CttsCursor: TRunCursor; Pts: Int64; FoundVideo: Boolean; + EdtsStart, EdtsEnd, ElstStart, ElstEnd: Int64; + MediaTime: Int64; + EntryCount: LongWord; begin Result := False; AError := ''; @@ -391,6 +397,40 @@ begin if Timescale = 0 then Continue; + { Список правок. Нас интересует одно число — с какого места + дорожки начинается показ: на него сдвинуты все времена, которые + показывает ffmpeg. У записи с B-кадрами это ровно задержка + показа, у записи без них правки обычно нет вовсе. + + Отрицательное значение (-1) означает пустой промежуток в начале — + он времена не сдвигает, поэтому такие записи пропускаем. } + MediaTime := 0; + if FindBox(Stream, TrakStart, TrakEnd, 'edts', EdtsStart, EdtsEnd) then + if FindBox(Stream, EdtsStart, EdtsEnd, 'elst', ElstStart, ElstEnd) then + begin + Stream.Position := ElstStart; + Version := 0; + Stream.ReadBuffer(Version, 1); + Stream.Position := ElstStart + 4; + EntryCount := ReadU32(Stream); + if EntryCount > 0 then + begin + if Version = 1 then + begin + { версия 1: длительность и время — по восемь байт } + Stream.Position := ElstStart + 8 + 8; + MediaTime := Int64(ReadU64(Stream)); + end + else + begin + Stream.Position := ElstStart + 8 + 4; + MediaTime := Int32(ReadU32(Stream)); + end; + if MediaTime < 0 then + MediaTime := 0; + end; + end; + if not FindBox(Stream, MdiaStart, MdiaEnd, 'minf', MinfStart, MinfEnd) then Continue; if not FindBox(Stream, MinfStart, MinfEnd, 'stbl', StblStart, StblEnd) then @@ -436,6 +476,11 @@ begin Pts := CursorSumBefore(DtsCursor, Sample); if HasCtts then Inc(Pts, CursorValueAt(CttsCursor, Sample)); + { Правка сдвигает начало показа: ffmpeg считает времена от неё, и + индекс должен считать так же. } + Dec(Pts, MediaTime); + if Pts < 0 then + Pts := 0; AIndex.Times[I] := Pts / Timescale; end; @@ -636,6 +681,7 @@ begin AIndex.Times[I] := Arr.Floats[I]; end; + Result := AIndex.AllFrames or (Length(AIndex.Times) > 0); finally Root.Free; diff --git a/src/uMainForm.pas b/src/uMainForm.pas index df6ba55..7335fca 100644 --- a/src/uMainForm.pas +++ b/src/uMainForm.pas @@ -355,7 +355,6 @@ type procedure MenuUndo(Sender: TObject); procedure MenuRedo(Sender: TObject); procedure MenuSplit(Sender: TObject); - procedure MenuSplitPrecise(Sender: TObject); procedure MenuDelete(Sender: TObject); procedure MenuColorKey(Sender: TObject); procedure MenuCopyLook(Sender: TObject); @@ -761,9 +760,11 @@ begin AddItem(MEdit, 'Отменить', @MenuUndo, ShortCut(VK_Z, [ssCtrl])); AddItem(MEdit, 'Повторить', @MenuRedo, ShortCut(VK_Y, [ssCtrl])); AddSep(MEdit); + { Вид реза теперь один. Раньше их было два — обычный, который при + сборке притягивался к опорному кадру, и точный. Притянутый рез уводил + картинку от звука, поэтому точным стал всякий, а отдельный пункт + потерял смысл. } AddItem(MEdit, 'Разрезать по курсору', @MenuSplit, ShortCut(VK_S, [])); - AddItem(MEdit, 'Разрезать точно (с перекодированием стыка)', - @MenuSplitPrecise, ShortCut(VK_S, [ssShift])); AddItem(MEdit, 'Удалить выделенное', @MenuDelete, ShortCut(VK_DELETE, [])); AddItem(MEdit, 'Выделить всё', @MenuSelectAll, ShortCut(VK_A, [ssCtrl])); AddItem(MEdit, 'Цветовой ключ клипа...', @MenuColorKey); @@ -3727,18 +3728,6 @@ begin SetStatus(SplitReport(FTimeline.SplitAtPlayhead(False)), True); end; -procedure TMainForm.MenuSplitPrecise(Sender: TObject); -var - Count: Integer; -begin - Count := FTimeline.SplitAtPlayhead(True); - if Count > 0 then - SetStatus(SplitReport(Count) + - ' Точный рез: стык будет перекодирован при сборке.', True) - else - SetStatus(SplitReport(Count), True); -end; - procedure TMainForm.MenuDelete(Sender: TObject); begin FTimeline.DeleteSelected; @@ -4584,9 +4573,14 @@ end; procedure TMainForm.ShowExportReport(const AReport: TExportReport); var - I, Encoded: Integer; - Shift: Double; + I, Encoded, Mismatched, WorstPart: Integer; + Shift, FrameSeconds: Double; + WantSum, Diff, WorstDiff: Int64; begin + FrameSeconds := 0; + if (FProject.Master.FpsNum > 0) and (FProject.Master.FpsDen > 0) then + FrameSeconds := FProject.Master.FpsDen / FProject.Master.FpsNum; + if AReport.Cancelled then begin LogExport('Сборка отменена.'); @@ -4633,6 +4627,57 @@ begin else LogExport(Format(' РАСХОЖДЕНИЕ на %d кадров.', [AReport.ActualPackets - AReport.ExpectedPackets])); + + { Сверка с монтажом: одно дело — склейка ничего не потеряла, другое — + получилось ли столько кадров, сколько просил монтаж. Звук набирается + по монтажу с точностью до отсчёта, поэтому лишний или недостающий + кадр картинки — это и есть расхождение звука с картинкой, и о нём + надо говорить числом, а не ждать, пока заметит глаз. } + WantSum := 0; + Mismatched := 0; + WorstPart := -1; + WorstDiff := 0; + for I := 0 to High(AReport.Segments) do + begin + if AReport.Segments[I].WantFrames <= 0 then + Continue; + Inc(WantSum, AReport.Segments[I].WantFrames); + if AReport.Segments[I].Packets < 0 then + Continue; + Diff := AReport.Segments[I].Packets - AReport.Segments[I].WantFrames; + if Diff <> 0 then + begin + Inc(Mismatched); + if Abs(Diff) > Abs(WorstDiff) then + begin + WorstDiff := Diff; + WorstPart := I; + end; + LogExport(Format(' часть %d: кадров %d, а монтаж просил %d (разница %d)', + [I + 1, AReport.Segments[I].Packets, + AReport.Segments[I].WantFrames, Diff])); + end; + end; + + if WantSum > 0 then + begin + LogExport(Format(' картинка: %d кадров, монтаж просил %d', + [AReport.ActualPackets, WantSum])); + Diff := AReport.ActualPackets - WantSum; + if Diff = 0 then + LogExport(' ЗВУК И КАРТИНКА СХОДЯТСЯ: кадров ровно столько, сколько в монтаже.') + else if WorstPart >= 0 then + LogExport(Format(' ЗВУК И КАРТИНКА РАСХОДЯТСЯ на %d %s (%s с), хуже всего часть %d.', + [Abs(Diff), Plural(Abs(Diff), 'кадр', 'кадра', 'кадров'), + SecondsToStr(Abs(Diff) * FrameSeconds), WorstPart + 1])) + else + LogExport(Format(' ЗВУК И КАРТИНКА РАСХОДЯТСЯ на %d %s (%s с).', + [Abs(Diff), Plural(Abs(Diff), 'кадр', 'кадра', 'кадров'), + SecondsToStr(Abs(Diff) * FrameSeconds)])); + if Mismatched > 0 then + LogExport(Format(' частей с неверной длиной: %d', [Mismatched])); + end; + LogExport(' файл: ' + AReport.OutputPath); SetStatus('Готово: ' + AReport.OutputPath); end; diff --git a/tests/clicktimes.py b/tests/clicktimes.py new file mode 100644 index 0000000..baa5f5c --- /dev/null +++ b/tests/clicktimes.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +"""Времена щелчков в звуковой дорожке — по самим отсчётам. + +Читает сырой звук (16 бит, моно, 48000) со стандартного ввода и печатает +время начала каждого громкого куска. Отсчёты, а не окна анализа: любой +оконный способ уплывает по времени, потому что окно двигается кратно +своему размеру, а не секунде, и на замере синхронности это выглядит как +накапливающийся рассинхрон, которого нет. +""" +import sys +import struct + +RATE = 48000 +THRESHOLD = 3000 # из 32767: щелчок заметно громче тишины +GAP = int(RATE * 0.5) # полсекунды тишины разделяет соседние щелчки + +data = sys.stdin.buffer.read() +count = len(data) // 2 +samples = struct.unpack('<%dh' % count, data[:count * 2]) + +last_loud = -GAP * 2 +for i, value in enumerate(samples): + if abs(value) < THRESHOLD: + continue + if i - last_loud > GAP: + print('%.6f' % (i / RATE)) + last_loud = i diff --git a/tests/nlvetool.lpr b/tests/nlvetool.lpr index 5453504..fa98659 100644 --- a/tests/nlvetool.lpr +++ b/tests/nlvetool.lpr @@ -285,6 +285,7 @@ var I, FirstArg: Integer; Shift, CancelAfter, Waited: Double; ExitCodeValue: Integer; + WantSum: Int64; Cancelled: Boolean; begin { Необязательный флаг остановки идёт первым, чтобы не мешать разбору @@ -379,6 +380,23 @@ begin WriteLn(' сумма пакетов кусков: ', Rep.ExpectedPackets); WriteLn(' пакетов в готовом файле: ', Rep.ActualPackets); + + { Сверка с монтажом: столько ли кадров, сколько он просил. Звук набран по + монтажу с точностью до отсчёта, поэтому лишний кадр картинки — это и + есть расхождение звука с картинкой. } + WantSum := 0; + for I := 0 to High(Rep.Segments) do + Inc(WantSum, Rep.Segments[I].WantFrames); + if WantSum > 0 then + begin + WriteLn(' картинка: ', Rep.ActualPackets, ' кадров, монтаж просил ', WantSum); + if Rep.ActualPackets = WantSum then + WriteLn(' ЗВУК И КАРТИНКА СХОДЯТСЯ') + else + WriteLn(' ЗВУК И КАРТИНКА РАСХОДЯТСЯ на ', + Rep.ActualPackets - WantSum, ' кадров'); + end; + WriteLn(' результат: ', Rep.Message); ExitCodeValue := 0; @@ -568,6 +586,7 @@ var Key: TColorKey; Tr: TMediaTransform; I, N: Integer; + Step, At, Total: Double; TempFile, Err: string; begin WriteLn('Проверка уплощения монтажа'); @@ -1004,6 +1023,47 @@ begin end; WriteLn; + { --- 16. Границы кусков лежат на кадровой сетке --- } + WriteLn(' 16. Кадровая сетка и точный рез'); + Proj := MakeProject(2, Media); + try + Proj.Master := Media.Info; + Proj.EnsureAudioTracks; + { Времена намеренно «некруглые»: столько кадров не бывает. } + Proj.AddClipPair(Proj.Tracks[0], Media, 3.3177, 4.4413, 0); + Proj.AddClipPair(Proj.Tracks[0], Media, 12.1301, 5.2077, 4.4413); + + FlattenStream(Proj, tkVideo, 0, Segs, Missing); + DumpSegments(Segs); + + Step := Proj.Master.FpsDen / Proj.Master.FpsNum; + At := 0; + for I := 0 to High(Segs) do + begin + Check(Abs(Segs[I].Duration / Step - Round(Segs[I].Duration / Step)) < 0.001, + Format('кусок %d — целое число кадров: %s с', [I, SecondsToStr(Segs[I].Duration)])); + Check(Abs(Segs[I].SrcStart / Step - Round(Segs[I].SrcStart / Step)) < 0.001, + Format('кусок %d — начало на кадре: %s с', [I, SecondsToStr(Segs[I].SrcStart)])); + if Segs[I].Kind = skVideo then + Check(Segs[I].PreciseIn, + Format('кусок %d режется точно', [I])); + At := At + Segs[I].Duration; + end; + + { Звук уплощается той же мерой: границы обязаны совпасть с картинкой, + иначе звук поедет относительно неё. } + FlattenAudioTrack(Proj, Proj.Tracks[2], At, AudioSegs, Missing); + Total := 0; + for I := 0 to High(AudioSegs) do + Total := Total + AudioSegs[I].Duration; + Check(Abs(Total - At) < 0.0005, + Format('звук и картинка одной длины: %s и %s', + [SecondsToStr(Total), SecondsToStr(At)])); + finally + Proj.Free; + end; + WriteLn; + WriteLn; if FailCount = 0 then begin @@ -1014,6 +1074,67 @@ begin Halt(1); end; +{ --- Проект для замера синхронности звука и картинки --- } + +{ Монтаж из кусков, начала которых заведомо не попадают на опорные кадры. + + Зачем отдельная команда: обычный проект-образец берёт два куска, а тут + нужно много резов подряд и в разных местах группы кадров — иначе не + видно, накапливается ли расхождение и зависит ли оно от того, далеко ли + до опорного кадра. } +procedure CmdSyncProj; +var + Proj: TProject; + Info: TMediaInfo; + Video: TMediaItem; + Err: string; + I: Integer; + At, Len, SrcAt: Double; +const + { Начала кусков в исходнике. Опорные кадры в образце стоят через две + секунды, поэтому точки выбраны с разным отступом от них: почти на + опорном кадре, посередине группы, у самого её конца. } + Starts: array[0..5] of Double = (3.3, 7.77, 12.13, 18.95, 25.02, 31.61); + Piece = 4.0; +begin + Proj := TProject.Create; + try + Proj.Clear; + Proj.AddTrack('V1'); + Proj.EnsureAudioTracks; + + if not ProbeMedia(ExpandFileName(ParamStr(3)), Info, Err) then + begin + WriteLn('Не удалось прочитать видео: ', Err); + Halt(1); + end; + Video := Proj.AddMedia(ExpandFileName(ParamStr(3)), Info); + Proj.Master := Info; + WriteLn('видео: ', DescribeMedia(Info)); + + At := 0; + for I := Low(Starts) to High(Starts) do + begin + SrcAt := Starts[I]; + Len := Piece; + Proj.AddClipPair(Proj.Tracks[0], Video, SrcAt, Len, At); + WriteLn(Format(' кусок %d: из %s, длина %s, на монтаже с %s', + [I + 1, SecondsToStr(SrcAt), SecondsToStr(Len), SecondsToStr(At)])); + At := At + Len; + end; + + if not Proj.SaveToFile(ExpandFileName(ParamStr(2)), Err) then + begin + WriteLn('Не удалось записать проект: ', Err); + Halt(1); + end; + WriteLn('проект записан: ', Proj.FileName); + WriteLn('длина монтажа: ', SecondsToStr(Proj.EndTime), ' с'); + finally + Proj.Free; + end; +end; + { --- Сборка проекта-образца и его отрисовка --- } procedure CmdBuild; @@ -1402,6 +1523,7 @@ var Watcher: TExportWatcher; Rep: TExportReport; I: Integer; + WantSum: Int64; Ok: Boolean; begin Proj := TProject.Create; @@ -1506,6 +1628,23 @@ begin SecondsToStr(Rep.Segments[I].Duration), Rep.Segments[I].Packets])); WriteLn(' сумма пакетов кусков: ', Rep.ExpectedPackets); WriteLn(' пакетов в готовом файле: ', Rep.ActualPackets); + + { Сверка с монтажом: столько ли кадров, сколько он просил. Звук набран по + монтажу с точностью до отсчёта, поэтому лишний кадр картинки — это и + есть расхождение звука с картинкой. } + WantSum := 0; + for I := 0 to High(Rep.Segments) do + Inc(WantSum, Rep.Segments[I].WantFrames); + if WantSum > 0 then + begin + WriteLn(' картинка: ', Rep.ActualPackets, ' кадров, монтаж просил ', WantSum); + if Rep.ActualPackets = WantSum then + WriteLn(' ЗВУК И КАРТИНКА СХОДЯТСЯ') + else + WriteLn(' ЗВУК И КАРТИНКА РАСХОДЯТСЯ на ', + Rep.ActualPackets - WantSum, ' кадров'); + end; + WriteLn(' результат: ', Rep.Message); if not Rep.Succeeded then @@ -1544,6 +1683,8 @@ begin CmdFlatTest else if (Cmd = 'build') and (ParamCount >= 3) then CmdBuild + else if (Cmd = 'syncproj') and (ParamCount >= 3) then + CmdSyncProj else if (Cmd = 'keytest') and (ParamCount >= 4) then CmdKeyTest else if (Cmd = 'buildcut') and (ParamCount >= 3) then diff --git a/tests/syncprobe.sh b/tests/syncprobe.sh new file mode 100755 index 0000000..a1f7698 --- /dev/null +++ b/tests/syncprobe.sh @@ -0,0 +1,123 @@ +#!/bin/sh +# Замер расхождения звука и картинки в собранном ролике. +# +# Как меряется. Делается исходник с метками: ровно на каждой секунде в +# кадре белая вспышка, а в звуке щелчок. Из него собирается монтаж, у +# которого начала кусков заведомо не попадают на опорные кадры. В готовом +# ролике ищутся времена вспышек и времена щелчков; если звук и картинка +# сходятся, каждая вспышка совпадает со щелчком. +# +# Сравнение идёт с поправкой на сам способ измерения: щелчок в сжатом +# звуке размазывается на десяток-другой миллисекунд, и эта поправка +# берётся замером того же исходника. +# +# Запуск: tests/syncprobe.sh — весь прогон +# tests/syncprobe.sh measure ФАЙЛ — только замер готового файла + +set -e + +HERE=$(cd "$(dirname "$0")" && pwd) +ROOT=$(dirname "$HERE") +WORK="$ROOT/test/sync" +SRC="$WORK/метки.mp4" +PROJ="$WORK/синхро.nlve" +OUT="$WORK/синхро.mkv" + +# Времена вспышек: кадры, у которых средняя яркость выше порога. Берётся +# первый кадр каждой вспышки — она длится несколько кадров. +flashes() { + ffprobe -v error -f lavfi "movie=$1,signalstats" \ + -show_entries frame=pts_time:frame_tags=lavfi.signalstats.YAVG \ + -of csv=p=0 2>/dev/null | + awk -F, '$2>150 { if ($1 - prev > 0.5 || NR == 1) print $1; prev = $1 }' +} + +# Времена щелчков: по самим отсчётам, без оконного анализа. Оконный +# уплывает (окно двигается кратно своему размеру), и на замере это +# выглядит как накапливающийся рассинхрон, которого нет. +clicks() { + ffmpeg -v error -i "$1" -vn -ac 1 -ar 48000 -f s16le - | + python3 "$HERE/clicktimes.py" +} + +# Сводит два списка времён и печатает расхождение по каждой метке. +# +# Пара ищется по близости, а не по порядку: при резе не на опорном кадре +# в куске может оказаться лишняя вспышка без своего щелчка, и сравнение +# «первый с первым» после такого куска поехало бы всё. +compare() { + awk ' + FNR == NR { v[++nv] = $1; next } + { + a = $1; best = 1e9; + for (i = 1; i <= nv; i++) { + d = a - v[i]; ad = (d < 0) ? -d : d; + if (ad < best) { best = ad; bd = d } + } + { n++ } + { # Щелчок, у которого вспышки рядом нет вообще, — это край куска: + # метка попала в звук, а её кадр остался за резом. Такую пару + # считать нельзя, иначе итог перевирает сам себя. + if (best > 0.5) { skipped++; next } + } + printf " метка %2d: звук %8.3f ближайшая вспышка %+7.3f с\n", n, a, -bd; + paired++; + s += bd; if (best > worst) { worst = best; wi = n } + } + END { + if (paired == 0) { print " пар не нашлось"; exit 1 } + printf " ИТОГ: среднее расхождение %+.3f с, худшее %.3f с (метка %d)", + s / paired, worst, wi + if (skipped > 0) printf ", одиноких меток %d", skipped + printf "\n" + }' "$1" "$2" +} + +measure() { + FILE="$1" + flashes "$FILE" > "$WORK/вспышки.txt" + clicks "$FILE" > "$WORK/щелчки.txt" + echo " вспышек: $(wc -l < "$WORK/вспышки.txt"), щелчков: $(wc -l < "$WORK/щелчки.txt")" + compare "$WORK/вспышки.txt" "$WORK/щелчки.txt" +} + +if [ "$1" = "measure" ]; then + mkdir -p "$WORK" + measure "$2" + exit 0 +fi + +mkdir -p "$WORK" + +if [ ! -f "$SRC" ]; then + echo "=== Исходник с метками ===" + # Опорные кадры через две секунды: тогда рез почти всегда приходится на + # середину группы, как на настоящей съёмке с камеры. + ffmpeg -y -v error \ + -f lavfi -i "color=c=black:s=640x360:r=60000/1001:d=60" \ + -f lavfi -i "aevalsrc=sin(1000*2*PI*t)*lt(mod(t\,1)\,0.05):s=48000:c=stereo:d=60" \ + -vf "drawbox=x=0:y=0:w=640:h=360:color=white:t=fill:enable='lt(mod(t\,1)\,0.02)'" \ + -c:v libx264 -preset veryfast -g 120 -keyint_min 120 -sc_threshold 0 \ + -pix_fmt yuv420p -c:a aac -b:a 128k "$SRC" + echo "готово: $SRC" +fi + +echo "=== Поправка на способ измерения (сам исходник) ===" +measure "$SRC" + +echo "=== Монтаж с резами не на опорных кадрах ===" +"$HERE/nlvetool" syncproj "$PROJ" "$SRC" + +echo "=== Сборка ===" +rm -f "$OUT" +"$HERE/nlvetool" render "$PROJ" "$OUT" | tail -5 + +echo "=== Замер готового ролика ===" +measure "$OUT" + +echo "=== Длительности потоков готового ролика ===" +ffprobe -v error -select_streams v:0 -count_packets \ + -show_entries stream=r_frame_rate,nb_read_packets -of csv=p=0 "$OUT" | + awk -F, '{ split($1, r, "/"); printf " картинка: %s кадров = %.3f с\n", $2, $2 * r[2] / r[1] }' +ffprobe -v error -show_entries format=duration -of csv=p=0 "$OUT" | + awk -F, '{ printf " файл целиком: %.3f с\n", $2 }'