Этап 5: прозрачность, переходы и затухания

Ползунок на клипе картинки теперь задаёт непрозрачность, на звуковом —
громкость. Уплощение выдаёт для таких мест не одного победителя, а слои.

Слои появляются в трёх случаях: клип полупрозрачен и сквозь него виден
нижний; два клипа на одной дорожке наползли друг на друга и должны
перетечь один в другой; у клипа задано плавное появление или уход.

Перекрытие на разных дорожках переходом не считается — там по-прежнему
побеждает верхний. Иначе в проекте, где дорожки перекрываются постоянно,
каждое такое место пришлось бы пересобирать.

Картинка собирается фильтрами ffmpeg: постоянная доля через overlay,
переход через xfade, затухание через fade. Звук сводится прямо в
отсчётах — доля каждого слоя считается на каждый отсчёт, и длина
результата получается ровно такой, какая нужна.

Перед сборкой программа сообщает, сколько секунд из скольких придётся
пересобрать, и даёт отказаться.

Проверено замерами:
- красный с непрозрачностью 50% поверх синего дал (127, 0, 126);
- переход на одной дорожке: синий плавно перешёл в красный за секунду;
- звуковой переход: -24 dB, -27.5, -35, -51, тишина;
- появление за 2 секунды: пересобрано 2 секунды из 6, остальное
  скопировано.

Найдено по дороге: участок с наложением склеивался с соседним куском и
терял слои — в условии склейки они не учитывались.
This commit is contained in:
chaiman 2026-08-20 10:11:20 +03:00
commit 3acf79d174
8 changed files with 494 additions and 6 deletions

View file

@ -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. Монтаж уплощается в последовательность непересекающихся кусков.

View file

@ -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;

View file

@ -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;

View file

@ -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',

View file

@ -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
{ Идут подряд в одном файле — это один кусок, а не два. }

View file

@ -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
к смене параметров потока терпимее. }

View file

@ -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

View file

@ -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;