Прозрачность и переходы видны в предпросмотре

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

Считает их тот же код, что и сборка ролика. Разными путями считать одно
и то же нельзя: предпросмотр разошёлся бы с результатом.

Для сборки подстановка не применяется: там наложение считается заново с
параметрами вывода, и промежуточный файл был бы лишней потерей качества.

Проверено на монтаже с клипом в 50% прозрачности, где оба слоя из одного
файла: кадр наложения ближе к полусумме двух слоёв (отклонение 13.8), чем
к любому из них по отдельности (26.0 и 18.4).

Попутно найдено и исправлено: наложения кодировались процессорным x265,
потому что ffmpeg выбирает кодировщик по имени кодека, а мы передавали
«hevc». Замерено: 9.6 с материала за 11.2 с работы. Теперь и здесь
берётся предпочтительный кодировщик, аппаратный.

В служебную программу добавлена команда preview: список кусков ровно
такой, каким его видит предпросмотр, вместе с готовыми наложениями.
This commit is contained in:
4aiman 2026-08-21 18:19:33 +03:00
commit 1e00cd9f44
5 changed files with 197 additions and 2 deletions

View file

@ -318,10 +318,14 @@ begin
[SecondsToStr(ALayers[High(ALayers)].Opacity)]);
end;
{ Кодировщик выбирается предпочтительный, а не тот, что ffmpeg возьмёт
по имени кодека. Имя «hevc» уводит его на процессорный x265, и участок
наложения считался медленнее реального времени: замерено 9.6 с
материала за 11.2 с работы. }
Result := Concat(Result,
['-filter_complex', Graph, '-map', '[out]',
'-t', SecondsToStr(ADuration),
'-c:v', ACodec, '-r', Format('%d/%d', [AFpsNum, AFpsDen]),
'-c:v', PreferredEncoder(ACodec), '-r', Format('%d/%d', [AFpsNum, AFpsDen]),
'-pix_fmt', APixFmt]);
Result := Concat(Result,
['-bf', '0', '-an', '-avoid_negative_ts', 'make_zero',

View file

@ -51,13 +51,15 @@ type
Со звуковой вставкой в той же точке -11.7 dB.
В звуковом файле опорным является каждый кадр, и такой беды нет. }
TGenKind = (gkNone, gkBlack, gkImage, gkSilence);
TGenKind = (gkNone, gkBlack, gkImage, gkSilence, gkLayers);
TGenRequest = record
Kind: TGenKind;
SourcePath: string;
Duration: Double;
TargetPath: string;
{ Только для gkLayers: что с чем смешивать. }
Layers: TSegmentLayers;
end;
TGenRequests = array of TGenRequest;
@ -106,12 +108,30 @@ function FlattenProject(AProject: TProject;
один раз. }
function GeneratedPath(const ACacheDir: string; const AReq: TGenRequest): string;
{ Заменяет куски с наложением готовыми файлами — для предпросмотра.
Плеер играет список кусков, а список накладывать не умеет: там побеждает
верхний клип целиком. Поэтому участки с прозрачностью и переходами
заранее собираются в отдельные файлы, и предпросмотр играет уже их. Файл
один раз посчитан и лежит в кэше, так что при следующем открытии ждать
нечего.
Чего ещё не готово — уходит в AMissing тем же путём, что чёрные вставки:
окно их закажет и повторит сборку списка.
Для сборки ролика это не применяется: там наложение считается заново с
параметрами вывода, и промежуточный файл был бы лишней потерей
качества. }
procedure ResolveLayers(AProject: TProject; var ASegments: TFlatSegmentArray;
var AMissing: TGenRequests);
{ Заготовка чёрной вставки самой ходовой длины.
Делается заранее, сразу после того как стали известны параметры проекта.
Иначе первая же дыра на монтаже ждала бы генерации, и в это время
предпросмотр играл бы прежнюю расстановку — выглядело бы так, будто
удаление клипа или выключение дорожки не действуют. }
function PrewarmRequests(const ACacheDir: string): TGenRequests;
implementation
@ -164,6 +184,11 @@ begin
Name := Format('black_%d.mkv', [Ms]);
gkSilence:
Name := Format('silence_%d.m4a', [Ms]);
gkLayers:
{ Имя выводится из описания слоёв: одно и то же наложение даёт один
и тот же файл, и пересчитывать его второй раз не приходится. }
Name := Format('mix_%s_%d.mkv',
[Copy(MD5Print(MD5String(AReq.SourcePath)), 1, 12), Ms]);
gkImage:
begin
Key := AReq.SourcePath + '|' + IntToStr(Ms);
@ -179,6 +204,62 @@ begin
Result := IncludeTrailingPathDelimiter(ACacheDir) + 'gen' + PathDelim + Name;
end;
{ Описание слоёв одной строкой. Из неё выводится имя файла, поэтому в неё
входит всё, что влияет на картинку: исходник, место в нём, кадрирование
и доли прозрачности. }
function LayersKey(const ALayers: TSegmentLayers;
const ADuration: Double): string;
var
I: Integer;
begin
Result := SecondsToStr(ADuration);
for I := 0 to High(ALayers) do
Result := Result + '|' + ALayers[I].FilePath +
'@' + SecondsToStr(ALayers[I].SrcStart) +
'#' + ALayers[I].Filter +
'*' + SecondsToStr(ALayers[I].Opacity) +
'>' + SecondsToStr(ALayers[I].FadeFrom) +
'<' + SecondsToStr(ALayers[I].FadeTo);
end;
procedure ResolveLayers(AProject: TProject; var ASegments: TFlatSegmentArray;
var AMissing: TGenRequests);
var
I: Integer;
Req: TGenRequest;
begin
if AProject = nil then
Exit;
for I := 0 to High(ASegments) do
begin
if Length(ASegments[I].Layers) = 0 then
Continue;
Req := Default(TGenRequest);
Req.Kind := gkLayers;
Req.Duration := ASegments[I].Duration;
Req.SourcePath := LayersKey(ASegments[I].Layers, ASegments[I].Duration);
Req.Layers := ASegments[I].Layers;
Req.TargetPath := GeneratedPath(AProject.CacheDir, Req);
if not FileExists(Req.TargetPath) then
begin
SetLength(AMissing, Length(AMissing) + 1);
AMissing[High(AMissing)] := Req;
Continue;
end;
{ Готовый файл встаёт на место куска целиком: слои в нём уже сведены,
кадрировать и пересобирать больше нечего. }
ASegments[I].FilePath := Req.TargetPath;
ASegments[I].SrcStart := 0;
ASegments[I].Filter := '';
ASegments[I].Transcode := False;
ASegments[I].Layers := nil;
end;
end;
function PrewarmRequests(const ACacheDir: string): TGenRequests;
var
Req: TGenRequest;

View file

@ -178,6 +178,9 @@ begin
gkSilence:
FStageText := Format('Делаю вставку тишины %d из %d',
[I + 1, Length(FRequests)]);
gkLayers:
FStageText := Format('Собираю наложение %d из %d',
[I + 1, Length(FRequests)]);
end;
Publish;
@ -203,6 +206,15 @@ begin
Args := ArgsSilenceClip(FRequests[I].TargetPath,
FRequests[I].Duration, AudioCodec,
FMaster.SampleRate, FMaster.Channels);
gkLayers:
{ Тот же расчёт, что и при сборке ролика: прозрачность —
наложением с приглушённым альфа-каналом, переход — фильтром
xfade. Разными путями считать одно и то же нельзя, иначе
предпросмотр разойдётся с готовым роликом. }
Args := ArgsRenderLayers(FRequests[I].Layers,
FRequests[I].TargetPath, FRequests[I].Duration,
FMaster.Width, FMaster.Height, FMaster.FpsNum, FMaster.FpsDen,
FMaster.PixFmt, FMaster.VideoCodec, False);
else
Continue;
end;

View file

@ -1494,6 +1494,11 @@ begin
Exit;
end;
{ Участки с прозрачностью и переходами плееру не по зубам: список
кусков накладывать не умеет. Подставляем вместо них заранее
посчитанные файлы, а чего ещё нет — заказываем. }
ResolveLayers(FProject, Segments, Missing);
{ Каждая звуковая дорожка идёт своим списком кусков: они смешиваются, а
не побеждают друг друга. Дыры в каждой дорожке свои, и вставки тишины
под них нужны свои — без этого список ссылается на несуществующий

View file

@ -23,6 +23,10 @@
render <проект.nlve> <выход>
уплотить проект, создать вставки, собрать ролик
и сверить число кадров
preview <проект.nlve>
собрать список кусков так же, как это делает
предпросмотр: с готовыми наложениями вместо
участков с прозрачностью и переходами
audio <проект.nlve>
разложить звуковые дорожки проекта на куски и
напечатать их вместе со строками edl://
@ -940,6 +944,93 @@ begin
end;
end;
{ Список кусков предпросмотра.
Отличается от сборки одним: участки с прозрачностью и переходами плеер
показать не может, поэтому они заранее сводятся в отдельные файлы. Здесь
видно, какие именно участки и во что превратились. }
procedure CmdPreview;
var
Proj: TProject;
Segs, AudioSegs: TFlatSegmentArray;
Missing: TGenRequests;
Err: string;
I: Integer;
At: Double;
Gen: TGenerateThread;
GenWatch: TGenWatcher;
Kind: string;
begin
Proj := TProject.Create;
try
if not Proj.LoadFromFile(ExpandFileName(ParamStr(2)), Err) then
begin
WriteLn('Не удалось прочитать проект: ', Err);
Halt(1);
end;
if not FlattenProject(Proj, Segs, AudioSegs, Missing) then
begin
WriteLn('Уплотить нечего.');
Halt(1);
end;
ResolveLayers(Proj, Segs, Missing);
if Length(Missing) > 0 then
begin
WriteLn('Не хватает вставок: ', Length(Missing), '. Делаю.');
Gen := TGenerateThread.Create(Missing, Proj.Master);
GenWatch := TGenWatcher.Create(Gen);
try
Gen.OnProgress := @GenWatch.Progress;
Gen.OnLog := @GenWatch.LogLine;
Gen.Start;
while not Gen.Finished do
CheckSynchronize(100);
CheckSynchronize(0);
if not Gen.Succeeded then
begin
WriteLn('Не вышло: ', Gen.Message);
Halt(1);
end;
finally
GenWatch.Free;
Gen.Free;
end;
{ Второй заход: теперь файлы на месте и должны подставиться. }
Missing := nil;
ResolveLayers(Proj, Segs, Missing);
if Length(Missing) > 0 then
begin
WriteLn('Часть наложений так и не появилась.');
Halt(1);
end;
end;
At := 0;
for I := 0 to High(Segs) do
begin
if Length(Segs[I].Layers) > 0 then
Kind := 'НАЛОЖЕНИЕ (плеер не покажет)'
else if Segs[I].Kind = skBlack then
Kind := 'чёрное'
else
Kind := 'кусок ';
WriteLn(Format(' [%2d] %s монтаж %8s..%8s из %8s %s',
[I, Kind, SecondsToStr(At), SecondsToStr(At + Segs[I].Duration),
SecondsToStr(Segs[I].SrcStart), ExtractFileName(Segs[I].FilePath)]));
At := At + Segs[I].Duration;
end;
WriteLn;
WriteLn(BuildEdlUri(Segs));
finally
Proj.Free;
end;
end;
procedure CmdRender;
var
Proj: TProject;
@ -1081,6 +1172,8 @@ begin
CmdBuild
else if (Cmd = 'buildcut') and (ParamCount >= 3) then
CmdBuildCut
else if (Cmd = 'preview') and (ParamCount >= 2) then
CmdPreview
else if (Cmd = 'audio') and (ParamCount >= 2) then
CmdAudio
else if (Cmd = 'render') and (ParamCount >= 3) then