diff --git a/src/uFfmpegCmd.pas b/src/uFfmpegCmd.pas index 6aa99ab..1760602 100644 --- a/src/uFfmpegCmd.pas +++ b/src/uFfmpegCmd.pas @@ -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', diff --git a/src/uFlatten.pas b/src/uFlatten.pas index a9f4994..4c1e973 100644 --- a/src/uFlatten.pas +++ b/src/uFlatten.pas @@ -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; diff --git a/src/uGenerate.pas b/src/uGenerate.pas index 7e9afb1..5a52eac 100644 --- a/src/uGenerate.pas +++ b/src/uGenerate.pas @@ -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; diff --git a/src/uMainForm.pas b/src/uMainForm.pas index 00e5b0e..da805d1 100644 --- a/src/uMainForm.pas +++ b/src/uMainForm.pas @@ -1494,6 +1494,11 @@ begin Exit; end; + { Участки с прозрачностью и переходами плееру не по зубам: список + кусков накладывать не умеет. Подставляем вместо них заранее + посчитанные файлы, а чего ещё нет — заказываем. } + ResolveLayers(FProject, Segments, Missing); + { Каждая звуковая дорожка идёт своим списком кусков: они смешиваются, а не побеждают друг друга. Дыры в каждой дорожке свои, и вставки тишины под них нужны свои — без этого список ссылается на несуществующий diff --git a/tests/nlvetool.lpr b/tests/nlvetool.lpr index c9baa10..5d385ff 100644 --- a/tests/nlvetool.lpr +++ b/tests/nlvetool.lpr @@ -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