Звук больше не отстаёт от картинки
Рез всегда точный. Раньше начало куска притягивалось к ближайшему опорному кадру, а звук брался из непритянутой точки: по журналам сборки средняя притяжка 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:
parent
18e55d466e
commit
019d016db9
9 changed files with 849 additions and 158 deletions
77
README.md
77
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`), а не
|
||||
оконным анализом: окно двигается кратно своему размеру, и его собственный
|
||||
уход выглядит как накапливающийся рассинхрон, которого нет. На это уже
|
||||
попались один раз.
|
||||
|
||||
## Структура
|
||||
|
||||
| Файл | Что это |
|
||||
|
|
|
|||
355
src/uExport.pas
355
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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
27
tests/clicktimes.py
Normal 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
|
||||
|
|
@ -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
123
tests/syncprobe.sh
Executable 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 }'
|
||||
Loading…
Add table
Add a link
Reference in a new issue