Наложение в предпросмотре сводится заранее; номер сборки в заголовке

Клип, не заполняющий кадр, стал уходить плееру отдельной дорожкой, и
весь показ пошёл через цепочку фильтров. Цепочка работает всё время
показа и считается процессором: на кадре 2688x1512 видео начало
отставать от звука.

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

Попутно, из разбора того же случая:

— заполнитель до первого наложения нарезается по длине файла. Одной
  записью mpv её обрезает («entry 0 uses 7082.687000 seconds, but file
  has only 15.021000»), дорожка оказывается короче монтажа и не
  совмещается с ним по времени;
— появление заготовки вызывает перезагрузку списка: строка списка при
  этом не меняется, и без признака готовности плеер так и оставался без
  дорожки наложения;
— в журнал пишется, сколько окон наложения и готовы ли файлы, а также
  собранная цепочка показа и неудача video-add. Без этого «картинки
  нет» нечем объяснить.

В заголовке окна теперь номер сборки: nlve 42 — фильм.nlve. Он растёт на
единицу при каждой сборке. По нему сразу видно, та ли это сборка, где
правка уже есть, — сегодня время ушло на то, что проверяли старым
двоичным файлом. Счётчик в build.number, в репозиторий не входит.

Проверено на монтаже с картинкой на 300-й секунде: в окне она лежит
поверх видео, цепочки показа нет.
This commit is contained in:
4aiman 2026-10-16 17:19:57 +03:00
commit 567c518a62
7 changed files with 192 additions and 16 deletions

4
.gitignore vendored
View file

@ -92,3 +92,7 @@ shot*.png
# Мусор компоновщика Lazarus. # Мусор компоновщика Lazarus.
link*.res link*.res
# Номер сборки и созданный из него файл: про эту машину, не про историю.
/build.number
/src/uBuildInfo.inc

View file

@ -731,12 +731,18 @@ Linux потом искала по нему `.dll`.
неоткуда. Заготовка с полями остаётся для случая, когда класть не на неоткуда. Заготовка с полями остаётся для случая, когда класть не на
что. что.
В предпросмотре накладываемая картинка тоже показывается заготовкой, Предпросмотр идёт тем же путём: участок с наложением сводится заранее
только без полей. Своих декодеров плееру хватает не на всё: настоящий и играется обычным файлом. Силами плеера рисуются только прозрачные
webp из сети дал «webp: invalid TIFF header in Exif data», и картинка клипы и клипы с ключом — их участки бывают длинными, и пересобирать
пропадала из окна, хотя в ролик попадала. У неподвижного кадра есть и минуты материала ради них дорого. Цепочка плеера работает всё время
вторая беда — в списке кусков у него нет длины. Заготовка же обычное показа и считается процессором: на кадре 2688x1512 видео начинает
видео нужной длины. отставать от звука, поэтому ради клипа, который просто не заполняет
кадр, её не включают.
Картинка, которую всё же рисует плеер, отдаётся ему заготовкой без
полей, а не самим файлом: своих декодеров ему хватает не на всё
(настоящий webp из сети дал «webp: invalid TIFF header in Exif data»),
да и у неподвижного кадра нет длины в списке кусков.
3. Границы кусков кладутся на кадровую сетку проекта: между кадрами 3. Границы кусков кладутся на кадровую сетку проекта: между кадрами
времени нет, а звук набирается с точностью до отсчёта, и доли кадра времени нет, а звук набирается с точностью до отсчёта, и доли кадра
разводят их в стороны. разводят их в стороны.
@ -1104,6 +1110,18 @@ xdotool не видит ни одного окна и проверка «про
времени, второй щелчок по наезду тащит верхний клип вместо нижнего, а времени, второй щелчок по наезду тащит верхний клип вместо нижнего, а
картинка с уменьшением 50% выходит в ролик во весь кадр. картинка с уменьшением 50% выходит в ролик во весь кадр.
## Номер сборки
В заголовке окна стоит номер: `nlve 42 — фильм.nlve`. Он растёт на
единицу при каждой сборке редактора (`build.sh`), и по нему сразу видно,
та ли это сборка, где нужная правка уже есть. Без него проверяют старым
двоичным файлом, видят прежнее поведение, и время уходит на выяснение,
у кого что не работает.
Счётчик лежит в `build.number`, из него создаётся `src/uBuildInfo.inc`.
Оба файла в репозиторий не входят: номер про эту машину, а не про
историю проекта.
## Структура ## Структура
| Файл | Что это | | Файл | Что это |

