Связки клипов и порядок дорожек

Ctrl+G связывает выделенные клипы и развязывает их, если они уже в одной
связке. Связка двигается, режется и удаляется целиком: половина связки —
это разъехавшийся звук, который потом не найти.

Форма звука переехала на звуковые клипы: на клипе картинки ей не место,
звук у него теперь свой и живёт на своей дорожке. Кнопка кадрирования, наоборот,
осталась только у картинки.

Новая дорожка картинки встаёт перед первой звуковой, а не в конец списка:
порядок дорожек задаёт, кто побеждает при перекрытии.

Найдена и исправлена настоящая ошибка: при резе новый кусок терял
громкость и кадрирование — они просто не переносились на хвост.
This commit is contained in:
chaiman 2026-08-20 09:58:16 +03:00
commit b6e215a03f
3 changed files with 221 additions and 17 deletions

View file

@ -1081,7 +1081,8 @@ begin
if ATrackIndex < 0 then
begin
FProject.AddTrack('V' + IntToStr(FProject.TrackCount + 1));
FProject.AddTrack('V' +
IntToStr(FProject.TrackCountOf(tkVideo) + 1));
ATrackIndex := FProject.TrackCount - 1;
Log('Заведена дорожка ' + FProject.Tracks[ATrackIndex].Name);
end;
@ -1167,7 +1168,9 @@ begin
end;
FProject.BeginChange;
FProject.AddClip(Track, Item, 0, Duration, PlaceAt);
{ Парой: клип картинки и его звук на соответствующей звуковой дорожке.
Связка держит их вместе, пока её не развяжут по Ctrl+G. }
FProject.AddClipPair(Track, Item, 0, Duration, PlaceAt);
FTimeline.Invalidate;
SchedulePreview;
UpdateCaption;
@ -2278,7 +2281,8 @@ end;
procedure TMainForm.MenuAddTrack(Sender: TObject);
begin
FProject.BeginChange;
FProject.AddTrack('V' + IntToStr(FProject.TrackCount + 1));
FProject.AddTrack('V' +
IntToStr(FProject.TrackCountOf(tkVideo) + 1));
FTimeline.Invalidate;
UpdateCaption;
end;

View file

@ -188,7 +188,11 @@ type
function FindMediaByPath(const APath: string): TMediaItem;
procedure RemoveMedia(AItem: TMediaItem);
{ Номер новой связки. Отдельным именем, чтобы не пришлось помнить
префикс в каждом месте, где связка заводится. }
function NewGroupId: string;
function AddTrack(const AName: string): TTrack;
function AddTrackOfKind(const AName: string; AKind: TTrackKind): TTrack;
function FindTrack(const AId: string): TTrack;
procedure RemoveTrack(AIndex: Integer);
procedure MoveTrack(AFrom, ATo: Integer);
@ -458,8 +462,8 @@ begin
так он не мешает смотреть на картинку. }
AddTrack('V1');
AddTrack('V2');
AddTrack('A1').Kind := tkAudio;
AddTrack('A2').Kind := tkAudio;
AddTrackOfKind('A1', tkAudio);
AddTrackOfKind('A2', tkAudio);
FModified := False;
end;
@ -483,6 +487,11 @@ begin
Result := FTracks.Count;
end;
function TProject.NewGroupId: string;
begin
Result := NewId('g');
end;
function TProject.NewId(const APrefix: string): string;
begin
Result := APrefix + IntToStr(FNextId);
@ -544,11 +553,37 @@ begin
end;
function TProject.AddTrack(const AName: string): TTrack;
begin
Result := AddTrackOfKind(AName, tkVideo);
end;
function TProject.AddTrackOfKind(const AName: string;
AKind: TTrackKind): TTrack;
var
I, Pos: Integer;
begin
Result := TTrack.Create;
Result.Id := NewId('t');
Result.Name := AName;
FTracks.Add(Result);
Result.Kind := AKind;
{ Порядок дорожек не только украшение: он задаёт, кто побеждает при
перекрытии. Картинка всегда выше звука, поэтому новая дорожка
картинки встаёт перед первой звуковой, а не в самый конец. }
if AKind = tkVideo then
begin
Pos := FTracks.Count;
for I := 0 to FTracks.Count - 1 do
if TTrack(FTracks[I]).Kind = tkAudio then
begin
Pos := I;
Break;
end;
FTracks.Insert(Pos, Result);
end
else
FTracks.Add(Result);
FModified := True;
end;
@ -693,8 +728,7 @@ begin
Have := TrackCountOf(tkAudio);
for I := Have to Need - 1 do
begin
Track := AddTrack('A' + IntToStr(I + 1));
Track.Kind := tkAudio;
Track := AddTrackOfKind('A' + IntToStr(I + 1), tkAudio);
end;
end;

