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

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

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