View file

@ -72,8 +72,37 @@ run_lazbuild() {
return "$rc" return "$rc"
} }
# Номер сборки. Растёт на единицу при каждой сборке редактора и попадает
# в заголовок окна.
#
# Зачем: по заголовку сразу видно, та ли это сборка, где правка уже есть.
# Без него человек проверяет старым двоичным файлом и видит прежнее
# поведение — так уже случалось, и время ушло на выяснение, у кого что
# не работает.
#
# Счётчик лежит рядом с исходниками, но в репозиторий не входит: он про
# эту машину, а не про историю проекта.
bump_version() {
local file="$HERE/build.number" n
n=0
[ -f "$file" ] && n=$(cat "$file" 2>/dev/null || echo 0)
case "$n" in
''|*[!0-9]*) n=0 ;;
esac
n=$((n + 1))
echo "$n" > "$file"
cat > "$HERE/src/uBuildInfo.inc" <<EOF
{ Создаётся build.sh при каждой сборке. В репозиторий не входит. }
const
BuildNumber = $n;
BuildStamp = '$(date '+%d.%m.%Y %H:%M')';
EOF
echo "сборка номер $n"
}
build_current() { build_current() {
check_toolchain check_toolchain
bump_version
if run_lazbuild "Редактор под эту систему ($LCL_LINUX)" \ if run_lazbuild "Редактор под эту систему ($LCL_LINUX)" \
--ws="$LCL_LINUX" "$HERE/nlve.lpi"; then --ws="$LCL_LINUX" "$HERE/nlve.lpi"; then
echo "готово: $HERE/nlve" echo "готово: $HERE/nlve"

View file

@ -57,6 +57,16 @@ type
Y: Integer; Y: Integer;
Width: Integer; Width: Integer;
Height: Integer; Height: Integer;
{ Сколько секунд можно взять из этого файла.
Список кусков оверлея обязан покрывать монтаж от самого начала,
иначе плееру нечем совместить его по времени с основой. Промежуток
до первого показа заполняется тем же файлом, и просить у него
больше, чем в нём есть, нельзя: mpv обрезает такую запись
(«entry uses 7082.687000 seconds, but file has only 15.021000») —
дорожка оказывается короче монтажа, и оверлей не показывается
вовсе. Поэтому заполнитель нарезается на куски по этой длине. }
SrcLength: Double;
end; end;
TPreviewOverlays = array of TPreviewOverlay; TPreviewOverlays = array of TPreviewOverlay;
@ -1391,6 +1401,21 @@ end;
хочет увидеть под ним то, что на дорожке ниже, — иначе зачем уменьшать. хочет увидеть под ним то, что на дорожке ниже, — иначе зачем уменьшать.
Если под клипом ничего нет, поля остаются полями: класть не на что. } Если под клипом ничего нет, поля остаются полями: класть не на что. }
{ Кого плеер рисует поверх основы сам, своей цепочкой.
Только прозрачные клипы и клипы с ключом. Цепочка идёт через процессор
и работает всё время показа, а не только там, где лежит оверлей: на
2688x1512 видео начинает отставать от звука. Ради длинного
полупрозрачного слоя это оправдано — иначе пришлось бы пересобирать
минуты материала. Ради клипа, который просто не заполняет кадр, — нет:
его участок дешевле свести заранее, как для ролика, и тогда
предпросмотр играет обычный файл. }
function IsPlayerOverlay(AClip: TClip): Boolean;
begin
Result := (AClip <> nil) and (AClip.Kind in [ckVideo, ckImage]) and
(AClip.ColorKey.Enabled or (AClip.Opacity < 0.999));
end;
function IsOverlayClip(AProject: TProject; AClip: TClip): Boolean; function IsOverlayClip(AProject: TProject; AClip: TClip): Boolean;
var var
Item: TMediaItem; Item: TMediaItem;
@ -1471,7 +1496,11 @@ begin
for C := 0 to Track.Count - 1 do for C := 0 to Track.Count - 1 do
begin begin
Clip := Track[C]; Clip := Track[C];
if not IsOverlayClip(AProject, Clip) then { Плееру отдаём только прозрачные и с ключом. Остальное, что не
заполняет кадр, сводится заранее вместе с тем, что под ним:
так предпросмотр совпадает с роликом и не упирается в
процессор. }
if not IsPlayerOverlay(Clip) then
Continue; Continue;
Item := AProject.FindMedia(Clip.MediaId); Item := AProject.FindMedia(Clip.MediaId);
if Item = nil then if Item = nil then
@ -1526,6 +1555,7 @@ begin
Ov.TlEnd := RangeB[J]; Ov.TlEnd := RangeB[J];
Ov.FilePath := Item.EffectivePath; Ov.FilePath := Item.EffectivePath;
Ov.SrcStart := Clip.SrcIn + (RangeA[J] - Clip.TlStart); Ov.SrcStart := Clip.SrcIn + (RangeA[J] - Clip.TlStart);
Ov.SrcLength := Item.Info.Duration;
Ov.Filter := OverlayFilter(Clip.Transform, Ov.Filter := OverlayFilter(Clip.Transform,
Item.Info.Width, Item.Info.Height, Item.Info.Width, Item.Info.Height,
AProject.Master.Width, AProject.Master.Height, AProject.Master.Width, AProject.Master.Height,
@ -1556,6 +1586,7 @@ begin
ImgReq.TlEnd := Ov.TlEnd; ImgReq.TlEnd := Ov.TlEnd;
Ov.FilePath := ImgReq.TargetPath; Ov.FilePath := ImgReq.TargetPath;
Ov.SrcStart := 0; Ov.SrcStart := 0;
Ov.SrcLength := ImgReq.Duration;
Ov.Filter := ''; Ov.Filter := '';
if not FileExists(ImgReq.TargetPath) then if not FileExists(ImgReq.TargetPath) then
begin begin

View file

@ -33,6 +33,10 @@ uses
uIconData, uSubtitles, uTextMeasure, uTextForm, uExportForm, uIconData, uSubtitles, uTextMeasure, uTextForm, uExportForm,
uColorKeyForm; uColorKeyForm;
{ Номер сборки и время. Файл создаётся build.sh при каждой сборке и в
репозиторий не входит. }
{$I uBuildInfo.inc}
const const
{ Номера системных сообщений о перерисовке рамки окна. Берём числами, а { Номера системных сообщений о перерисовке рамки окна. Берём числами, а
не из модуля Windows: тот переопределяет TRect и Point, и обычные не из модуля Windows: тот переопределяет TRect и Point, и обычные
@ -130,6 +134,9 @@ type
FCheckMisplacedOnShow: Boolean; FCheckMisplacedOnShow: Boolean;
FPreviewOverlayUrl: string; FPreviewOverlayUrl: string;
FPreviewOverlayWindows: TOverlayWindows; FPreviewOverlayWindows: TOverlayWindows;
{ Лежат ли на диске все файлы наложения. Входит в сравнение списков:
появление заготовки обязано вызвать перезагрузку. }
FPreviewOverlayReady: Boolean;
{ Списки кусков звуковых дорожек для плеера. } { Списки кусков звуковых дорожек для плеера. }
FPreviewAudioUrls: array of string; FPreviewAudioUrls: array of string;
{ Звуковые дорожки предпросмотра — их номера в проекте, а не сами { Звуковые дорожки предпросмотра — их номера в проекте, а не сами
@ -1578,7 +1585,11 @@ begin
ProjectName := ExtractFileName(FProject.FileName); ProjectName := ExtractFileName(FProject.FileName);
if FProject.Modified then if FProject.Modified then
ProjectName := ProjectName + ' *'; ProjectName := ProjectName + ' *';
Caption := 'nlve — ' + ProjectName; { Номер сборки в заголовке: по нему сразу видно, та ли это сборка, где
правка уже есть. Без него проверяют старым двоичным файлом, видят
прежнее поведение, и время уходит на выяснение, у кого что не
работает. }
Caption := Format('nlve %d — %s', [BuildNumber, ProjectName]);
end; end;
procedure TMainForm.UpdateTimeLabel; procedure TMainForm.UpdateTimeLabel;
@ -2392,6 +2403,29 @@ var
Inc(N); Inc(N);
end; end;
{ Заполнить промежуток повторами файла, не прося у него больше, чем в
нём есть. Длина неизвестна — кладём одной записью, как было. }
procedure FillGap(const AOv: TPreviewOverlay; const AFrom, ATo: Double);
var
Left, Step: Double;
begin
Left := ATo - AFrom;
if AOv.SrcLength <= 0.001 then
begin
Push(AOv.FilePath, 0, Left);
Exit;
end;
{ Полсекунды запаса: длина файла у ffmpeg и у mpv расходится в
последнем знаке, и запись ровно во весь файл даёт то же самое
предупреждение об обрезке. }
Step := Max(0.5, AOv.SrcLength - 0.5);
while Left > 0.001 do
begin
Push(AOv.FilePath, 0, Min(Step, Left));
Left := Left - Step;
end;
end;
begin begin
FPreviewOverlayUrl := ''; FPreviewOverlayUrl := '';
FPreviewOverlayWindows := nil; FPreviewOverlayWindows := nil;
@ -2402,9 +2436,16 @@ begin
At := 0; At := 0;
for I := 0 to High(AOverlays) do for I := 0 to High(AOverlays) do
begin begin
{ Промежуток до этого куска — заполнитель из того же файла. } { Промежуток до этого куска — заполнитель из того же файла, нарезанный
по его длине.
Одной записью нельзя: mpv обрезает запись, которая просит больше,
чем есть в файле («entry uses 7082.687000 seconds, but file has only
15.021000»), дорожка оверлея оказывается короче монтажа и не
совмещается с ним по времени — наложение просто не показывается.
Замерено на монтаже в два часа с картинкой на 7082-й секунде. }
if AOverlays[I].TlStart > At + 0.001 then if AOverlays[I].TlStart > At + 0.001 then
Push(AOverlays[I].FilePath, 0, AOverlays[I].TlStart - At); FillGap(AOverlays[I], At, AOverlays[I].TlStart);
Push(AOverlays[I].FilePath, AOverlays[I].SrcStart, Push(AOverlays[I].FilePath, AOverlays[I].SrcStart,
AOverlays[I].TlEnd - AOverlays[I].TlStart); AOverlays[I].TlEnd - AOverlays[I].TlStart);
At := AOverlays[I].TlEnd; At := AOverlays[I].TlEnd;
@ -2429,6 +2470,23 @@ begin
Exit; Exit;
end; end;
FPreviewOverlayUrl := BuildEdlUri(Segs); FPreviewOverlayUrl := BuildEdlUri(Segs);
{ Все ли файлы наложения уже есть на диске.
Картинка показывается заготовкой, а она делается не мгновенно. Пока
её нет, плееру нечего добавлять, и дорожки наложения он не заводит.
Когда заготовка появится, список кусков останется прежним — строка в
строку, — и перезагрузки не случится: предпросмотр сравнивает именно
строки. Поэтому готовность входит в сравнение наравне с ними.
Проверено на монтаже, где картинка так и не показывалась до первой
правки. }
FPreviewOverlayReady := True;
for I := 0 to N - 1 do
if not FileExists(Segs[I].FilePath) then
begin
FPreviewOverlayReady := False;
Break;
end;
end; end;
{ Делает ли идущая генерация то, что монтажу ещё нужно. { Делает ли идущая генерация то, что монтажу ещё нужно.
@ -2524,6 +2582,8 @@ begin
{ Наложение входит в сравнение наравне со звуком: сдвинули оверлей — { Наложение входит в сравнение наравне со звуком: сдвинули оверлей —
список кусков основы мог не измениться, а показывать надо иначе. } список кусков основы мог не измениться, а показывать надо иначе. }
AudioEdl := AudioEdl + '#' + FPreviewOverlayUrl; AudioEdl := AudioEdl + '#' + FPreviewOverlayUrl;
if not FPreviewOverlayReady then
AudioEdl := AudioEdl + '@ждём';
for I := 0 to High(FPreviewOverlayWindows) do for I := 0 to High(FPreviewOverlayWindows) do
AudioEdl := AudioEdl + Format('|%s@%d:%d:%s-%s', AudioEdl := AudioEdl + Format('|%s@%d:%d:%s-%s',
[FPreviewOverlayWindows[I].Filter, [FPreviewOverlayWindows[I].Filter,
@ -2552,6 +2612,12 @@ begin
{ Внешний звук назначается до загрузки: он часть того, что открывается. } { Внешний звук назначается до загрузки: он часть того, что открывается. }
FPlayer.SetAudioSources(FPreviewAudioUrls); FPlayer.SetAudioSources(FPreviewAudioUrls);
FPlayer.SetVideoOverlay(FPreviewOverlayUrl, FPreviewOverlayWindows); FPlayer.SetVideoOverlay(FPreviewOverlayUrl, FPreviewOverlayWindows);
{ Наложение — самая хрупкая часть предпросмотра: оно живёт отдельной
дорожкой, которой может не оказаться. Пишем в журнал, сколько окон
показа и готовы ли файлы, иначе «картинки нет» нечем объяснить. }
if FPreviewOverlayUrl <> '' then
Log(Format('Наложение: окон %d, файлы %s', [Length(FPreviewOverlayWindows),
BoolToStr(FPreviewOverlayReady, 'готовы', 'ещё делаются')]));
FPlayer.LoadUrl(Edl, Format('start=%s,pause=%s', FPlayer.LoadUrl(Edl, Format('start=%s,pause=%s',
[SecondsToStr(Pos), BoolToStr(WasPaused, 'yes', 'no')])); [SecondsToStr(Pos), BoolToStr(WasPaused, 'yes', 'no')]));
FLastLoadedEdl := Edl; FLastLoadedEdl := Edl;

View file

@ -1093,6 +1093,10 @@ begin
if Graph = FLastGraph then if Graph = FLastGraph then
Exit; Exit;
FLastGraph := Graph; FLastGraph := Graph;
{ В журнал пишется всегда, в том числе пустая: по ней видно, собралось
ли наложение. Пустая цепочка при заявленном наложении означает, что
дорожки для него у плеера нет. }
Note('Цепочка показа: ' + Graph);
mpv_set_property_string(FHandle, 'lavfi-complex', PAnsiChar(Graph)); mpv_set_property_string(FHandle, 'lavfi-complex', PAnsiChar(Graph));
end; end;
@ -1103,7 +1107,7 @@ end;
она заменила бы картинку. Её дело — быть входом цепочки. } она заменила бы картинку. Её дело — быть входом цепочки. }
procedure TMpvPlayer.AttachOverlay; procedure TMpvPlayer.AttachOverlay;
var var
Count, I: Integer; Count, I, Code: Integer;
Kind, Ext, Id: string; Kind, Ext, Id: string;
begin begin
if FHandle = nil then if FHandle = nil then
@ -1113,7 +1117,12 @@ begin
FOverlayVid := ''; FOverlayVid := '';
if (FOverlayUrl <> '') and (Length(FOverlayWindows) > 0) then if (FOverlayUrl <> '') and (Length(FOverlayWindows) > 0) then
Command(['video-add', FOverlayUrl, 'auto']); begin
Code := Command(['video-add', FOverlayUrl, 'auto']);
if Code < 0 then
Note(Format('Дорожку наложения добавить не удалось: video-add вернул %d',
[Code]));
end;
Count := StrToIntDef(GetPropString('track-list/count'), 0); Count := StrToIntDef(GetPropString('track-list/count'), 0);
for I := 0 to Count - 1 do for I := 0 to Count - 1 do
@ -1133,6 +1142,13 @@ begin
else if FBaseVid = '' then else if FBaseVid = '' then
FBaseVid := Id; FBaseVid := Id;
end; end;
{ Пустая дорожка наложения при заявленном наложении — самый частый
способ остаться без картинки в окне. Пишем, что увидели. }
if (FOverlayUrl <> '') and (Length(FOverlayWindows) > 0) and
((FOverlayVid = '') or (FBaseVid = '')) then
Note(Format('Наложение не подключилось: дорожек %d, основа "%s", ' +
'наложение "%s"', [Count, FBaseVid, FOverlayVid]));
end; end;
{ Субтитры подключаются двумя путями, и оба нужны. { Субтитры подключаются двумя путями, и оба нужны.

View file

@ -1134,17 +1134,29 @@ begin
Check(Pos('pad=', Segs[0].Layers[1].FitFilter) = 0, Check(Pos('pad=', Segs[0].Layers[1].FitFilter) = 0,
'у верхнего слоя полей нет'); 'у верхнего слоя полей нет');
{ Предпросмотру картинка отдаётся готовым куском, а не самим файлом: { Предпросмотр идёт тем же путём, что и сборка: участок сводится
своих декодеров у плеера хватает не на всё, а у неподвижного кадра заранее. Плееру его не отдаём — цепочка наложения работает всё
нет длины. } время показа и на большом кадре уводит видео от звука. }
FlattenPreview(Proj, Segs, AudioSegs, Overlays, Missing, True); FlattenPreview(Proj, Segs, AudioSegs, Overlays, Missing, True);
Check(Length(Overlays) = 1, 'картинка ушла в накладываемые'); Check(Length(Overlays) = 0,
'непрозрачная картинка плееру не отдаётся');
Check((Length(Segs) = 1) and (Length(Segs[0].Layers) = 2),
'в предпросмотре тот же кусок со слоями, что и в сборке');
{ А вот прозрачная уходит плееру — и тоже готовым куском, а не самим
файлом: своих декодеров у него хватает не на всё, а у неподвижного
кадра нет длины. }
Clip.Opacity := 0.5;
FlattenPreview(Proj, Segs, AudioSegs, Overlays, Missing, True);
Check(Length(Overlays) = 1, 'прозрачная картинка ушла в накладываемые');
if Length(Overlays) = 1 then if Length(Overlays) = 1 then
begin begin
Check(Pos('.nlve-cache', Overlays[0].FilePath) > 0, Check(Pos('.nlve-cache', Overlays[0].FilePath) > 0,
'показывается готовый кусок, а не сам файл'); 'показывается готовый кусок, а не сам файл');
Check(Pos('pad=', Overlays[0].Filter) = 0, Check(Pos('pad=', Overlays[0].Filter) = 0,
'полей в цепочке наложения нет'); 'полей в цепочке наложения нет');
Check(Overlays[0].SrcLength > 0,
'длина куска известна: заполнитель нарежут по ней');
PlainPath := ''; PlainPath := '';
for I := 0 to High(Missing) do for I := 0 to High(Missing) do
if Missing[I].TargetPath = Overlays[0].FilePath then if Missing[I].TargetPath = Overlays[0].FilePath then