From 3583f748640f34754856576d5e8446430e03df62 Mon Sep 17 00:00:00 2001 From: chaiman Date: Thu, 20 Aug 2026 12:10:57 +0300 Subject: [PATCH] =?UTF-8?q?=D0=97=D0=B2=D1=83=D0=BA=D0=BE=D0=B2=D1=8B?= =?UTF-8?q?=D0=B5=20=D0=B4=D0=BE=D1=80=D0=BE=D0=B6=D0=BA=D0=B8=20=D1=81?= =?UTF-8?q?=D0=BC=D0=B5=D1=88=D0=B8=D0=B2=D0=B0=D1=8E=D1=82=D1=81=D1=8F,?= =?UTF-8?q?=20=D0=B0=20=D0=BD=D0=B5=20=D0=BF=D0=BE=D0=B1=D0=B5=D0=B6=D0=B4?= =?UTF-8?q?=D0=B0=D1=8E=D1=82=20=D0=B4=D1=80=D1=83=D0=B3=20=D0=B4=D1=80?= =?UTF-8?q?=D1=83=D0=B3=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Правило «верхний побеждает» я перенёс на звук механически, и это была ошибка: оно придумано для картинки, где и делает возможной сборку копированием. Для звука оно бессмысленно — под голос нельзя было положить фоновую музыку. И стоит смешивание ничего: звук всё равно перекодируется. В сборке куски со звуком нескольких дорожек сводятся тем же путём, что переходы: слоями, каждый со своей громкостью. В предпросмотре сложнее: плеер играет одну звуковую дорожку за раз. Поэтому каждая дорожка монтажа подаётся своим списком кусков, а смесь собирается цепочкой фильтров плеера. Громкость клипов подставляется в ту же цепочку — иначе предпросмотр врал бы про баланс. Проверено: два тона на разных дорожках, второй с громкостью 50%. В готовом ролике -21.2 и -27.1 dB, в предпросмотре -21.2 и -27.2. Совпало. Найдена по дороге ошибка в собственном условии: я считал слои без победителя и потому обнулял список, когда звучали ровно две дорожки. --- README.md | 12 ++++ src/uFlatten.pas | 166 ++++++++++++++++++++++++++++++++++++++++++++- src/uMainForm.pas | 86 +++++++++++++++++------ src/uMpvPlayer.pas | 124 ++++++++++++++++++++++++++++++--- 4 files changed, 355 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index c844ace..32c9a85 100644 --- a/README.md +++ b/README.md @@ -292,6 +292,18 @@ Linux потом искала по нему `.dll`. громкость. Клип с непрозрачностью меньше единицы смешивается с тем, что под ним. +**Звуковые дорожки смешиваются.** Правило «верхний побеждает» придумано +для картинки — оно и делает возможной сборку копированием. Для звука оно +бессмысленно: без смешивания нельзя положить фоновую музыку под голос. И +стоит оно ничего, потому что звук всё равно перекодируется. + +Плеер играет одну звуковую дорожку за раз, поэтому в предпросмотре смесь +собирается его же цепочкой фильтров: каждая дорожка монтажа подаётся +своим списком кусков, дальше `amix`. Громкость клипов подставляется в ту +же цепочку — иначе предпросмотр врал бы про баланс. Проверено: в +предпросмотре и в готовом ролике уровни совпадают до десятой доли +децибела. + Два клипа **на одной дорожке**, наползшие друг на друга, дают переход: один плавно перетекает в другой. На разных дорожках перекрытие означает не переход, а наложение — там по-прежнему побеждает верхний, если он diff --git a/src/uFlatten.pas b/src/uFlatten.pas index c8ebe1d..ba0b030 100644 --- a/src/uFlatten.pas +++ b/src/uFlatten.pas @@ -71,6 +71,15 @@ function FlattenStream(AProject: TProject; AKind: TTrackKind; const AMinLength: Double; out ASegments: TFlatSegmentArray; out AMissing: TGenRequests): Boolean; +{ Уплощение одной звуковой дорожки — для предпросмотра. + + Плеер умеет играть только одну звуковую дорожку за раз, поэтому смесь + ему приходится собирать самому: каждая дорожка монтажа подаётся своим + списком кусков, а дальше они складываются его же средствами. } +function FlattenAudioTrack(AProject: TProject; ATrack: TTrack; + const ATotal: Double; + out ASegments: TFlatSegmentArray; out AMissing: TGenRequests): Boolean; + { Длина слышимого материала одного вида дорожек. } function AudibleEnd(AProject: TProject; AKind: TTrackKind): Double; @@ -274,6 +283,70 @@ var AProject.Master.Height); end; + { Все звучащие клипы в этот момент, со всех слышимых звуковых дорожек. + + Если звучит ровно один и без перехода, слои не нужны — кусок описан + обычным способом. } + procedure CollectAudioLayers(AWinner, APartner: TClip; + const AAt, ASegStart: Double; var ASeg: TFlatSegment); + var + Ti, C, N: Integer; + Track: TTrack; + Cl: TClip; + + procedure Push(const ALayer: TSegmentLayer); + begin + SetLength(ASeg.Layers, N + 1); + ASeg.Layers[N] := ALayer; + Inc(N); + end; + + begin + N := 0; + ASeg.Layers := nil; + + if APartner <> nil then + begin + { Переход на дорожке победителя: один уходит, другой приходит. } + if APartner.TlStart < AWinner.TlStart then + begin + Push(LayerOf(APartner, ASegStart, APartner.Volume, 0)); + Push(LayerOf(AWinner, ASegStart, 0, AWinner.Volume)); + end + else + begin + Push(LayerOf(AWinner, ASegStart, AWinner.Volume, 0)); + Push(LayerOf(APartner, ASegStart, 0, APartner.Volume)); + end; + end + else + Push(LayerOf(AWinner, ASegStart, AWinner.Volume, AWinner.Volume)); + + { Всё, что звучит одновременно на других звуковых дорожках. } + for Ti := 0 to AProject.TrackCount - 1 do + begin + Track := AProject.Tracks[Ti]; + if Track.Kind <> tkAudio then + Continue; + if not AProject.TrackAudible(Track) then + Continue; + for C := 0 to Track.Count - 1 do + begin + Cl := Track[C]; + if not Cl.Covers(AAt) then + Continue; + if (Cl = AWinner) or (Cl = APartner) then + Continue; + Push(LayerOf(Cl, ASegStart, Cl.Volume, Cl.Volume)); + end; + end; + + { Звучит ровно один клип и без перехода — смешивать нечего, кусок + описывается обычным способом и обходится без пересборки. } + if N < 2 then + ASeg.Layers := nil; + end; + { Клип, побеждающий в указанный момент: с самой верхней дорожки. } function WinnerAt(const ATime: Double): TClip; var @@ -531,7 +604,19 @@ begin end; end; - if (Partner <> nil) or (Under <> nil) then + { Звуковые дорожки смешиваются, а не побеждают друг друга. + + Правило «верхний побеждает» придумано для картинки: оно и делает + возможной сборку копированием. Для звука оно бессмысленно — без + смешивания нельзя положить фоновую музыку под голос. И стоит оно + ничего: звук у нас всё равно перекодируется. } + if AKind = tkAudio then + begin + CollectAudioLayers(Winner, Partner, Mid, SegStart, Seg); + if Length(Seg.Layers) > 0 then + Seg.Transcode := True; + end + else if (Partner <> nil) or (Under <> nil) then begin SetLength(Seg.Layers, 2); if Partner <> nil then @@ -626,6 +711,85 @@ begin Result := True; end; +function FlattenAudioTrack(AProject: TProject; ATrack: TTrack; + const ATotal: Double; + out ASegments: TFlatSegmentArray; out AMissing: TGenRequests): Boolean; +var + C, N: Integer; + At: Double; + Clip: TClip; + Item: TMediaItem; + Seg: TFlatSegment; + Req: TGenRequest; + Path: string; + + procedure PushSilence(const ALen: Double); + begin + if ALen <= 0.0005 then + Exit; + Seg := Default(TFlatSegment); + Seg.Volume := 1; + Seg.Kind := skBlack; + Seg.Duration := ALen; + Req.Kind := gkBlack; + Req.SourcePath := ''; + Req.Duration := BucketFor(ALen); + Req.TargetPath := GeneratedPath(AProject.CacheDir, Req); + Seg.FilePath := Req.TargetPath; + if not FileExists(Req.TargetPath) then + begin + SetLength(AMissing, Length(AMissing) + 1); + AMissing[High(AMissing)] := Req; + end; + SetLength(ASegments, N + 1); + ASegments[N] := Seg; + Inc(N); + end; + +begin + ASegments := nil; + AMissing := nil; + Result := False; + if (AProject = nil) or (ATrack = nil) then + Exit; + + N := 0; + At := 0; + ATrack.Sort; + for C := 0 to ATrack.Count - 1 do + begin + Clip := ATrack[C]; + { Клипы на дорожке могут наползать друг на друга. В предпросмотре это + разбирается проще некуда: наползший кусок начинается там, где + кончился предыдущий. Переход в предпросмотре не показывается — он + виден в готовом ролике. } + if Clip.TlEnd <= At + 0.0005 then + Continue; + if Clip.TlStart > At then + PushSilence(Clip.TlStart - At); + + Item := AProject.FindMedia(Clip.MediaId); + if Item = nil then + Continue; + + Seg := Default(TFlatSegment); + Seg.Kind := skVideo; + Seg.Volume := Clip.Volume; + Seg.FilePath := Item.EffectivePath; + Seg.SrcStart := Clip.SrcIn + Max(0, At - Clip.TlStart); + Seg.Duration := Clip.TlEnd - Max(At, Clip.TlStart); + SetLength(ASegments, N + 1); + ASegments[N] := Seg; + Inc(N); + At := Clip.TlEnd; + end; + + if ATotal > At then + PushSilence(ATotal - At); + + Result := N > 0; +end; + function FlattenProject(AProject: TProject; out AVideo, AAudio: TFlatSegmentArray; out AMissing: TGenRequests): Boolean; diff --git a/src/uMainForm.pas b/src/uMainForm.pas index 973c250..dce91ba 100644 --- a/src/uMainForm.pas +++ b/src/uMainForm.pas @@ -92,6 +92,7 @@ type находить звучащий кусок и брать у него громкость. } FPreviewSegments: TFlatSegmentArray; FLastVolumeSent: Double; + FLastVolumeKey: string; FGenerate: TGenerateThread; FGenerateBusy: Boolean; @@ -1337,8 +1338,12 @@ end; procedure TMainForm.LoadPreviewEdl(const ASegments, AAudio: TFlatSegmentArray); var Edl, AudioEdl: string; - Pos, Total: Double; + Pos, Total, TotalLen: Double; WasPaused: Boolean; + Urls: array of string; + TrackSegs: TFlatSegmentArray; + TrackMissing: TGenRequests; + I: Integer; begin { Кадр проекта задаётся до загрузки: mpv должен показывать то, что получится в готовом ролике, а не размер очередного исходника. Файл @@ -1347,13 +1352,32 @@ begin ApplyPreviewFrame; Edl := BuildEdlUri(ASegments); - { Звук идёт отдельным списком кусков и подаётся плееру как внешняя - звуковая дорожка. Иначе картинку и звук из разных мест монтажа - одновременно не показать: у одного файла звук всегда свой. + { Каждая звуковая дорожка идёт своим списком кусков. - Проверено замером: mpv принимает edl:// в качестве внешнего звука и - берёт его именно оттуда. } - AudioEdl := BuildEdlUri(AAudio); + Одним общим списком не обойтись: звуковые дорожки смешиваются, а не + побеждают друг друга, иначе под голос не положить музыку. Плеер играет + одну дорожку за раз, поэтому смесь он собирает своими средствами, а + мы даём ему каждую по отдельности. } + Urls := nil; + 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; + if not FlattenAudioTrack(FProject, FProject.Tracks[I], TotalLen, + TrackSegs, TrackMissing) then + Continue; + SetLength(Urls, Length(Urls) + 1); + Urls[High(Urls)] := BuildEdlUri(TrackSegs); + end; + + AudioEdl := ''; + for I := 0 to High(Urls) do + AudioEdl := AudioEdl + Urls[I] + '|'; { Список кусков запоминаем всегда, даже если строка не изменилась: громкость в неё не входит, а брать её потом неоткуда. } @@ -1374,7 +1398,7 @@ begin перемоткой следом. Иначе mpv успевает начать с нуля, и на каждой правке монтажа слышен и виден кусок не оттуда. } { Внешний звук назначается до загрузки: он часть того, что открывается. } - FPlayer.SetAudioSource(AudioEdl); + FPlayer.SetAudioSources(Urls); FPlayer.LoadUrl(Edl, Format('start=%s,pause=%s', [SecondsToStr(Pos), BoolToStr(WasPaused, 'yes', 'no')])); FLastLoadedEdl := Edl; @@ -1852,32 +1876,50 @@ begin AddClipFromPool(FProject.PoolCount - 1, TrackIdx, DropTime); end; +{ Громкость клипов в предпросмотре. + + Дорожки смешиваются, поэтому одной общей громкостью не обойтись: у + каждой дорожки под курсором свой клип со своей громкостью. Значения + собираются по дорожкам и отдаются плееру; он подставляет их в свою + цепочку смешивания. + + Пересобирать цепочку на каждый кадр нельзя — она создаётся заново и + звук щёлкает. Поэтому только при изменении. } procedure TMainForm.ApplyVolumeAt(const ATime: Double); var I: Integer; - Acc, Vol: Double; + Clip: TClip; + Vols: array of Double; + Key: string; begin if (FPlayer = nil) or (not FPlayer.Initialized) then Exit; - if Length(FPreviewSegments) = 0 then + if FProject = nil then Exit; - Vol := 1; - Acc := 0; - for I := 0 to High(FPreviewSegments) do + Vols := nil; + Key := ''; + for I := 0 to FProject.TrackCount - 1 do begin - if (ATime >= Acc) and (ATime < Acc + FPreviewSegments[I].Duration) then - begin - Vol := FPreviewSegments[I].Volume; - Break; - end; - Acc := Acc + FPreviewSegments[I].Duration; + 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; + Clip := FProject.Tracks[I].ClipAt(ATime); + SetLength(Vols, Length(Vols) + 1); + if Clip <> nil then + Vols[High(Vols)] := Clip.Volume + else + Vols[High(Vols)] := 1; + Key := Key + SecondsToStr(Vols[High(Vols)]) + ','; end; - if Abs(Vol - FLastVolumeSent) < 0.001 then + if Key = FLastVolumeKey then Exit; - FLastVolumeSent := Vol; - FPlayer.SetVolume(Vol * 100); + FLastVolumeKey := Key; + FPlayer.SetTrackVolumes(Vols); end; procedure TMainForm.GenerateProgress(Sender: TObject); diff --git a/src/uMpvPlayer.pas b/src/uMpvPlayer.pas index 73c67b2..65acf9e 100644 --- a/src/uMpvPlayer.pas +++ b/src/uMpvPlayer.pas @@ -58,6 +58,11 @@ type FVideoFilter: string; FHwdec: string; FAudioSource: string; + FAudioSources: array of string; + FTrackVolumes: array of Double; + { Номера подключённых звуковых дорожек, в порядке подключения. } + FAudioIds: array of string; + FLastGraph: string; { Что произошло с выбором звуковой дорожки. Читается окном для журнала: без этого понять, откуда взялся звук, нечем. } FAudioTrackNote: string; @@ -91,6 +96,8 @@ type усмотрению. Получалось, что звук слышен там, где на монтаже его нет вовсе — он приходил из видеофайла, а не с звуковой дорожки. } procedure SelectExternalAudio; + { Цепочка смешивания звуковых дорожек с их громкостями. } + procedure BuildAudioGraph; function SetOption(const AName, AValue: string): Integer; public constructor Create(AVideoParent: TWinControl); @@ -153,6 +160,14 @@ type Пустая строка снимает внешний звук. } procedure SetAudioSource(const AUrl: string); + { Несколько звуковых дорожек монтажа разом. Плеер играет только одну, + поэтому смесь собирается его же средствами — см. AttachAudio. } + procedure SetAudioSources(const AUrls: array of string); + + { Громкость каждой звуковой дорожки монтажа. Подставляется в цепочку + смешивания: одной общей громкостью тут не обойтись. } + procedure SetTrackVolumes(const AVolumes: array of Double); + function GetPropString(const AName: string): string; function GetPropDouble(const AName: string; out AValue: Double): Boolean; @@ -657,10 +672,39 @@ begin end; procedure TMpvPlayer.SetAudioSource(const AUrl: string); +begin + SetAudioSources([AUrl]); +end; + +procedure TMpvPlayer.SetAudioSources(const AUrls: array of string); +var + I, N: Integer; begin { Здесь только запоминаем. Подключается звук после загрузки картинки, - командой audio-add — см. AttachAudioSource. } - FAudioSource := AUrl; + командой audio-add — см. SelectExternalAudio. } + SetLength(FAudioSources, Length(AUrls)); + N := 0; + for I := 0 to High(AUrls) do + if AUrls[I] <> '' then + begin + FAudioSources[N] := AUrls[I]; + Inc(N); + end; + SetLength(FAudioSources, N); + if N > 0 then + FAudioSource := FAudioSources[0] + else + FAudioSource := ''; +end; + +procedure TMpvPlayer.SetTrackVolumes(const AVolumes: array of Double); +var + I: Integer; +begin + SetLength(FTrackVolumes, Length(AVolumes)); + for I := 0 to High(AVolumes) do + FTrackVolumes[I] := AVolumes[I]; + BuildAudioGraph; end; procedure TMpvPlayer.SetHwdec(const AMode: string); @@ -743,9 +787,52 @@ begin end; end; +procedure TMpvPlayer.BuildAudioGraph; +var + I: Integer; + Graph, Vol: string; +begin + if FHandle = nil then + Exit; + if Length(FAudioIds) = 0 then + Exit; + + { Одна дорожка играется как есть: цепочка фильтров тут только мешала бы, + да и громкость для неё задаётся обычным способом. } + if Length(FAudioIds) = 1 then + begin + Graph := ''; + if (Length(FTrackVolumes) > 0) and (Abs(FTrackVolumes[0] - 1) > 0.001) then + SetVolume(FTrackVolumes[0] * 100) + else + SetVolume(100); + end + else + begin + Graph := ''; + for I := 0 to High(FAudioIds) do + begin + if I <= High(FTrackVolumes) then + Vol := SecondsToStr(FTrackVolumes[I]) + else + Vol := '1'; + Graph := Graph + Format('[aid%s]volume=%s[t%d];', [FAudioIds[I], Vol, I]); + end; + for I := 0 to High(FAudioIds) do + Graph := Graph + Format('[t%d]', [I]); + Graph := Graph + Format('amix=inputs=%d:normalize=0[ao]', + [Length(FAudioIds)]); + end; + + if Graph = FLastGraph then + Exit; + FLastGraph := Graph; + mpv_set_property_string(FHandle, 'lavfi-complex', PAnsiChar(Graph)); +end; + procedure TMpvPlayer.SelectExternalAudio; var - Count, I: Integer; + Count, I, Found: Integer; Kind, Ext, Id: string; begin if FHandle = nil then @@ -755,8 +842,7 @@ begin if FAudioSource = '' then Exit; - { Подключаем свой список кусков как отдельную звуковую дорожку и сразу - выбираем её. + { Подключаем каждую звуковую дорожку монтажа отдельным списком кусков. Через свойство audio-files это не работает: проверено на живом mpv — список дорожек после загрузки остаётся прежним, внешний файл не @@ -766,8 +852,15 @@ begin Команда audio-add с признаком select делает ровно то, что нужно: добавляет дорожку, выбирает её и снимает выбор с внутренней. } - Command(['audio-add', FAudioSource, 'select']); + for I := 0 to High(FAudioSources) do + if I = 0 then + Command(['audio-add', FAudioSources[I], 'select']) + else + Command(['audio-add', FAudioSources[I], 'auto']); + { Собираем номера всех подключённых звуковых дорожек. } + FAudioIds := nil; + Found := 0; Count := StrToIntDef(GetPropString('track-list/count'), 0); for I := 0 to Count - 1 do begin @@ -778,11 +871,22 @@ begin if (Ext <> 'yes') and (Ext <> 'true') then Continue; Id := GetPropString(Format('track-list/%d/id', [I])); - if Id <> '' then - begin + if Id = '' then + Continue; + if Found = 0 then mpv_set_property_string(FHandle, 'aid', PAnsiChar(Id)); - FAudioTrackNote := 'Звук: отдельная дорожка монтажа (' + Id + ').'; - end; + SetLength(FAudioIds, Found + 1); + FAudioIds[Found] := Id; + Inc(Found); + end; + + if Found > 0 then + begin + FAudioTrackNote := 'Звук: дорожек монтажа ' + IntToStr(Found) + '.'; + { Цепочка строится отдельно: она зависит ещё и от громкостей, а те + меняются по ходу воспроизведения. } + FLastGraph := ''; + BuildAudioGraph; Exit; end;