From b6e215a03f64eae1636cbb70a77fa085fc7bffc5 Mon Sep 17 00:00:00 2001 From: chaiman Date: Thu, 20 Aug 2026 09:58:16 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A1=D0=B2=D1=8F=D0=B7=D0=BA=D0=B8=20=D0=BA?= =?UTF-8?q?=D0=BB=D0=B8=D0=BF=D0=BE=D0=B2=20=D0=B8=20=D0=BF=D0=BE=D1=80?= =?UTF-8?q?=D1=8F=D0=B4=D0=BE=D0=BA=20=D0=B4=D0=BE=D1=80=D0=BE=D0=B6=D0=B5?= =?UTF-8?q?=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ctrl+G связывает выделенные клипы и развязывает их, если они уже в одной связке. Связка двигается, режется и удаляется целиком: половина связки — это разъехавшийся звук, который потом не найти. Форма звука переехала на звуковые клипы: на клипе картинки ей не место, звук у него теперь свой и живёт на своей дорожке. Кнопка кадрирования, наоборот, осталась только у картинки. Новая дорожка картинки встаёт перед первой звуковой, а не в конец списка: порядок дорожек задаёт, кто побеждает при перекрытии. Найдена и исправлена настоящая ошибка: при резе новый кусок терял громкость и кадрирование — они просто не переносились на хвост. --- src/uMainForm.pas | 10 ++- src/uProject.pas | 44 ++++++++-- src/uTimelineCtrl.pas | 184 +++++++++++++++++++++++++++++++++++++++--- 3 files changed, 221 insertions(+), 17 deletions(-) diff --git a/src/uMainForm.pas b/src/uMainForm.pas index 87076a0..fc84d7b 100644 --- a/src/uMainForm.pas +++ b/src/uMainForm.pas @@ -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; diff --git a/src/uProject.pas b/src/uProject.pas index e14f3cb..bbde52d 100644 --- a/src/uProject.pas +++ b/src/uProject.pas @@ -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; diff --git a/src/uTimelineCtrl.pas b/src/uTimelineCtrl.pas index 964a602..d866105 100644 --- a/src/uTimelineCtrl.pas +++ b/src/uTimelineCtrl.pas @@ -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