View file

@ -107,6 +107,8 @@ type
клипы, которые уже сдвинулись. }
FRippleClips: array of TClip;
FRippleOrigStarts: array of Double;
FGroupClips: array of TClip;
FGroupOrigStarts: array of Double;
FOnPlayheadChange: TNotifyEvent;
FOnStructureChange: TNotifyEvent;
@ -215,6 +217,15 @@ type
расстояния между опорными кадрами. }
procedure SplitAtPlayhead(APrecise: Boolean; AOnlyCurrentTrack: Boolean);
procedure DeleteSelected;
{ Связать выделенные клипы или развязать, если они уже связаны.
Связанные двигаются, режутся и удаляются вместе. По умолчанию так
связаны картинка и её звук. }
procedure ToggleGroupSelected;
procedure SelectWholeGroups;
{ Клипы одной связки с перетаскиваемым и их исходные позиции. }
procedure CollectGroupClips(AClip: TClip);
procedure ApplyGroupMove(const ADelta: Double);
{ Сдвинуть клипы влево, закрыв дыру под курсором. }
procedure RippleCloseGap;
procedure SelectAll;
@ -311,6 +322,7 @@ const
ColorEdgePrecise = TColor($FFC030); // голубой: рез потребует перекодирования
ColorWave = TColor($96C8D2); // пастельно-жёлтый: форма звука
ColorCropButton = TColor($4A6A8A); // кнопка кадрирования на клипе
ColorClipAudio = TColor($5A4A2A); // приглушённый синий: звуковой клип
ColorVolumeLine = TColor($60E0E0); // жёлтый: линия громкости
ColorVolumeHandle = TColor($30C0FF); // оранжевый: ручка громкости
ColorHeaderActive = TColor($3A3A3A); // подсветка выбранной дорожки
@ -659,6 +671,8 @@ begin
ACanvas.Brush.Color := ColorClipSelected
else if AClip.Kind = ckImage then
ACanvas.Brush.Color := ColorClipImage
else if AClip.Kind = ckAudio then
ACanvas.Brush.Color := ColorClipAudio
else
ACanvas.Brush.Color := ColorClip;
ACanvas.FillRect(Body);
@ -691,9 +705,8 @@ begin
нём рисуется. }
DrawWaveform(ACanvas, AClip, Body);
{ Кнопка кадрирования — поверх всего, чтобы её не перекрывала ни форма
звука, ни подписи. }
if AClip.Kind <> ckBlack then
{ Кнопка кадрирования — только у картинки: звук кадрировать нечем. }
if AClip.Kind in [ckVideo, ckImage] then
DrawCropButton(ACanvas, Body);
{ Громкость рисуется до подписей: иначе её линия перечёркивает название
@ -738,7 +751,9 @@ var
SrcTime: Double;
Idx, Value: Integer;
begin
if (FProject = nil) or (AClip.Kind <> ckVideo) then
{ Форма звука рисуется на звуковом клипе. На клипе картинки ей не место:
звук у него отдельный и живёт на своей дорожке. }
if (FProject = nil) or (AClip.Kind <> ckAudio) then
Exit;
Item := FProject.FindMedia(AClip.MediaId);
if (Item = nil) or (not Item.Info.HasAudio) then
@ -794,7 +809,7 @@ var
L, R: Integer;
begin
Result := Rect(0, 0, 0, 0);
if (AClip = nil) or (AClip.Kind = ckBlack) then
if (AClip = nil) or (not (AClip.Kind in [ckVideo, ckImage])) then
Exit;
L := TimeToX(AClip.TlStart);
@ -1329,6 +1344,9 @@ begin
FDragClip := Clip;
FDragTrack := Track;
{ Клипы связки поедут следом. Список составляется один раз при нажатии:
пересчитывать его на каждое движение нельзя — они уже сдвинулись. }
CollectGroupClips(Clip);
FDragOrigStart := Clip.TlStart;
FDragOrigSrcIn := Clip.SrcIn;
FDragOrigDuration := Clip.Duration;
@ -1463,6 +1481,8 @@ begin
begin
NewStart := SnapTime(XToTime(X) - FDragGrabOffset, FDragClip);
FDragClip.TlStart := Max(0, NewStart);
{ Связка едет целиком: звук должен остаться под своей картинкой. }
ApplyGroupMove(FDragClip.TlStart - FDragOrigStart);
{ Всё, что правее, едет на ту же величину. }
if Length(FRippleClips) > 0 then
ApplyRipple(FDragClip.TlStart - FDragOrigStart);
@ -1473,7 +1493,8 @@ begin
if (Idx < 0) and (FProject <> nil) and (Y > TracksBottom) and
(not FTrackAddedDuringDrag) then
begin
FProject.AddTrack('V' + IntToStr(FProject.TrackCount + 1));
FProject.AddTrack('V' +
IntToStr(FProject.TrackCountOf(tkVideo) + 1));
FTrackAddedDuringDrag := True;
Idx := FProject.TrackCount - 1;
if Assigned(FOnTrackAdded) then
@ -1592,6 +1613,39 @@ end;
двигал нижние, а нижний не двигал верхние — те начинались раньше его
конца и в список не попадали. От начала правило читается просто: едет
всё, что начинается не раньше того, за что взялись. }
procedure TTimelineControl.CollectGroupClips(AClip: TClip);
var
T, C, N: Integer;
Clip: TClip;
begin
FGroupClips := nil;
FGroupOrigStarts := nil;
if (FProject = nil) or (AClip = nil) or (AClip.GroupId = '') then
Exit;
N := 0;
for T := 0 to FProject.TrackCount - 1 do
for C := 0 to FProject.Tracks[T].Count - 1 do
begin
Clip := FProject.Tracks[T][C];
if (Clip = AClip) or (Clip.GroupId <> AClip.GroupId) then
Continue;
SetLength(FGroupClips, N + 1);
SetLength(FGroupOrigStarts, N + 1);
FGroupClips[N] := Clip;
FGroupOrigStarts[N] := Clip.TlStart;
Inc(N);
end;
end;
procedure TTimelineControl.ApplyGroupMove(const ADelta: Double);
var
I: Integer;
begin
for I := 0 to High(FGroupClips) do
FGroupClips[I].TlStart := Max(0, FGroupOrigStarts[I] + ADelta);
end;
procedure TTimelineControl.CollectRippleClips(ATrackIndex: Integer;
AExclude: TClip; const AFromTime: Double);
var
@ -1741,6 +1795,7 @@ var
Clip, Tail: TClip;
AnyChange: Boolean;
Offset: Double;
HoverGroup: string;
begin
if FProject = nil then
Exit;
@ -1748,16 +1803,28 @@ begin
AnyChange := False;
FProject.BeginChange;
{ Связка режется целиком. Резать только картинку, оставив звук цельным,
значит развалить пару: дальше они поедут порознь. }
HoverGroup := '';
if AOnlyCurrentTrack and (FHoverTrack >= 0) and
(FHoverTrack < FProject.TrackCount) then
begin
Clip := FProject.Tracks[FHoverTrack].ClipAt(FPlayhead);
if Clip <> nil then
HoverGroup := Clip.GroupId;
end;
for T := 0 to FProject.TrackCount - 1 do
begin
if AOnlyCurrentTrack and (FHoverTrack >= 0) and (T <> FHoverTrack) then
Continue;
Track := FProject.Tracks[T];
Clip := Track.ClipAt(FPlayhead);
if Clip = nil then
Continue;
if AOnlyCurrentTrack and (FHoverTrack >= 0) and (T <> FHoverTrack) then
if (HoverGroup = '') or (Clip.GroupId <> HoverGroup) then
Continue;
Offset := FPlayhead - Clip.TlStart;
{ Рез вплотную к краю ничего не даёт, только плодит клипы нулевой длины. }
if (Offset < 0.001) or (Offset > Clip.Duration - 0.001) then
@ -1771,6 +1838,12 @@ begin
Tail.TlStart := FPlayhead;
Tail.Duration := Clip.Duration - Offset;
Tail.EdgeOut := Clip.EdgeOut;
{ Хвост — тот же клип, только вторая его половина. Всё, что было
настроено, должно перейти к нему: иначе после реза половина клипа
теряет громкость и кадрирование. }
Tail.Volume := Clip.Volume;
Tail.Transform := Clip.Transform;
Tail.GroupId := Clip.GroupId;
{ Новый рез описывается одинаково с обеих сторон: конец первой части
и начало второй — это одно и то же место. }
@ -1798,6 +1871,89 @@ begin
FProject.Undo;
end;
{ Расширяет выделение на всю связку: клипы, связанные с уже выделенными,
тоже считаются выделенными. Иначе удаление или перенос разорвали бы
связку пополам. }
procedure TTimelineControl.SelectWholeGroups;
var
T, C, T2, C2: Integer;
Groups: TStringList;
Clip: TClip;
begin
if FProject = nil then
Exit;
Groups := TStringList.Create;
try
Groups.Sorted := True;
Groups.Duplicates := dupIgnore;
for T := 0 to FProject.TrackCount - 1 do
for C := 0 to FProject.Tracks[T].Count - 1 do
begin
Clip := FProject.Tracks[T][C];
if Clip.Selected and (Clip.GroupId <> '') then
Groups.Add(Clip.GroupId);
end;
if Groups.Count = 0 then
Exit;
for T2 := 0 to FProject.TrackCount - 1 do
for C2 := 0 to FProject.Tracks[T2].Count - 1 do
begin
Clip := FProject.Tracks[T2][C2];
if (Clip.GroupId <> '') and (Groups.IndexOf(Clip.GroupId) >= 0) then
Clip.Selected := True;
end;
finally
Groups.Free;
end;
end;
procedure TTimelineControl.ToggleGroupSelected;
var
T, C, N: Integer;
Clip: TClip;
Group: string;
AllSame: Boolean;
begin
if FProject = nil then
Exit;
{ Сначала смотрим, что выделено: если все выделенные уже в одной
связке, значит их надо развязать. }
Group := '';
AllSame := True;
N := 0;
for T := 0 to FProject.TrackCount - 1 do
for C := 0 to FProject.Tracks[T].Count - 1 do
begin
Clip := FProject.Tracks[T][C];
if not Clip.Selected then
Continue;
Inc(N);
if Group = '' then
Group := Clip.GroupId
else if Clip.GroupId <> Group then
AllSame := False;
end;
if N < 1 then
Exit;
FProject.BeginChange;
if AllSame and (Group <> '') then
Group := ''
else
Group := FProject.NewGroupId;
for T := 0 to FProject.TrackCount - 1 do
for C := 0 to FProject.Tracks[T].Count - 1 do
if FProject.Tracks[T][C].Selected then
FProject.Tracks[T][C].GroupId := Group;
StructureChanged;
end;
procedure TTimelineControl.DeleteSelected;
var
T, C: Integer;
@ -1807,6 +1963,10 @@ begin
if FProject = nil then
Exit;
{ Удалять надо связку целиком: половина связки — это разъехавшийся
звук, который потом не найти. }
SelectWholeGroups;
AnyChange := False;
FProject.BeginChange;
@ -1946,6 +2106,12 @@ begin
DeleteSelected;
Key := 0;
end;
VK_G:
if ssCtrl in Shift then
begin
ToggleGroupSelected;
Key := 0;
end;
VK_A:
if ssCtrl in Shift then
begin