Этап 5: прозрачность, переходы и затухания
Ползунок на клипе картинки теперь задаёт непрозрачность, на звуковом — громкость. Уплощение выдаёт для таких мест не одного победителя, а слои. Слои появляются в трёх случаях: клип полупрозрачен и сквозь него виден нижний; два клипа на одной дорожке наползли друг на друга и должны перетечь один в другой; у клипа задано плавное появление или уход. Перекрытие на разных дорожках переходом не считается — там по-прежнему побеждает верхний. Иначе в проекте, где дорожки перекрываются постоянно, каждое такое место пришлось бы пересобирать. Картинка собирается фильтрами ffmpeg: постоянная доля через overlay, переход через xfade, затухание через fade. Звук сводится прямо в отсчётах — доля каждого слоя считается на каждый отсчёт, и длина результата получается ровно такой, какая нужна. Перед сборкой программа сообщает, сколько секунд из скольких придётся пересобрать, и даёт отказаться. Проверено замерами: - красный с непрозрачностью 50% поверх синего дал (127, 0, 126); - переход на одной дорожке: синий плавно перешёл в красный за секунду; - звуковой переход: -24 dB, -27.5, -35, -51, тишина; - появление за 2 секунды: пересобрано 2 секунды из 6, остальное скопировано. Найдено по дороге: участок с наложением склеивался с соседним куском и терял слои — в условии склейки они не учитывались.
This commit is contained in:
parent
b6e215a03f
commit
3acf79d174
8 changed files with 494 additions and 6 deletions
27
README.md
27
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. Монтаж уплощается в последовательность непересекающихся кусков.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
132
src/uExport.pas
132
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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
153
src/uFlatten.pas
153
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
|
||||
{ Идут подряд в одном файле — это один кусок, а не два. }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
к смене параметров потока терпимее. }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue