Сборка вставки не начинается заново от каждой правки

Затухание в семьдесят секунд не собиралось никогда: сборка сведённого
куска шла полторы минуты, и любая правка в это время сбрасывала её на
ноль. В журнале это видно как «Расстановка изменилась» и тот же самый
запуск ffmpeg по кругу.

Причина — в вопросе, который задавался. «Нужна ли ещё эта работа»
проверялось по списку недостающего, а недостающее у нас — это то, чего
нет на диске. У куска, который делается прямо сейчас, файл уже есть:
ffmpeg создаёт его в первую же секунду. Кусок в список не попадал, ответ
был «не нужна», работа отменялась.

Теперь уплощение отдельно отдаёт список нужного — независимо от того,
лежит файл на диске или ещё пишется. Заодно закрыта та же дыра со
вставками тишины: проверка их вообще не видела, потому что звуковые
дорожки считаются отдельным проходом.

Проверено обеими сторонами (tests/genrestart.sh, на настоящем материале,
затухание 70 с): пять резов в стороне от затухания — один запуск и ни
одной отмены, было шесть запусков и десять отмен. Удалили всё — работа
прекращается за две секунды, как и прежде.
This commit is contained in:
4aiman 2026-10-01 00:47:28 +03:00
commit d2a49343c3
4 changed files with 229 additions and 7 deletions

View file

@ -840,6 +840,16 @@ Wine с новой библиотекой не грузилось ничего.
привычный RGB. Записанный по привычке код даёт цвет с переставленными привычный RGB. Записанный по привычке код даёт цвет с переставленными
красным и синим. красным и синим.
**Список недостающего — не список нужного.** Недостающее у нас — это то,
чего нет на диске. Но у куска, который делается прямо сейчас, файл уже
есть: ffmpeg создаёт его в первую же секунду и пишет до конца. Проверка
«нужна ли ещё эта работа» спрашивала по списку недостающего — и отвечала
«не нужна» про то, что делается. Работа отменялась в разгаре и начиналась
заново. На коротком затухании это проходило незаметно, на затухании в
семьдесят секунд сборка не заканчивалась никогда: любая правка сбрасывала
её. Теперь уплощение отдельно отдаёт список нужного, независимо от того,
лежит файл на диске или ещё пишется (`tests/genrestart.sh`).
**MousePos у колеса мыши — не экранный.** По описанию `DoMouseWheel` **MousePos у колеса мыши — не экранный.** По описанию `DoMouseWheel`
получает точку в координатах экрана, но под gtk2 в ней уже клиентские: получает точку в координатах экрана, но под gtk2 в ней уже клиентские:
`ScreenToClient` переводит их второй раз, и точка уезжает влево ровно на `ScreenToClient` переводит их второй раз, и точка уезжает влево ровно на
@ -935,6 +945,21 @@ Wine с новой библиотекой не грузилось ничего.
уход выглядит как накапливающийся рассинхрон, которого нет. На это уже уход выглядит как накапливающийся рассинхрон, которого нет. На это уже
попались один раз. попались один раз.
Ещё три проверки гоняют саму программу на невидимом экране (`Xvfb :99`).
Рабочий экран они не занимают:
```
./tests/buttonclicks.sh <окно> <x> <y> каждый щелчок по кнопке считается
./tests/zoomanchor.py <окно> масштаб берётся от точки под мышкой
./tests/genrestart.sh [<путь к nlve>] сборка вставки не сбрасывается зря
```
Последней нужен проект с одним клипом и долгим — секунд на семьдесят —
затуханием: такой кусок сводится около полутора минут, и всё это время
человек продолжает работать. Путь к проекту задаётся переменной
`NLVE_FADE_PROJECT`; в репозиторий он не входит, потому что ссылается на
съёмку, которой здесь нет.
## Структура ## Структура
| Файл | Что это | | Файл | Что это |

View file

