From ab719dfd1e4690b69fd153697942056577bb2781 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Fri, 11 Sep 2026 21:14:52 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9A=D0=BE=D0=BF=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BB=D0=B8=D0=BF=D0=BE=D0=B2?= =?UTF-8?q?:=20Ctrl+C=20=D0=B8=20Ctrl+V?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Отбивку или текстовый блок проще размножить копией, чем заводить заново через меню и настраивать оформление с нуля. Копируются выделенные клипы, вставляются по курсору. Связанные берутся вместе с парой — у картинки со звуком иначе уехала бы половина, — но связка у копий своя: копия и подлинник не должны ездить вместе. Взаимное расположение сохраняется, набор сдвигается к курсору. Дорожка выбирается сама: сначала та, откуда копировали, если это время на ней свободно; потом любая подходящая по виду; в крайнем случае заводится новая. Наложить копию на соседа по дорожке нельзя — это означало бы переход, которого никто не заказывал. В модели появился TClip.CopyAllFrom: полная копия, кроме места на монтаже. Проверка в lfmcheck перебирает перенесённые свойства и отдельно убеждается, что место и связка не переносятся. --- README.md | 16 +++ src/uMainForm.pas | 210 +++++++++++++++++++++++++++++++++++- src/uProject.pas | 22 ++++ tests/lfmcheck/lfmcheck.lpr | 71 ++++++++++++ 4 files changed, 318 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e7c47f8..9fb31d1 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,8 @@ xdg-mime query default application/x-nlve-project | Выключить дорожку / оставить только её | кнопки М и С в заголовке | | Громкость клипа | тянуть ручку на клипе вверх-вниз | | Автосхлопывание вкл/выкл | Ctrl+K | +| Копировать и вставить клипы | Ctrl+C, Ctrl+V | +| Скопировать и применить оформление | Ctrl+Shift+C, Ctrl+Shift+V | | Связать или развязать клипы | Ctrl+G | | Отменить прилипание на время | Shift при перетаскивании | | Вписать проект целиком | Ctrl+0 | @@ -182,6 +184,20 @@ xdg-mime query default application/x-nlve-project переименовать, удалить. Удаление дорожки с клипами спрашивает подтверждение и называет число клипов. +**Копирование клипов.** Ctrl+C копирует выделенные клипы, Ctrl+V кладёт +копии по курсору. Отбивку или текстовый блок так размножить проще, чем +заводить заново и настраивать оформление с нуля. + +Связанные клипы копируются с парой: у картинки со звуком иначе уехала бы +половина. Связка у копий своя — копия и подлинник не ездят вместе. +Взаимное расположение сохраняется: несколько клипов встают тем же узором, +сдвинутым к курсору. + +Дорожка выбирается сама: сначала та, откуда копировали, если это время на +ней свободно; потом любая подходящая по виду; в крайнем случае заводится +новая. Класть копию поверх соседа нельзя — на одной дорожке это означает +переход, а его никто не заказывал. + **Копирование свойств клипа.** Ctrl+Shift+C берёт с выделенного клипа всё оформление — кадрирование, поворот, увеличение, смещение, прозрачность, цветовой ключ, затухания, громкость, — Ctrl+Shift+V применяет их ко всем diff --git a/src/uMainForm.pas b/src/uMainForm.pas index 400d5c6..a77791e 100644 --- a/src/uMainForm.pas +++ b/src/uMainForm.pas @@ -22,7 +22,8 @@ unit uMainForm; interface uses - Classes, SysUtils, Forms, Controls, Graphics, Dialogs, ExtCtrls, StdCtrls, + Classes, SysUtils, contnrs, Forms, Controls, Graphics, Dialogs, ExtCtrls, + StdCtrls, ComCtrls, Menus, Buttons, LCLType, LMessages, uAppTypes, uEdl, uMpvLib, uMpvPlayer, uProject, uMediaProbe, uTimelineCtrl, uFlatten, uGenerate, uExport, uEncodeForm, uAboutForm, uProgressForm, @@ -106,6 +107,13 @@ type { Клип, с которого скопированы свойства. Хранится копией, а не ссылкой: исходный клип могут удалить, а свойства должны остаться. } FLookClip: TClip; + { Скопированные клипы: сами копии, имя дорожки каждого, смещение от + начала выделения и прежняя связка. Хранятся копиями по той же + причине — исходные могут исчезнуть. } + FClipboard: TObjectList; + FClipTrackNames: array of string; + FClipOffsets: array of Double; + FClipGroups: array of string; { Проект открыт из командной строки: спросить про клипы на чужих дорожках можно только когда окно уже показано. } FCheckMisplacedOnShow: Boolean; @@ -335,6 +343,11 @@ type procedure MenuColorKey(Sender: TObject); procedure MenuCopyLook(Sender: TObject); procedure MenuPasteLook(Sender: TObject); + procedure MenuCopyClips(Sender: TObject); + procedure MenuPasteClips(Sender: TObject); + { Куда положить вставляемый клип: дорожка того же вида, где это время + свободно. } + function TrackForPaste(AClip: TClip; const AName: string): TTrack; function FirstSelectedClip: TClip; procedure MenuRegionIn(Sender: TObject); procedure MenuRegionOut(Sender: TObject); @@ -463,6 +476,9 @@ begin BuildPoolMenu; BuildTrackMenu; + { Буфер владеет копиями: подлинники могут исчезнуть, а вставлять надо + по-прежнему. } + FClipboard := TObjectList.Create(True); RefreshRecentMenu; AllowDropFiles := True; OnDropFiles := @FormFilesDropped; @@ -490,6 +506,7 @@ procedure TMainForm.FormDestroy(Sender: TObject); begin Log('Закрытие: останавливаю предпросмотр.'); FreeAndNil(FLookClip); + FreeAndNil(FClipboard); FPreviewTimer.Enabled := False; if Assigned(FThumbThread) then begin @@ -713,6 +730,10 @@ begin AddItem(MEdit, 'Выделить всё', @MenuSelectAll, ShortCut(VK_A, [ssCtrl])); AddItem(MEdit, 'Цветовой ключ клипа...', @MenuColorKey); AddSep(MEdit); + AddItem(MEdit, 'Копировать клипы', @MenuCopyClips, ShortCut(VK_C, [ssCtrl])); + AddItem(MEdit, 'Вставить клипы по курсору', @MenuPasteClips, + ShortCut(VK_V, [ssCtrl])); + AddSep(MEdit); AddItem(MEdit, 'Скопировать свойства клипа', @MenuCopyLook, ShortCut(VK_C, [ssCtrl, ssShift])); AddItem(MEdit, 'Применить свойства к выделенным', @MenuPasteLook, @@ -3508,6 +3529,193 @@ end; Отметки ставятся по курсору, как в монтажных программах. Перепутанные местами меняются сами: требовать от человека помнить порядок — лишняя строгость там, где всё однозначно. } +{ Копирование клипов. + + Отбивку или текстовый блок проще размножить копией, чем заводить заново + через меню и настраивать заново оформление. Связанные клипы копируются + вместе с парой: у картинки со звуком иначе уехала бы половина. + + Взаимное расположение сохраняется: несколько клипов вставляются тем же + узором, сдвинутым к курсору. } +procedure TMainForm.MenuCopyClips(Sender: TObject); +var + T, C, I: Integer; + Clip, Copy: TClip; + Track: TTrack; + Base: Double; + Taken: TFPList; + + { Берём и связанные: они двигаются, режутся и удаляются вместе, значит + и копироваться должны вместе. } + procedure TakeWithGroup(ASource: TClip); + var + Ti, Ci: Integer; + Other: TClip; + begin + if Taken.IndexOf(ASource) >= 0 then + Exit; + Taken.Add(ASource); + if ASource.GroupId = '' then + Exit; + for Ti := 0 to FProject.TrackCount - 1 do + for Ci := 0 to FProject.Tracks[Ti].Count - 1 do + begin + Other := FProject.Tracks[Ti][Ci]; + if (Other.GroupId = ASource.GroupId) and (Taken.IndexOf(Other) < 0) then + Taken.Add(Other); + end; + end; + +begin + Taken := TFPList.Create; + try + 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 + TakeWithGroup(FProject.Tracks[T][C]); + + if Taken.Count = 0 then + begin + SetStatus('Сначала выделите клипы, которые копировать.'); + Exit; + end; + + FClipboard.Clear; + FClipTrackNames := nil; + FClipOffsets := nil; + FClipGroups := nil; + + { Смещения считаем от самого раннего клипа: вставка кладёт его под + курсор, остальные — как лежали относительно него. } + Base := TClip(Taken[0]).TlStart; + for I := 1 to Taken.Count - 1 do + Base := Min(Base, TClip(Taken[I]).TlStart); + + SetLength(FClipTrackNames, Taken.Count); + SetLength(FClipOffsets, Taken.Count); + SetLength(FClipGroups, Taken.Count); + for I := 0 to Taken.Count - 1 do + begin + Clip := TClip(Taken[I]); + Track := FProject.FindClipTrack(Clip); + Copy := TClip.Create; + Copy.CopyAllFrom(Clip); + FClipboard.Add(Copy); + if Track <> nil then + FClipTrackNames[I] := Track.Name + else + FClipTrackNames[I] := ''; + FClipOffsets[I] := Clip.TlStart - Base; + FClipGroups[I] := Clip.GroupId; + end; + + SetStatus(Format('Скопировано %d %s. Вставить: Ctrl+V.', + [Taken.Count, Plural(Taken.Count, 'клип', 'клипа', 'клипов')])); + finally + Taken.Free; + end; +end; + +{ Дорожка для вставки: сначала та, откуда копировали, если она есть и + время свободно; потом любая подходящая по виду; в крайнем случае новая. + Класть клип поверх соседа нельзя: на одной дорожке это означает переход, + а его никто не заказывал. } +function TMainForm.TrackForPaste(AClip: TClip; const AName: string): TTrack; +var + I: Integer; + Need: TTrackKind; + + function Fits(ATrack: TTrack): Boolean; + var + C: Integer; + begin + Result := True; + for C := 0 to ATrack.Count - 1 do + if (AClip.TlStart < ATrack[C].TlEnd - 0.001) and + (AClip.TlEnd > ATrack[C].TlStart + 0.001) then + Exit(False); + end; + +begin + Need := TProject.TrackKindFor(AClip.Kind); + + for I := 0 to FProject.TrackCount - 1 do + if (FProject.Tracks[I].Name = AName) and (FProject.Tracks[I].Kind = Need) and + Fits(FProject.Tracks[I]) then + Exit(FProject.Tracks[I]); + + for I := 0 to FProject.TrackCount - 1 do + if (FProject.Tracks[I].Kind = Need) and Fits(FProject.Tracks[I]) then + Exit(FProject.Tracks[I]); + + case Need of + tkAudio: Result := FProject.AddTrackOfKind(FProject.UniqueTrackName('A'), + tkAudio); + tkText: Result := FProject.AddTrackOfKind(FProject.UniqueTrackName('Т'), + tkText); + else + Result := FProject.AddTrack(FProject.UniqueTrackName('V')); + end; +end; + +procedure TMainForm.MenuPasteClips(Sender: TObject); +var + I, J: Integer; + Clip: TClip; + Track: TTrack; + NewGroups: TStringList; +begin + if FClipboard.Count = 0 then + begin + SetStatus('Сначала скопируйте клипы: Ctrl+C.'); + Exit; + end; + + FProject.BeginChange; + FProject.ClearSelection; + + { Связки у вставленных свои: иначе копия и подлинник ездили бы и + удалялись вместе. Прежние связки при этом сохраняются как связки + внутри вставленного набора. } + NewGroups := TStringList.Create; + try + NewGroups.NameValueSeparator := '='; + for I := 0 to FClipboard.Count - 1 do + begin + Clip := TClip.Create; + Clip.CopyAllFrom(TClip(FClipboard[I])); + Clip.Id := FProject.NewId('c'); + Clip.TlStart := Max(0, FTimeline.Playhead + FClipOffsets[I]); + Clip.Selected := True; + + { Старая связка отображается в новую: клипы, связанные между собой, + останутся связанными и после вставки, но с подлинником общего у + них больше не будет. } + if FClipGroups[I] <> '' then + begin + J := NewGroups.IndexOfName(FClipGroups[I]); + if J < 0 then + J := NewGroups.Add(FClipGroups[I] + '=' + FProject.NewId('g')); + Clip.GroupId := NewGroups.ValueFromIndex[J]; + end; + + Track := TrackForPaste(Clip, FClipTrackNames[I]); + Track.Add(Clip); + Track.Sort; + end; + finally + NewGroups.Free; + end; + + FTimeline.Invalidate; + FLastLoadedEdl := ''; + SchedulePreview; + UpdateCaption; + SetStatus(Format('Вставлено %d %s.', + [FClipboard.Count, + Plural(FClipboard.Count, 'клип', 'клипа', 'клипов')])); +end; + { Первый выделенный клип: с него берут свойства. } function TMainForm.FirstSelectedClip: TClip; var diff --git a/src/uProject.pas b/src/uProject.pas index 6da4a5b..34d4a70 100644 --- a/src/uProject.pas +++ b/src/uProject.pas @@ -164,6 +164,13 @@ type картинку в надпись при этом незачем. Оформление надписи переносится только между надписями. } procedure CopyLookFrom(ASource: TClip); + { Полная копия клипа, кроме его места на монтаже. + + Переносится всё, что делает клип этим куском материала: файл, откуда + и сколько брать, как сделаны края, и всё оформление. Не переносится + только то, что определяет, где он лежит: имя, время начала, связка и + выделение — их задаёт тот, кто вставляет. } + procedure CopyAllFrom(ASource: TClip); function TlEnd: Double; function SrcOut: Double; { Попадает ли момент внутрь клипа. Правый край не включается: там уже @@ -481,6 +488,21 @@ begin FTextStyle := ASource.TextStyle; end; +procedure TClip.CopyAllFrom(ASource: TClip); +begin + if ASource = nil then + Exit; + FMediaId := ASource.MediaId; + FSrcIn := ASource.SrcIn; + FDuration := ASource.Duration; + FEdgeIn := ASource.EdgeIn; + FEdgeOut := ASource.EdgeOut; + FKind := ASource.Kind; + FText := ASource.Text; + FTextStyle := ASource.TextStyle; + CopyLookFrom(ASource); +end; + procedure TClip.CopyLookFrom(ASource: TClip); begin if ASource = nil then diff --git a/tests/lfmcheck/lfmcheck.lpr b/tests/lfmcheck/lfmcheck.lpr index cfaff79..28290d6 100644 --- a/tests/lfmcheck/lfmcheck.lpr +++ b/tests/lfmcheck/lfmcheck.lpr @@ -304,6 +304,76 @@ begin end; end; +{ Копирование клипа: модель должна переносить всё, кроме места. + + Само сочетание клавиш проверяется руками, а вот перенос свойств — + числами: именно из-за него копия отбивки или текстового блока была бы + бесполезной. } +procedure CheckClipCopy; +var + Proj: TProject; + Info: TMediaInfo; + Media: TMediaItem; + Src, Dst: TClip; + Key: TColorKey; +begin + WriteLn; + WriteLn('Копия клипа'); + WriteLn; + + Info := Default(TMediaInfo); + Info.Valid := True; + Info.Kind := mkVideo; + Info.Duration := 600; + Info.HasVideo := True; + Info.VideoCodec := 'hevc'; + Info.Width := 1920; + Info.Height := 1080; + Info.FpsNum := 30; + Info.FpsDen := 1; + Info.PixFmt := 'yuv420p'; + + Proj := TProject.Create; + try + Proj.Clear; + Proj.AddTrack('V1'); + Media := Proj.AddMedia('/несуществующий/видео.mp4', Info); + Src := Proj.AddClip(Proj.Tracks[0], Media, 30, 7, 5); + Src.Kind := ckText; + Src.Text := 'Отбивка'; + Src.Volume := 0.4; + Src.Opacity := 0.6; + Key := DefaultColorKey; + Key.Enabled := True; + Src.ColorKey := Key; + Src.FadeIn := 1.25; + Src.EdgeOut := edPrecise; + Src.GroupId := 'g7'; + + Dst := TClip.Create; + try + Dst.CopyAllFrom(Src); + CheckTrue('вид клипа перенесён', Dst.Kind = ckText); + CheckTrue('текст перенесён', Dst.Text = 'Отбивка'); + CheckTrue('файл и кусок исходника перенесены', + (Dst.MediaId = Src.MediaId) and (Abs(Dst.SrcIn - 30) < 0.001) and + (Abs(Dst.Duration - 7) < 0.001)); + CheckTrue('края перенесены', Dst.EdgeOut = edPrecise); + CheckTrue('громкость и прозрачность перенесены', + (Abs(Dst.Volume - 0.4) < 0.001) and (Abs(Dst.Opacity - 0.6) < 0.001)); + CheckTrue('ключ перенесён', Dst.ColorKey.Enabled); + CheckTrue('затухание перенесено', Abs(Dst.FadeIn - 1.25) < 0.001); + { Место и связка — дело того, кто вставляет. } + CheckTrue('место не перенесено', Abs(Dst.TlStart) < 0.001); + CheckTrue('связка не перенесена', Dst.GroupId = ''); + finally + Dst.Free; + end; + finally + Proj.Free; + end; +end; + begin WriteLn('Свойства из файлов форм'); WriteLn; @@ -333,6 +403,7 @@ begin CheckPlayheadSnap; CheckSplitKeepsSettings; CheckRippleWithoutMouse; + CheckClipCopy; WriteLn; if Failed = 0 then