diff --git a/README.md b/README.md index 66a472e..c5aeca2 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ Lazarus другой версии, и собранные им модули LCL | Выключить дорожку / оставить только её | кнопки М и С в заголовке | | Громкость клипа | тянуть ручку на клипе вверх-вниз | | Автосхлопывание вкл/выкл | Ctrl+K | +| Связать или развязать клипы | Ctrl+G | | Отменить прилипание на время | Shift при перетаскивании | | Вписать проект целиком | Ctrl+0 | | Новый, открыть, сохранить проект | Ctrl+N, Ctrl+O, Ctrl+S | @@ -285,6 +286,32 @@ Linux потом искала по нему `.dll`. случая, когда программа падает до появления окна: иначе всё, что она о себе рассказала, пропадает вместе с окном. +## Наложение, переходы и прозрачность + +Ползунок на клипе картинки задаёт **непрозрачность**, на звуковом — +громкость. Клип с непрозрачностью меньше единицы смешивается с тем, что +под ним. + +Два клипа **на одной дорожке**, наползшие друг на друга, дают переход: +один плавно перетекает в другой. На разных дорожках перекрытие означает +не переход, а наложение — там по-прежнему побеждает верхний, если он +непрозрачен. + +Различие не косметическое. Если бы кроссфейдом считалось любое +перекрытие, то в проекте, где клипы на разных дорожках перекрываются +постоянно, каждое такое место пришлось бы пересобирать. + +У клипа есть плавное появление и уход. Для картинки это затемнение, для +звука — нарастание громкости. + +**Всё это стоит перекодирования** — но только своего участка. Перед +сборкой программа сообщает, сколько секунд из скольких придётся +пересобрать, и даёт отказаться. + +Звук при наложении сводится прямо в отсчётах, без фильтров ffmpeg: доля +каждого слоя считается на каждый отсчёт, и длина результата получается +ровно такой, какая нужна. + ## Как собирается ролик 1. Монтаж уплощается в последовательность непересекающихся кусков. diff --git a/src/uAppTypes.pas b/src/uAppTypes.pas index 060570e..0f0d645 100644 --- a/src/uAppTypes.pas +++ b/src/uAppTypes.pas @@ -26,6 +26,28 @@ type показа. Позиция на таймлайне здесь не хранится намеренно — она равна сумме длительностей предыдущих сегментов, дублировать её значит завести источник рассогласования. } + { Один слой наложения. + + Появляется там, где кусок нельзя описать одним источником: клип + полупрозрачен и сквозь него видно нижний, либо два клипа на одной + дорожке наползли друг на друга и должны перетечь один в другой. + + Слои перечислены снизу вверх. } + TSegmentLayer = record + FilePath: string; + SrcStart: Double; + { Цепочка кадрирования этого слоя. } + Filter: string; + { Постоянная непрозрачность слоя. } + Opacity: Double; + { Переход: непрозрачность меняется от начала куска к концу. Если оба + значения равны Opacity, перехода нет. } + FadeFrom: Double; + FadeTo: Double; + end; + + TSegmentLayers = array of TSegmentLayer; + TFlatSegment = record FilePath: string; SrcStart: Double; @@ -53,6 +75,12 @@ type { Готовая цепочка фильтров ffmpeg: как кадр этого исходника ложится в кадр проекта. Считается один раз при уплощении. } Filter: string; + { Слои наложения. Пусто — обычный кусок с одним источником, который + можно скопировать. Непусто — участок придётся собрать заново. + + Ради этого и заведено: наложение и переходы стоят перекодирования, + но только своего участка, а не всего ролика. } + Layers: TSegmentLayers; end; TFlatSegmentArray = array of TFlatSegment; diff --git a/src/uExport.pas b/src/uExport.pas index 67632e1..1d0d23b 100644 --- a/src/uExport.pas +++ b/src/uExport.pas @@ -144,6 +144,8 @@ type проходом. Сырой поток нужен для точности — стык получается с точностью до отсчёта, а не до звукового кадра. } function BuildAudio: Boolean; + function MixLayers(const ASeg: TFlatSegment; ADest: TStream; + ANeed: Int64; ARate, AChannels: Integer): Boolean; function MuxFinal: Boolean; protected procedure Execute; override; @@ -493,6 +495,36 @@ begin Continue; end; + { Участок с наложением: полупрозрачный клип поверх нижнего либо + переход между двумя клипами одной дорожки. Собирается из двух + входов сразу, копированием такое не получить. } + if Length(FSegments[I].Layers) > 0 then + begin + SetStage(Format('Собираю наложение, кусок %d из %d', + [I + 1, Length(FSegments)])); + Dst := FCacheDir + Format('part_%.3d_x%s', [I, FIntermediateExt]); + Rep.Index := I; + Rep.SourcePath := FSegments[I].Layers[0].FilePath; + Rep.RequestedStart := FSegments[I].Layers[0].SrcStart; + Rep.ActualStart := Rep.RequestedStart; + Rep.Duration := FSegments[I].Duration; + Rep.Packets := -1; + Rep.Encoded := True; + + FStepWork := FSegments[I].Duration; + if not RunFfmpeg(ArgsRenderLayers(FSegments[I].Layers, Dst, + FSegments[I].Duration, FMaster.Width, FMaster.Height, + FMaster.FpsNum, FMaster.FpsDen, FMaster.PixFmt, + PreferredEncoder(FMaster.VideoCodec), + FIntermediateFormat = 'mpegts'), + 'Наложение в куске ' + IntToStr(I + 1)) then + Exit; + + Rep.TempPath := Dst; + AddPart(Rep); + Continue; + end; + { Кусок от файла, не совпадающего с параметрами проекта. Скопировать его нельзя — пересобираем. Но только его: соседние куски, снятые тем же оборудованием, что и весь проект, по-прежнему копируются. } @@ -722,7 +754,15 @@ begin монтажом до отсчёта, и звук не уезжает от картинки. } Need := Round(FAudio[I].Duration * Rate) * Channels * 2; - if FAudio[I].FilePath <> '' then + if Length(FAudio[I].Layers) > 0 then + begin + { Переход или наложение: слои сводятся прямо в отсчётах. Здесь + это проще и точнее, чем через фильтры ffmpeg — доля каждого + слоя считается на каждый отсчёт, без округлений по кадрам. } + if MixLayers(FAudio[I], All, Need, Rate, Channels) then + Need := 0; + end + else if FAudio[I].FilePath <> '' then begin Res := RunProcess(FfmpegExe, ArgsExtractAudioRaw(FAudio[I].FilePath, PartPath, @@ -788,6 +828,96 @@ begin end; end; +{ Сведение слоёв звука в один поток. + + Доля каждого слоя меняется от начала куска к концу: так получается + переход. Смешивание идёт по отсчётам, поэтому длина результата ровно + такая, какая нужна. } +function TExportThread.MixLayers(const ASeg: TFlatSegment; ADest: TStream; + ANeed: Int64; ARate, AChannels: Integer): Boolean; +var + L, I, N: Integer; + Parts: array of TMemoryStream; + PartPath: string; + Res: TProcResult; + Src: TFileStream; + Samples, S, Ch: Integer; + Acc: Double; + K: Double; + V: SmallInt; + Out16: SmallInt; + Buf: array of SmallInt; +begin + Result := False; + SetLength(Parts, Length(ASeg.Layers)); + for I := 0 to High(Parts) do + Parts[I] := nil; + + try + for L := 0 to High(ASeg.Layers) do + begin + PartPath := FCacheDir + Format('mix_%d.raw', [L]); + Res := RunProcess(FfmpegExe, + ArgsExtractAudioRaw(ASeg.Layers[L].FilePath, PartPath, + ASeg.Layers[L].SrcStart, ASeg.Duration, ARate, AChannels, 1), + nil, @ErrorLine, @CancelQuery); + Parts[L] := TMemoryStream.Create; + if Res.Started and (Res.ExitCode = 0) and FileExists(PartPath) then + begin + Src := TFileStream.Create(PartPath, fmOpenRead or fmShareDenyNone); + try + Parts[L].CopyFrom(Src, Src.Size); + finally + Src.Free; + end; + SysUtils.DeleteFile(PartPath); + end; + Parts[L].Position := 0; + end; + + Samples := ANeed div (AChannels * 2); + if Samples <= 0 then + Exit; + SetLength(Buf, AChannels); + + for S := 0 to Samples - 1 do + begin + for Ch := 0 to AChannels - 1 do + begin + Acc := 0; + for L := 0 to High(ASeg.Layers) do + begin + { Доля слоя меняется линейно по ходу куска. } + if Samples > 1 then + K := ASeg.Layers[L].FadeFrom + + (ASeg.Layers[L].FadeTo - ASeg.Layers[L].FadeFrom) * S / (Samples - 1) + else + K := ASeg.Layers[L].FadeFrom; + + N := (S * AChannels + Ch) * 2; + if N + 1 < Parts[L].Size then + begin + Move(PByte(Parts[L].Memory)[N], V, 2); + Acc := Acc + V * K; + end; + end; + if Acc > 32767 then + Acc := 32767; + if Acc < -32768 then + Acc := -32768; + Out16 := Round(Acc); + Buf[Ch] := Out16; + end; + ADest.WriteBuffer(Buf[0], AChannels * 2); + end; + + Result := True; + finally + for I := 0 to High(Parts) do + Parts[I].Free; + end; +end; + function TExportThread.MuxFinal: Boolean; var Res: TProcResult; diff --git a/src/uFfmpegCmd.pas b/src/uFfmpegCmd.pas index fc5151c..ec88632 100644 --- a/src/uFfmpegCmd.pas +++ b/src/uFfmpegCmd.pas @@ -122,6 +122,16 @@ function ArgsExtractAudioRaw(const ASource, ATarget: string; function ArgsEncodeAudio(const ASource, ATarget: string; ARate, AChannels: Integer; const ACodec: string): TArgs; +{ Пересборка участка с наложением: два слоя сводятся в один кадр. + + Постоянная непрозрачность делается наложением с приглушённым альфа- + каналом, переход — фильтром xfade. Их нельзя перепутать: xfade умеет + плавно менять долю во времени, а простое наложение — нет. } +function ArgsRenderLayers(const ALayers: array of TSegmentLayer; + const ATarget: string; const ADuration: Double; + AWidth, AHeight, AFpsNum, AFpsDen: Integer; + const APixFmt, ACodec: string; AIsTs: Boolean): TArgs; + { Сшивание готовой картинки с готовым звуком. Оба потока копируются. } function ArgsMux(const AVideo, AAudio, ATarget: string): TArgs; @@ -235,6 +245,71 @@ begin '-progress', 'pipe:1', '-nostats', ATarget]; end; +function ArgsRenderLayers(const ALayers: array of TSegmentLayer; + const ATarget: string; const ADuration: Double; + AWidth, AHeight, AFpsNum, AFpsDen: Integer; + const APixFmt, ACodec: string; AIsTs: Boolean): TArgs; +var + I: Integer; + Graph, Fit: string; + Crossfade: Boolean; +begin + Result := ['-hide_banner', '-nostdin', '-loglevel', 'warning', '-y']; + + { Каждый слой — отдельный вход, вырезанный ровно по длине участка. } + for I := 0 to High(ALayers) do + Result := Concat(Result, + ['-ss', SecondsToStr(ALayers[I].SrcStart), + '-t', SecondsToStr(ADuration), + '-i', ALayers[I].FilePath]); + + { Переход отличается от наложения тем, что доля меняется во времени. } + Crossfade := (Length(ALayers) = 2) and + ((Abs(ALayers[1].FadeFrom - ALayers[1].FadeTo) > 0.001) or + (Abs(ALayers[0].FadeFrom - ALayers[0].FadeTo) > 0.001)); + + Graph := ''; + for I := 0 to High(ALayers) do + begin + Fit := ALayers[I].Filter; + if Fit = '' then + Fit := Format('scale=%d:%d,setsar=1', [AWidth, AHeight]); + Graph := Graph + Format('[%d:v]%s,format=yuva420p[v%d];', [I, Fit, I]); + end; + + if Length(ALayers) = 1 then + begin + { Один слой с меняющейся долей — это затухание в чёрное или из него. } + if ALayers[0].FadeTo >= ALayers[0].FadeFrom then + Graph := Graph + Format('[v0]fade=t=in:st=0:d=%s:alpha=0[out]', + [SecondsToStr(ADuration)]) + else + Graph := Graph + Format('[v0]fade=t=out:st=0:d=%s:alpha=0[out]', + [SecondsToStr(ADuration)]); + end + else if Crossfade then + Graph := Graph + Format('[v0][v1]xfade=transition=fade:duration=%s:offset=0[out]', + [SecondsToStr(ADuration)]) + else + begin + { Верхний слой приглушается по альфа-каналу и кладётся на нижний. } + Graph := Graph + Format('[v1]colorchannelmixer=aa=%s[t];[v0][t]overlay=0:0[out]', + [SecondsToStr(ALayers[High(ALayers)].Opacity)]); + end; + + Result := Concat(Result, + ['-filter_complex', Graph, '-map', '[out]', + '-t', SecondsToStr(ADuration), + '-c:v', ACodec, '-r', Format('%d/%d', [AFpsNum, AFpsDen]), + '-pix_fmt', APixFmt]); + Result := Concat(Result, + ['-bf', '0', '-an', '-avoid_negative_ts', 'make_zero', + '-progress', 'pipe:1', '-nostats']); + if AIsTs then + Result := Concat(Result, ['-f', 'mpegts']); + Result := Concat(Result, [ATarget]); +end; + function ArgsMux(const AVideo, AAudio, ATarget: string): TArgs; begin Result := ['-hide_banner', '-nostdin', '-loglevel', 'warning', '-y', diff --git a/src/uFlatten.pas b/src/uFlatten.pas index a017360..4eea397 100644 --- a/src/uFlatten.pas +++ b/src/uFlatten.pas @@ -219,6 +219,31 @@ var Req: TGenRequest; Path: string; Seen: TStringList; + Partner, Under: TClip; + + { Слой из клипа: где в исходнике брать картинку и с какой + непрозрачностью её накладывать. } + function LayerOf(AClip: TClip; const AAt: Double; + const AFrom, ATo: Double): TSegmentLayer; + var + It: TMediaItem; + begin + Result := Default(TSegmentLayer); + Result.FadeFrom := AFrom; + Result.FadeTo := ATo; + Result.Opacity := AFrom; + It := AProject.FindMedia(AClip.MediaId); + if It = nil then + Exit; + Result.FilePath := It.EffectivePath; + Result.SrcStart := AClip.SrcIn + (AAt - AClip.TlStart); + Result.Filter := TransformFilter(AClip.Transform, + It.Info.Width, It.Info.Height, + AProject.Master.Width, AProject.Master.Height); + if Result.Filter = '' then + Result.Filter := PreviewFitFilter(AProject.Master.Width, + AProject.Master.Height); + end; { Клип, побеждающий в указанный момент: с самой верхней дорожки. } function WinnerAt(const ATime: Double): TClip; @@ -241,6 +266,63 @@ var end; end; + { Номер дорожки, на которой стоит клип. } + function TrackIndexOfClip(AClip: TClip): Integer; + var + Ti: Integer; + begin + Result := -1; + for Ti := 0 to AProject.TrackCount - 1 do + if AProject.Tracks[Ti].IndexOf(AClip) >= 0 then + Exit(Ti); + end; + + { Второй клип, покрывающий тот же момент на той же дорожке. + + Два клипа на одной дорожке, наползшие друг на друга, — это переход: + один перетекает в другой. На разных дорожках такое означает не + переход, а наложение, и разбирается отдельно. } + function OverlapPartner(AClip: TClip; const ATime: Double): TClip; + var + Ti, C: Integer; + Track: TTrack; + Cl: TClip; + begin + Result := nil; + Ti := TrackIndexOfClip(AClip); + if Ti < 0 then + Exit; + Track := AProject.Tracks[Ti]; + for C := 0 to Track.Count - 1 do + begin + Cl := Track[C]; + if (Cl <> AClip) and Cl.Covers(ATime) then + Exit(Cl); + end; + end; + + { Клип под победителем: нужен, когда победитель полупрозрачен. } + function BelowAt(AClip: TClip; const ATime: Double): TClip; + var + Ti, Start: Integer; + Cl: TClip; + begin + Result := nil; + Start := TrackIndexOfClip(AClip); + if Start < 0 then + Exit; + for Ti := Start + 1 to AProject.TrackCount - 1 do + begin + if AProject.Tracks[Ti].Kind <> AKind then + Continue; + if not AProject.TrackAudible(AProject.Tracks[Ti]) then + Continue; + Cl := AProject.Tracks[Ti].ClipAt(ATime); + if Cl <> nil then + Exit(Cl); + end; + end; + begin ASegments := nil; AMissing := nil; @@ -267,6 +349,14 @@ begin begin AddBoundary(Bounds, BoundCount, Track[C].TlStart); AddBoundary(Bounds, BoundCount, Track[C].TlEnd); + { Границы затуханий: внутри них доля меняется, снаружи нет, значит + это разные куски. } + if Track[C].FadeIn > 0 then + AddBoundary(Bounds, BoundCount, + Track[C].TlStart + Min(Track[C].FadeIn, Track[C].Duration)); + if Track[C].FadeOut > 0 then + AddBoundary(Bounds, BoundCount, + Track[C].TlEnd - Min(Track[C].FadeOut, Track[C].Duration)); end; end; @@ -377,6 +467,66 @@ begin if (MaxLen > 0) and (Seg.Duration > MaxLen - 0.001) then Seg.Duration := Max(0.001, MaxLen - 0.001); end; + + { --- Наложение --- } + + Partner := OverlapPartner(Winner, Mid); + Under := nil; + if (Partner = nil) and (Winner.Opacity < 0.999) then + Under := BelowAt(Winner, Mid); + + { Ручное затухание: кусок попал в зону появления или ухода. Слой + один, но с меняющейся долей — дальше это обрабатывается тем же + путём, что и переход между двумя клипами. } + if (Partner = nil) and (Under = nil) then + begin + if (Winner.FadeIn > 0) and (SegEnd <= Winner.TlStart + Winner.FadeIn + 0.0005) then + begin + SetLength(Seg.Layers, 1); + Seg.Layers[0] := LayerOf(Winner, SegStart, + (SegStart - Winner.TlStart) / Winner.FadeIn, + (SegEnd - Winner.TlStart) / Winner.FadeIn); + Seg.Transcode := True; + end + else if (Winner.FadeOut > 0) and + (SegStart >= Winner.TlEnd - Winner.FadeOut - 0.0005) then + begin + SetLength(Seg.Layers, 1); + Seg.Layers[0] := LayerOf(Winner, SegStart, + (Winner.TlEnd - SegStart) / Winner.FadeOut, + (Winner.TlEnd - SegEnd) / Winner.FadeOut); + Seg.Transcode := True; + end; + end; + + if (Partner <> nil) or (Under <> nil) then + begin + SetLength(Seg.Layers, 2); + if Partner <> nil then + begin + { Переход на одной дорожке: кто начался раньше, тот уходит. + Нижним слоем идёт уходящий, верхним приходящий. } + if Partner.TlStart < Winner.TlStart then + begin + Seg.Layers[0] := LayerOf(Partner, SegStart, 1, 0); + Seg.Layers[1] := LayerOf(Winner, SegStart, 0, 1); + end + else + begin + Seg.Layers[0] := LayerOf(Winner, SegStart, 1, 0); + Seg.Layers[1] := LayerOf(Partner, SegStart, 0, 1); + end; + end + else + begin + { Полупрозрачный клип поверх нижнего. } + Seg.Layers[0] := LayerOf(Under, SegStart, 1, 1); + Seg.Layers[1] := LayerOf(Winner, SegStart, + Winner.Opacity, Winner.Opacity); + end; + { Собрать такой участок копированием нельзя. } + Seg.Transcode := True; + end; end; Raw[RawCount] := Seg; @@ -395,6 +545,9 @@ begin if (Raw[I].Kind = ASegments[N].Kind) and (Raw[I].FilePath = ASegments[N].FilePath) and (Raw[I].Kind = skVideo) and + { Участок с наложением склеивать с соседним нельзя: у него свой + набор слоёв, и в объединённом куске он потерялся бы. } + (Length(Raw[I].Layers) = 0) and (Length(ASegments[N].Layers) = 0) and (Abs(Raw[I].Volume - ASegments[N].Volume) < 0.001) and (Abs((ASegments[N].SrcStart + ASegments[N].Duration) - Raw[I].SrcStart) < 0.0005) then { Идут подряд в одном файле — это один кусок, а не два. } diff --git a/src/uMainForm.pas b/src/uMainForm.pas index fc84d7b..bbaeb20 100644 --- a/src/uMainForm.pas +++ b/src/uMainForm.pas @@ -2340,6 +2340,7 @@ var Missing: TGenRequests; HasPrecise: Boolean; T, C: Integer; + Rebuilt: Double; begin if not FlattenProject(FProject, Segments, AudioSegs, Missing) then begin @@ -2357,6 +2358,27 @@ begin Exit; end; + { Сколько материала придётся пересобрать. Наложение, переход, + полупрозрачность и чужие параметры файла — всё это перекодирование, и + человек должен знать цену заранее, а не обнаружить её по времени + сборки. } + Rebuilt := 0; + for T := 0 to High(Segments) do + if Segments[T].Transcode or (Length(Segments[T].Layers) > 0) then + Rebuilt := Rebuilt + Segments[T].Duration; + + if Rebuilt > 0.5 then + if ThemedMessageDlg('nlve', + Format('Часть ролика придётся пересобрать: %s с из %s.' + LineEnding + + LineEnding + + 'Пересобираются участки с наложением, переходами, прозрачностью и ' + + 'куски файлов, не совпадающих с параметрами проекта. Остальное ' + + 'копируется без потерь и почти мгновенно.' + LineEnding + LineEnding + + 'Продолжить?', + [SecondsToStr(Rebuilt), SecondsToStr(TotalDuration(Segments))]), + mtConfirmation, [mbYes, mbNo], 0) <> mrYes then + Exit; + { Если есть хоть один точный рез, предупреждаем про контейнер: в MP4 перекодированный кусок и скопированный уживаются не всегда, а MKV к смене параметров потока терпимее. } diff --git a/src/uProject.pas b/src/uProject.pas index bbde52d..f9316d9 100644 --- a/src/uProject.pas +++ b/src/uProject.pas @@ -86,6 +86,9 @@ type FVolume: Double; FTransform: TMediaTransform; FGroupId: string; + FOpacity: Double; + FFadeIn: Double; + FFadeOut: Double; public constructor Create; function TlEnd: Double; @@ -112,6 +115,18 @@ type настройка, но она лишь заготовка — её берёт новый клип при добавлении. } property Transform: TMediaTransform read FTransform write FTransform; + { Непрозрачность клипа картинки: 1 — обычный, 0.5 — сквозь него видно + то, что ниже. + + Стоит понимать цену: как только клип перестаёт быть непрозрачным, + его нельзя скопировать — участок приходится пересобирать вместе со + всем, что под ним. Поэтому по умолчанию единица, и программа + предупреждает перед сборкой, сколько секунд это займёт. } + property Opacity: Double read FOpacity write FOpacity; + { Плавное появление и уход, в секундах от краёв клипа. Для картинки + это затемнение, для звука — нарастание громкости. } + property FadeIn: Double read FFadeIn write FFadeIn; + property FadeOut: Double read FFadeOut write FFadeOut; { Связка. Клипы с одинаковым непустым значением двигаются, режутся и удаляются вместе. По умолчанию так связаны картинка и её звук; Ctrl+G связывает и развязывает что угодно. } @@ -310,6 +325,7 @@ begin inherited Create; { Без явного значения множитель был бы нулём, то есть тишиной. } FVolume := 1; + FOpacity := 1; FTransform := DefaultTransform; end; @@ -1184,6 +1200,9 @@ begin 'kind', KindToStr(Clip.Kind), 'group', Clip.GroupId, 'volume', Clip.Volume, + 'opacity', Clip.Opacity, + 'fade_in', Clip.FadeIn, + 'fade_out', Clip.FadeOut, 'transform', TJSONObject.Create([ 'fit', FitModeToStr(Clip.Transform.Fit), 'rotation', Clip.Transform.Rotation, @@ -1293,6 +1312,9 @@ begin Clip.Kind := StrToKind(ClipObj.Get('kind', 'video')); Clip.Volume := ClipObj.Get('volume', Double(1)); Clip.GroupId := ClipObj.Get('group', ''); + Clip.Opacity := ClipObj.Get('opacity', Double(1)); + Clip.FadeIn := ClipObj.Get('fade_in', Double(0)); + Clip.FadeOut := ClipObj.Get('fade_out', Double(0)); { Проекты, записанные до появления кадрирования у клипа, берут его у файла — там оно и хранилось раньше. } if ClipObj.Find('transform') <> nil then diff --git a/src/uTimelineCtrl.pas b/src/uTimelineCtrl.pas index d866105..01262e1 100644 --- a/src/uTimelineCtrl.pas +++ b/src/uTimelineCtrl.pas @@ -223,6 +223,8 @@ type связаны картинка и её звук. } procedure ToggleGroupSelected; procedure SelectWholeGroups; + function ClipLevel(AClip: TClip): Double; + procedure SetClipLevel(AClip: TClip; const AValue: Double); { Клипы одной связки с перетаскиваемым и их исходные позиции. } procedure CollectGroupClips(AClip: TClip); procedure ApplyGroupMove(const ADelta: Double); @@ -519,6 +521,32 @@ begin Result := MaxClipVolume; end; +{ Что задаёт ползунок на клипе. + + У звука это громкость, у картинки — непрозрачность. Величина одна и та + же по смыслу (доля от единицы), поэтому и ползунок один, но означает он + разное, и подписывается по-разному. } +function TTimelineControl.ClipLevel(AClip: TClip): Double; +begin + if AClip = nil then + Exit(1); + if AClip.Kind = ckAudio then + Result := AClip.Volume + else + Result := AClip.Opacity; +end; + +procedure TTimelineControl.SetClipLevel(AClip: TClip; const AValue: Double); +begin + if AClip = nil then + Exit; + if AClip.Kind = ckAudio then + AClip.Volume := AValue + else + { Прозрачнее полного клип быть может, а ярче исходника — нет. } + AClip.Opacity := Min(1, AValue); +end; + function TTimelineControl.VolumeHandleRect(AClip: TClip; ATop: Integer): TRect; var L, R, CenterX, Y: Integer; @@ -542,7 +570,7 @@ begin Exit; CenterX := (L + R) div 2; - Y := VolumeToY(AClip.Volume, ATop); + Y := VolumeToY(ClipLevel(AClip), ATop); Result := Rect(CenterX - VolumeHandleWidth div 2, Y - VolumeHandleHeight div 2, CenterX + VolumeHandleWidth div 2, Y + VolumeHandleHeight div 2); end; @@ -880,7 +908,7 @@ begin if ABody.Right - ABody.Left < 12 then Exit; - Y := VolumeToY(AClip.Volume, ATop); + Y := VolumeToY(ClipLevel(AClip), ATop); if (Y <= ABody.Top) then Y := ABody.Top + 1; if (Y >= ABody.Bottom) then @@ -903,7 +931,7 @@ begin надпись налезет на соседние. } if ABody.Right - ABody.Left > 130 then begin - VolText := IntToStr(Round(AClip.Volume * 100)) + '%'; + VolText := IntToStr(Round(ClipLevel(AClip) * 100)) + '%'; ACanvas.Brush.Style := bsClear; ACanvas.Font.Color := ColorVolumeLine; ACanvas.Font.Height := -10; @@ -1372,7 +1400,7 @@ begin (Y >= VolHandle.Top - 3) and (Y <= VolHandle.Bottom + 3) then begin FAction := taVolume; - FDragOrigVolume := Clip.Volume; + FDragOrigVolume := ClipLevel(Clip); end else case Edge of @@ -1471,7 +1499,7 @@ begin Idx := TrackIndexAt(FPressY); if Idx >= 0 then begin - FDragClip.Volume := YToVolume(Y, TrackTop(Idx)); + SetClipLevel(FDragClip, YToVolume(Y, TrackTop(Idx))); Invalidate; end; end; @@ -1842,6 +1870,9 @@ begin настроено, должно перейти к нему: иначе после реза половина клипа теряет громкость и кадрирование. } Tail.Volume := Clip.Volume; + Tail.Opacity := Clip.Opacity; + Tail.FadeIn := 0; + Tail.FadeOut := Clip.FadeOut; Tail.Transform := Clip.Transform; Tail.GroupId := Clip.GroupId;