Звук больше не отстаёт от картинки
Рез всегда точный. Раньше начало куска притягивалось к ближайшему опорному кадру, а звук брался из непритянутой точки: по журналам сборки средняя притяжка 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
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