@ -119,7 +119,8 @@ function FlattenStream(AProject: TProject; AKind: TTrackKind;
списком кусков, а дальше они складываются его же средствами. } списком кусков, а дальше они складываются его же средствами. }
function FlattenAudioTrack(AProject: TProject; ATrack: TTrack; function FlattenAudioTrack(AProject: TProject; ATrack: TTrack;
const ATotal: Double; const ATotal: Double;
out ASegments: TFlatSegmentArray; out AMissing: TGenRequests): Boolean; out ASegments: TFlatSegmentArray; out AMissing: TGenRequests;
AWanted: TStrings = nil): Boolean;
{ Длина слышимого материала одного вида дорожек. } { Длина слышимого материала одного вида дорожек. }
function AudibleEnd(AProject: TProject; AKind: TTrackKind): Double; function AudibleEnd(AProject: TProject; AKind: TTrackKind): Double;
@ -196,9 +197,14 @@ function GeneratedPath(const ACacheDir: string; const AReq: TGenRequest): string
Для сборки ролика это не применяется: там наложение считается заново с Для сборки ролика это не применяется: там наложение считается заново с
параметрами вывода, и промежуточный файл был бы лишней потерей параметрами вывода, и промежуточный файл был бы лишней потерей
качества. } качества.
AWanted — куда сложить пути всех сведённых кусков, которые этой
расстановке нужны, независимо от того, лежат они уже на диске или ещё
делаются. По списку недостающего этого не узнать: файл появляется в
первую же секунду работы ffmpeg и пишется до конца. }
procedure ResolveLayers(AProject: TProject; var ASegments: TFlatSegmentArray; procedure ResolveLayers(AProject: TProject; var ASegments: TFlatSegmentArray;
var AMissing: TGenRequests); var AMissing: TGenRequests; AWanted: TStrings = nil);
{ Заготовка чёрной вставки самой ходовой длины. { Заготовка чёрной вставки самой ходовой длины.
@ -416,7 +422,7 @@ begin
end; end;
procedure ResolveLayers(AProject: TProject; var ASegments: TFlatSegmentArray; procedure ResolveLayers(AProject: TProject; var ASegments: TFlatSegmentArray;
var AMissing: TGenRequests); var AMissing: TGenRequests; AWanted: TStrings);
var var
I: Integer; I: Integer;
Req: TGenRequest; Req: TGenRequest;
@ -443,6 +449,16 @@ begin
Req.TlEnd := At + ASegments[I].Duration; Req.TlEnd := At + ASegments[I].Duration;
At := Req.TlEnd; At := Req.TlEnd;
{ Список нужного — это не то же самое, что список недостающего.
Недостающее — чего нет на диске. Но пока кусок делается, файл уже
есть: ffmpeg создаёт его сразу и пишет в него до самого конца.
Спрашивать «нужен ли ещё тот кусок, что сейчас делается» по списку
недостающего бессмысленно — его там не будет, и работа отменится в
разгаре. }
if AWanted <> nil then
AWanted.Add(Req.TargetPath);
if not FileExists(Req.TargetPath) then if not FileExists(Req.TargetPath) then
begin begin
SetLength(AMissing, Length(AMissing) + 1); SetLength(AMissing, Length(AMissing) + 1);
@ -1115,7 +1131,8 @@ end;
function FlattenAudioTrack(AProject: TProject; ATrack: TTrack; function FlattenAudioTrack(AProject: TProject; ATrack: TTrack;
const ATotal: Double; const ATotal: Double;
out ASegments: TFlatSegmentArray; out AMissing: TGenRequests): Boolean; out ASegments: TFlatSegmentArray; out AMissing: TGenRequests;
AWanted: TStrings): Boolean;
var var
C, N: Integer; C, N: Integer;
At: Double; At: Double;
@ -1160,6 +1177,11 @@ var
Req.Duration := BucketFor(Take); Req.Duration := BucketFor(Take);
Req.TargetPath := GeneratedPath(AProject.CacheDir, Req); Req.TargetPath := GeneratedPath(AProject.CacheDir, Req);
Seg.FilePath := Req.TargetPath; Seg.FilePath := Req.TargetPath;
{ Нужное и недостающее — разные списки: см. пояснение у
ResolveLayers. Кусок, который делается прямо сейчас, на диске уже
есть, и в недостающее он не попадёт. }
if AWanted <> nil then
AWanted.Add(Req.TargetPath);
{ Один и тот же файл не заказываем дважды: длинная тишина набирается { Один и тот же файл не заказываем дважды: длинная тишина набирается
повторами, и без этой проверки минутный кусок делался бы столько повторами, и без этой проверки минутный кусок делался бы столько
раз, сколько его повторов. } раз, сколько его повторов. }

View file

@ -2327,11 +2327,29 @@ end;
одна из готовящихся вставок в новом списке не нужна, работу можно одна из готовящихся вставок в новом списке не нужна, работу можно
прекращать. Ошибиться тут не страшно: отменённое, если оно всё же прекращать. Ошибиться тут не страшно: отменённое, если оно всё же
понадобится, закажут заново на следующей пересборке. } понадобится, закажут заново на следующей пересборке. }
{ Нужна ли ещё та работа, что идёт сейчас.
Спрашивать по одному только списку недостающего нельзя, и это стоило
повисшей сборки. Недостающее — то, чего нет на диске; а у куска,
который делается прямо сейчас, файл уже есть: ffmpeg создаёт его в
первую же секунду и пишет до конца. Поэтому сведённый кусок в списке
недостающего не появлялся, проверка отвечала «не нужен», работа
отменялась в разгаре — и начиналась заново с нуля.
На коротком затухании это проходило незаметно: кусок успевал
посчитаться между правками. На затухании в семьдесят секунд, которое
считается полторы минуты, любая следующая правка сбрасывала работу, и
сборка не заканчивалась никогда.
Поэтому спрашиваем иначе: нужен ли расстановке сам этот путь — неважно,
лежит он уже на диске или ещё пишется. }
function TMainForm.GenerationStillNeeded: Boolean; function TMainForm.GenerationStillNeeded: Boolean;
var var
Segs, Auds: TFlatSegmentArray; Segs, Auds, TrackSegs: TFlatSegmentArray;
Ovl: TPreviewOverlays; Ovl: TPreviewOverlays;
Missing: TGenRequests; Missing, TrackMissing: TGenRequests;
Wanted: TStringList;
TotalLen: Double;
I, J: Integer; I, J: Integer;
begin begin
Result := False; Result := False;
@ -2341,6 +2359,33 @@ begin
FSettings.PreviewKey) then FSettings.PreviewKey) then
Exit; Exit;
Wanted := TStringList.Create;
try
ResolveLayers(FProject, Segs, Missing, Wanted);
{ Звуковые дорожки считаются тем же путём, что и при пересборке:
вставки тишины под дыры у каждой дорожки свои, и они тоже могут
делаться прямо сейчас. }
TotalLen := Max(AudibleEnd(FProject, tkVideo), AudibleEnd(FProject, tkAudio));
for I := 0 to FProject.TrackCount - 1 do
begin
if FProject.Tracks[I].Kind <> tkAudio then
Continue;
if not FProject.TrackAudible(FProject.Tracks[I]) then
Continue;
if FProject.Tracks[I].Count = 0 then
Continue;
FlattenAudioTrack(FProject, FProject.Tracks[I], TotalLen,
TrackSegs, TrackMissing, Wanted);
end;
for J := 0 to FGenerate.TargetCount - 1 do
if Wanted.IndexOf(FGenerate.TargetPath(J)) >= 0 then
Exit(True);
finally
Wanted.Free;
end;
for I := 0 to High(Missing) do for I := 0 to High(Missing) do
for J := 0 to FGenerate.TargetCount - 1 do for J := 0 to FGenerate.TargetCount - 1 do
if Missing[I].TargetPath = FGenerate.TargetPath(J) then if Missing[I].TargetPath = FGenerate.TargetPath(J) then

