Звук больше не отстаёт от картинки

Рез всегда точный. Раньше начало куска притягивалось к ближайшему
опорному кадру, а звук брался из непритянутой точки: по журналам сборки
средняя притяжка 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 с.
This commit is contained in:
4aiman 2026-09-16 18:28:29 +03:00
commit 019d016db9
9 changed files with 849 additions and 158 deletions

View file

@ -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`), а не
оконным анализом: окно двигается кратно своему размеру, и его собственный
уход выглядит как накапливающийся рассинхрон, которого нет. На это уже
попались один раз.
## Структура
| Файл | Что это |

View file

@ -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;

View file

@ -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',

View file

@ -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;

View file

@ -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;

View file

@ -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;

27
tests/clicktimes.py Normal file
View file

@ -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

View file

@ -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

123
tests/syncprobe.sh Executable file
View file

@ -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 }'