130
tests/genrestart.sh Executable file
View file

@ -0,0 +1,130 @@
#!/bin/sh
# Сборка сведённого куска не должна начинаться заново от каждой правки.
#
# Затухание, наплыв, прозрачность — всё это предпросмотр не умеет играть
# как есть: такой кусок заранее сводится в отдельный файл. На затухании в
# семьдесят секунд файл считается около полутора минут, и всё это время
# человек продолжает работать.
#
# Проверяется два обратных случая:
# 1. Режем монтаж в стороне от затухания. Сам кусок от этого не
# меняется — значит сборка обязана продолжаться, а не начинаться
# заново.
# 2. Удаляем всё. Кусок стал не нужен — сборка обязана прекратиться.
#
# Раньше первый случай отменял работу: «нужен ли ещё этот кусок»
# спрашивалось по списку недостающего, а у куска, который делается прямо
# сейчас, файл уже есть — ffmpeg создаёт его сразу. Кусок в список не
# попадал, работа отменялась в разгаре и начиналась с нуля.
#
# Запуск: tests/genrestart.sh [<путь к nlve>]
# Проект берётся из test/поток/длинный-фейд.nlve (см. README, раздел
# проверок): один клип с затуханием в семьдесят секунд.
set -e
cd "$(dirname "$0")/.."
BIN=${1:-$PWD/nlve}
DISP=${NLVE_DISPLAY:-:99}
# Проект для проверки в репозиторий не входит: он ссылается на съёмку,
# которой в нём нет. Годится любой с одним клипом и долгим затуханием —
# путь можно задать переменной.
PROJ=${NLVE_FADE_PROJECT:-$PWD/test/поток/длинный-фейд.nlve}
LOG=$(dirname "$BIN")/nlve.log
[ -f "$PROJ" ] || { echo "нет проекта для проверки: $PROJ"; exit 2; }
start() {
pkill -x nlve 2>/dev/null || true
# Закрытая программа не уносит с собой свой ffmpeg: дочерний процесс
# остаётся и продолжает писать в кэш. Убираем только его — по пути
# этого самого проекта, чтобы не задеть чужую работу.
pkill -f "длинный-фейд.nlve-cache" 2>/dev/null || true
sleep 1
rm -rf "$PWD/test/поток/длинный-фейд.nlve-cache" "$PWD/test/поток/профиль-$1"
rm -f "$PROJ.autosave"
SP=$PWD/test/поток/профиль-$1
mkdir -p "$SP"
: > "$LOG"
XDG_CONFIG_HOME=$SP XDG_CACHE_HOME=$SP XDG_DATA_HOME=$SP DISPLAY=$DISP \
"$BIN" "$PROJ" >/dev/null 2>&1 &
sleep 16
WIN=$(DISPLAY=$DISP xdotool search --name "длинный-фейд" | tail -1)
DISPLAY=$DISP xdotool windowmove "$WIN" 0 0 windowsize "$WIN" 1500 860
sleep 2
}
runs() { grep -c filter_complex "$LOG" || true; }
stops() { grep -c 'Расстановка изменилась' "$LOG" || true; }
BAD=0
echo "1. Правки в стороне от затухания"
start правки
echo " сборка началась: запусков $(runs)"
X=650
I=0
while [ $I -lt 5 ]; do
DISPLAY=$DISP xdotool mousemove $X 640 click 1
sleep 0.6
DISPLAY=$DISP xdotool key s
sleep 3
X=$((X + 40))
I=$((I + 1))
done
sleep 5
N=$(runs)
S=$(stops)
echo " после пяти резов: запусков $N, отмен $S"
case "$(DISPLAY=$DISP xdotool getwindowname "$WIN")" in
*\*) ;;
*) echo " ПЛОХО: монтаж не изменился, правки не дошли — проверка ничего не значит"
BAD=$((BAD + 1)) ;;
esac
if [ "$N" = "1" ] && [ "$S" = "0" ]; then
echo " ok: работа не сбрасывалась"
else
echo " ПЛОХО: сборка начиналась заново"
BAD=$((BAD + 1))
fi
echo
echo "2. Кусок стал не нужен"
start отмена
echo " сборка началась: запусков $(runs)"
DISPLAY=$DISP xdotool mousemove 700 600 click 1
sleep 0.5
DISPLAY=$DISP xdotool key ctrl+a
sleep 0.8
DISPLAY=$DISP xdotool key Delete
# Ждём, пока работа прекратится, а не спим наугад: ffmpeg получает сигнал
# и ещё дописывает то, что у него в руках. Именно -x у pgrep: иначе под
# раздачу попадёт и сама эта оболочка, у которой слово ffmpeg есть в
# командной строке.
ALIVE=1
WAITED=0
while [ "$WAITED" -lt 30 ]; do
sleep 1
WAITED=$((WAITED + 1))
ALIVE=$(pgrep -a -x ffmpeg | grep -c "длинный-фейд.nlve-cache" || true)
[ "$ALIVE" = "0" ] && break
done
S=$(stops)
echo " отмен $S, живых ffmpeg $ALIVE, ждали $WAITED с"
if [ "$S" -ge 1 ] && [ "$ALIVE" = "0" ]; then
echo " ok: ненужная работа прекращена"
else
echo " ПЛОХО: работа продолжается впустую"
BAD=$((BAD + 1))
fi
pkill -x nlve 2>/dev/null || true
pkill -f "длинный-фейд.nlve-cache" 2>/dev/null || true
rm -f "$PROJ.autosave"
echo
if [ "$BAD" = "0" ]; then
echo "ВЕРДИКТ: сборка вставок не сбрасывается зря и не идёт впустую"
else
echo "ВЕРДИКТ: не пройдено случаев: $BAD"
exit 1
fi