Compare commits
4 commits
82929a8d5b
...
8258de7145
| Author | SHA1 | Date | |
|---|---|---|---|
| 8258de7145 | |||
| ab719dfd1e | |||
| 407eb02f2d | |||
| 939184fce7 |
11 changed files with 843 additions and 40 deletions
|
|
@ -119,6 +119,15 @@ NVENC, если он есть). Это разрозненно и не даёт
|
|||
неподъёмным. Пока цифры замеряны только на одной машине, у человека
|
||||
должна быть возможность выключить любую из этих трат.
|
||||
|
||||
## Отмена ненужной генерации не проверена живьём
|
||||
|
||||
Правка монтажа прекращает создание вставок, ставших ненужными, — код на
|
||||
месте и вызывается на каждой пересборке предпросмотра. Но поймать отмену
|
||||
глазами не на чем: на мелком проверочном материале минутная чёрная
|
||||
вставка делается мгновенно. Нужен прогон на настоящем материале в
|
||||
2688x1512: завести дыру, дождаться начала генерации, закрыть дыру и
|
||||
убедиться по журналу, что ffmpeg остановлен.
|
||||
|
||||
## Мелочи, до которых не дошли руки
|
||||
|
||||
**Вид дорожки не виден в её заголовке.** Дорожки картинки, звука и текста
|
||||
|
|
|
|||
57
README.md
57
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 |
|
||||
|
|
@ -141,6 +143,13 @@ xdg-mime query default application/x-nlve-project
|
|||
липнут при перетаскивании, курсор — при щелчке, и одно бывает нужно без
|
||||
другого.
|
||||
|
||||
**Курсор посередине** (кнопка с целью, клавиша `\`). Включено — при
|
||||
воспроизведении монтаж едет под курсором, и всегда видно, что будет
|
||||
дальше. Выключено — окно не двигается вовсе, даже когда курсор ушёл за
|
||||
край: отмотал Shift с колесом — там и останешься. Раньше в выключенном
|
||||
состоянии окно всё равно подтягивалось к курсору, стоило тому выйти из
|
||||
видимой части, и прокрутка отскакивала назад со второго поворота колеса.
|
||||
|
||||
**Ключ и прозрачность в предпросмотре** (меню «Вид»). Включено — окно
|
||||
показывает то же, что даст сборка: выбитый цвет и приглушение считаются
|
||||
на лету. Выключено — оверлей виден целиком, со своим фоном: так понятно,
|
||||
|
|
@ -175,6 +184,20 @@ xdg-mime query default application/x-nlve-project
|
|||
переименовать, удалить. Удаление дорожки с клипами спрашивает
|
||||
подтверждение и называет число клипов.
|
||||
|
||||
**Копирование клипов.** Ctrl+C копирует выделенные клипы, Ctrl+V кладёт
|
||||
копии по курсору. Отбивку или текстовый блок так размножить проще, чем
|
||||
заводить заново и настраивать оформление с нуля.
|
||||
|
||||
Связанные клипы копируются с парой: у картинки со звуком иначе уехала бы
|
||||
половина. Связка у копий своя — копия и подлинник не ездят вместе.
|
||||
Взаимное расположение сохраняется: несколько клипов встают тем же узором,
|
||||
сдвинутым к курсору.
|
||||
|
||||
Дорожка выбирается сама: сначала та, откуда копировали, если это время на
|
||||
ней свободно; потом любая подходящая по виду; в крайнем случае заводится
|
||||
новая. Класть копию поверх соседа нельзя — на одной дорожке это означает
|
||||
переход, а его никто не заказывал.
|
||||
|
||||
**Копирование свойств клипа.** Ctrl+Shift+C берёт с выделенного клипа всё
|
||||
оформление — кадрирование, поворот, увеличение, смещение, прозрачность,
|
||||
цветовой ключ, затухания, громкость, — Ctrl+Shift+V применяет их ко всем
|
||||
|
|
@ -398,6 +421,21 @@ Linux потом искала по нему `.dll`.
|
|||
каждого слоя считается на каждый отсчёт, и длина результата получается
|
||||
ровно такой, какая нужна.
|
||||
|
||||
## Текст режет кусок, а не пересобирает его целиком
|
||||
|
||||
Вжигание надписи не делает весь кусок пересобираемым. Кусок режется на
|
||||
части: до надписи, под надписью и после. Пересобирается только середина,
|
||||
остальное копируется. Надпись в тридцать секунд внутри сорокаминутного
|
||||
куска раньше стоила сорока минут пересборки — половины ролика.
|
||||
|
||||
Часть, которая начинается не с начала куска, помечается точным резом:
|
||||
копия пошла бы от ближайшего опорного кадра и сдвинула бы картинку.
|
||||
Сборка перекодирует огрызок до опорного кадра, а дальше снова копирует —
|
||||
доли секунды вместо минут.
|
||||
|
||||
Обрезки короче секунды не копируются, а дожигаются вместе с надписью:
|
||||
возни с опорными кадрами там больше, чем выгоды.
|
||||
|
||||
## Качество пересобираемых кусков
|
||||
|
||||
Куски, которые нельзя скопировать, целятся в наибольший битрейт среди
|
||||
|
|
@ -428,6 +466,25 @@ Linux потом искала по нему `.dll`.
|
|||
Оставить их можно настройкой `keep_parts` в файле настроек: пригодится,
|
||||
когда разбираешься, из чего собрался ролик.
|
||||
|
||||
## Дыры и вставки
|
||||
|
||||
Промежуток короче половины кадра проекта дырой не считается: между
|
||||
клипами, сдвинутыми встык мышью, остаются микросекунды, и заказывать под
|
||||
них чёрную вставку не нужно. Мышь при этом кладёт клипы на кадровую
|
||||
сетку — между кадрами времени не существует.
|
||||
|
||||
Готовые вставки делаются двух длин, 15 и 60 секунд. Дыра длиннее минуты
|
||||
набирается повторами одного и того же минутного файла: и список кусков
|
||||
предпросмотра, и список склейки повторы принимают. Раньше лестница длин
|
||||
доходила до восьми часов, и дыра в сорок минут требовала двухчасового
|
||||
чёрного файла — программа выглядела зависшей.
|
||||
|
||||
Чёрное кодируется отдельным скромным потоком в мегабит: целиться в
|
||||
битрейт съёмки там нечему.
|
||||
|
||||
Если правка монтажа сделала идущую генерацию ненужной, она прекращается:
|
||||
программа проверяет, нужна ли ещё та вставка, что делается сейчас.
|
||||
|
||||
## Прерванная сборка
|
||||
|
||||
Каждый доведённый до конца кусок помечается в списке рядом с самими
|
||||
|
|
|
|||
|
|
@ -259,6 +259,10 @@ uses
|
|||
слоёв, а она по тексту раньше самой функции. }
|
||||
function BitrateArgs(ABitrate: Int64): TArgs; forward;
|
||||
|
||||
const
|
||||
{ Поток для чёрных вставок: мегабит в секунду. }
|
||||
BlackBitrate = 1000000;
|
||||
|
||||
{ Общие параметры вывода в формате проекта. Объявлено заранее, потому что
|
||||
используется выше по тексту, чем определено. }
|
||||
function ArgsExtractAudioRaw(const ASource, ATarget: string;
|
||||
|
|
@ -1138,8 +1142,12 @@ begin
|
|||
Format('anullsrc=channel_layout=%s:sample_rate=%d',
|
||||
[IfThen(AChannels = 1, 'mono', 'stereo'), ASampleRate])]);
|
||||
|
||||
{ Чёрный кадр жмётся в ничто, и целиться в битрейт съёмки тут незачем:
|
||||
мегабита в секунду хватит с запасом. Без явного числа сюда попало бы
|
||||
то, чем целятся пересобираемые куски, — полсотни мегабит на чёрное. }
|
||||
Result := Concat(Result, MasterOutputArgs(0, 0, AFpsNum, AFpsDen,
|
||||
AVideoCodec, APixFmt, AAudioCodec, ASampleRate, AChannels));
|
||||
AVideoCodec, APixFmt, AAudioCodec, ASampleRate, AChannels,
|
||||
BlackBitrate));
|
||||
Result := Concat(Result, NoBFramesArgs);
|
||||
Result := Concat(Result, ['-t', SecondsToStr(ADuration)]);
|
||||
Result := Concat(Result, ProgressArgs);
|
||||
|
|
|
|||
|
|
@ -221,7 +221,17 @@ const
|
|||
Теперь вставка берётся из готового файла подходящей длины, а нужный
|
||||
кусок из неё просто вырезается. Файлов получается несколько штук на
|
||||
проект, и делаются они один раз. }
|
||||
LengthBuckets: array[0..5] of Double = (15, 60, 300, 1800, 7200, 28800);
|
||||
LengthBuckets: array[0..1] of Double = (15, 60);
|
||||
|
||||
{ Длиннее минуты готовых вставок не делаем.
|
||||
|
||||
Раньше лестница доходила до восьми часов, и дыра в сорок минут
|
||||
требовала двухчасового чёрного файла: NVENC в 2688x1512 при 60 к/с
|
||||
кодирует его минутами, а пока он делается, программа выглядит
|
||||
зависшей. Длинная дыра теперь набирается из одного и того же
|
||||
минутного файла, повторённого нужное число раз — и список кусков
|
||||
предпросмотра, и список склейки повторы принимают. }
|
||||
MaxInsertLength = 60.0;
|
||||
|
||||
function QuantizeDuration(const AValue: Double): Double;
|
||||
begin
|
||||
|
|
@ -479,6 +489,8 @@ var
|
|||
Path: string;
|
||||
Seen: TStringList;
|
||||
Partner, Under: TClip;
|
||||
Left, HalfFrame: Double;
|
||||
Piece: TFlatSegment;
|
||||
|
||||
{ Слой из клипа: где в исходнике брать картинку и с какой
|
||||
непрозрачностью её накладывать. }
|
||||
|
|
@ -683,6 +695,13 @@ begin
|
|||
if AProject = nil then
|
||||
Exit;
|
||||
|
||||
{ Половина кадра проекта. Частота бывает не задана — тогда берём
|
||||
полмиллисекунды, как было раньше. }
|
||||
if (AProject.Master.FpsNum > 0) and (AProject.Master.FpsDen > 0) then
|
||||
HalfFrame := 0.5 * AProject.Master.FpsDen / AProject.Master.FpsNum
|
||||
else
|
||||
HalfFrame := 0.0005;
|
||||
|
||||
{ --- Границы --- }
|
||||
N := 0;
|
||||
for T := 0 to AProject.TrackCount - 1 do
|
||||
|
|
@ -729,8 +748,15 @@ begin
|
|||
begin
|
||||
SegStart := Bounds[I];
|
||||
SegEnd := Bounds[I + 1];
|
||||
{ Совпавшие границы дают промежуток нулевой длины — пропускаем. }
|
||||
if SegEnd - SegStart < 0.0005 then
|
||||
{ Промежуток короче половины кадра — это не дыра, а остаток
|
||||
вычислений в долях секунды.
|
||||
|
||||
Раньше порог был жёсткий, полмиллисекунды, и зазор в двадцать три
|
||||
микросекунды между сдвинутыми встык клипами оказывался с ним рядом.
|
||||
Стоит зазору чуть подрасти — и программа заказывает чёрную вставку
|
||||
туда, где на глаз всё встык. Половина кадра — естественная мера:
|
||||
короче неё в ролике не бывает ничего. }
|
||||
if SegEnd - SegStart < HalfFrame then
|
||||
Continue;
|
||||
|
||||
{ Внутри промежутка победитель не меняется, поэтому достаточно
|
||||
|
|
@ -916,6 +942,30 @@ begin
|
|||
end;
|
||||
end;
|
||||
|
||||
{ Длинная вставка набирается повторами одного файла: см.
|
||||
MaxInsertLength. Куски картинки и видео не трогаем — они берутся из
|
||||
настоящих файлов и такой разбивке не подлежат. }
|
||||
if (Seg.Kind in [skBlack, skImage]) and
|
||||
(Seg.Duration > MaxInsertLength + 0.0005) then
|
||||
begin
|
||||
Left := Seg.Duration;
|
||||
Piece := Seg;
|
||||
while Left > 0.0005 do
|
||||
begin
|
||||
Piece.Duration := Min(MaxInsertLength, Left);
|
||||
{ Лишний запас: последняя часть может быть короче, и файл под неё
|
||||
берётся тот же самый, из которого вырезается сколько надо. }
|
||||
if RawCount >= Length(Raw) then
|
||||
SetLength(Raw, RawCount + 16);
|
||||
Raw[RawCount] := Piece;
|
||||
Inc(RawCount);
|
||||
Left := Left - Piece.Duration;
|
||||
end;
|
||||
Continue;
|
||||
end;
|
||||
|
||||
if RawCount >= Length(Raw) then
|
||||
SetLength(Raw, RawCount + 16);
|
||||
Raw[RawCount] := Seg;
|
||||
Inc(RawCount);
|
||||
end;
|
||||
|
|
|
|||
|
|
@ -64,6 +64,11 @@ type
|
|||
property PendingLog: string read FPendingLog;
|
||||
property Succeeded: Boolean read FSucceeded;
|
||||
property Cancelled: Boolean read FCancelled;
|
||||
{ Что этот поток делает. Список задан при создании и не меняется,
|
||||
поэтому читать его из главного потока безопасно. Нужен, чтобы
|
||||
понять, не стала ли работа лишней после правки монтажа. }
|
||||
function TargetCount: Integer;
|
||||
function TargetPath(AIndex: Integer): string;
|
||||
property Message: string read FMessage;
|
||||
|
||||
property OnProgress: TNotifyEvent read FOnProgress write FOnProgress;
|
||||
|
|
@ -88,6 +93,18 @@ begin
|
|||
FMaster := AMaster;
|
||||
end;
|
||||
|
||||
function TGenerateThread.TargetCount: Integer;
|
||||
begin
|
||||
Result := Length(FRequests);
|
||||
end;
|
||||
|
||||
function TGenerateThread.TargetPath(AIndex: Integer): string;
|
||||
begin
|
||||
if (AIndex < 0) or (AIndex > High(FRequests)) then
|
||||
Exit('');
|
||||
Result := FRequests[AIndex].TargetPath;
|
||||
end;
|
||||
|
||||
procedure TGenerateThread.RequestCancel;
|
||||
begin
|
||||
FCancelRequested := True;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -301,6 +309,8 @@ type
|
|||
procedure LoadPreviewEdl(const ASegments, AAudio: TFlatSegmentArray);
|
||||
procedure BuildOverlayPreview(const AOverlays: TPreviewOverlays;
|
||||
const ATotal: Double);
|
||||
{ Нужно ли ещё то, что делает идущая генерация. }
|
||||
function GenerationStillNeeded: Boolean;
|
||||
procedure GenerateDone(Sender: TObject);
|
||||
procedure GenerateProgress(Sender: TObject);
|
||||
procedure GenerateLog(Sender: TObject);
|
||||
|
|
@ -333,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);
|
||||
|
|
@ -461,6 +476,9 @@ begin
|
|||
|
||||
BuildPoolMenu;
|
||||
BuildTrackMenu;
|
||||
{ Буфер владеет копиями: подлинники могут исчезнуть, а вставлять надо
|
||||
по-прежнему. }
|
||||
FClipboard := TObjectList.Create(True);
|
||||
RefreshRecentMenu;
|
||||
AllowDropFiles := True;
|
||||
OnDropFiles := @FormFilesDropped;
|
||||
|
|
@ -488,6 +506,7 @@ procedure TMainForm.FormDestroy(Sender: TObject);
|
|||
begin
|
||||
Log('Закрытие: останавливаю предпросмотр.');
|
||||
FreeAndNil(FLookClip);
|
||||
FreeAndNil(FClipboard);
|
||||
FPreviewTimer.Enabled := False;
|
||||
if Assigned(FThumbThread) then
|
||||
begin
|
||||
|
|
@ -711,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,
|
||||
|
|
@ -1982,7 +2005,19 @@ begin
|
|||
if FGenerateBusy then
|
||||
begin
|
||||
{ Пока идёт создание вставок, пересборку откладываем: иначе запустим
|
||||
второй такой же поток. }
|
||||
второй такой же поток.
|
||||
|
||||
Но сперва смотрим, нужна ли ещё та работа, что идёт. Дыру могли
|
||||
закрыть, клип подвинуть — и тогда вставка делается впустую, а
|
||||
чёрная минута в 2688x1512 считается не мгновенно. Ненужное
|
||||
прекращаем: поток сам доложит о завершении, и пересборка пойдёт по
|
||||
свежей расстановке. }
|
||||
if Assigned(FGenerate) and (not GenerationStillNeeded) then
|
||||
begin
|
||||
Log('Расстановка изменилась — прекращаю делать ненужные вставки.');
|
||||
SetStatus('Расстановка изменилась, лишние вставки отменены.');
|
||||
FGenerate.RequestCancel;
|
||||
end;
|
||||
FPreviewDirty := True;
|
||||
FPreviewTimer.Enabled := True;
|
||||
Exit;
|
||||
|
|
@ -2129,6 +2164,32 @@ begin
|
|||
FPreviewOverlayUrl := BuildEdlUri(Segs);
|
||||
end;
|
||||
|
||||
{ Делает ли идущая генерация то, что монтажу ещё нужно.
|
||||
|
||||
Уплощение считается заново — оно дешёвое, ffmpeg не запускает. Если ни
|
||||
одна из готовящихся вставок в новом списке не нужна, работу можно
|
||||
прекращать. Ошибиться тут не страшно: отменённое, если оно всё же
|
||||
понадобится, закажут заново на следующей пересборке. }
|
||||
function TMainForm.GenerationStillNeeded: Boolean;
|
||||
var
|
||||
Segs, Auds: TFlatSegmentArray;
|
||||
Ovl: TPreviewOverlays;
|
||||
Missing: TGenRequests;
|
||||
I, J: Integer;
|
||||
begin
|
||||
Result := False;
|
||||
if not Assigned(FGenerate) then
|
||||
Exit;
|
||||
if not FlattenPreview(FProject, Segs, Auds, Ovl, Missing,
|
||||
FSettings.PreviewKey) then
|
||||
Exit;
|
||||
|
||||
for I := 0 to High(Missing) do
|
||||
for J := 0 to FGenerate.TargetCount - 1 do
|
||||
if Missing[I].TargetPath = FGenerate.TargetPath(J) then
|
||||
Exit(True);
|
||||
end;
|
||||
|
||||
procedure TMainForm.LoadPreviewEdl(const ASegments, AAudio: TFlatSegmentArray);
|
||||
var
|
||||
Edl, AudioEdl: string;
|
||||
|
|
@ -3128,13 +3189,15 @@ begin
|
|||
FUpdatingFromPlayer := True;
|
||||
try
|
||||
FTimeline.Playhead := FPlayer.TimePos;
|
||||
{ Во время воспроизведения держим курсор посередине: так видно, что
|
||||
будет дальше, и не приходится гоняться за ним прокруткой. }
|
||||
{ Держать курсор посередине или дать монтажу стоять — решает человек:
|
||||
на слабой машине и на мелком масштабе постоянная прокрутка мешает
|
||||
разглядывать. }
|
||||
if not FPlayer.Paused then
|
||||
FTimeline.ScrollToPlayhead(FSettings.CenterPlayhead);
|
||||
{ Держать курсор посередине или дать монтажу стоять — решает человек.
|
||||
|
||||
Выключено — не двигаем окно вовсе, даже когда курсор ушёл за край.
|
||||
Раньше в этом случае монтаж всё равно подтягивался к курсору, стоило
|
||||
тому выйти из видимой части: прокрутил колесом с Shift — первый
|
||||
поворот сдвинул, второй вернул обратно. Человек отматывал
|
||||
осознанно, и возвращать его силой нельзя. }
|
||||
if (not FPlayer.Paused) and FSettings.CenterPlayhead then
|
||||
FTimeline.ScrollToPlayhead(True);
|
||||
finally
|
||||
FUpdatingFromPlayer := False;
|
||||
end;
|
||||
|
|
@ -3466,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
|
||||
|
|
@ -3595,7 +3845,11 @@ end;
|
|||
|
||||
procedure TMainForm.MenuRipple(Sender: TObject);
|
||||
begin
|
||||
FTimeline.RippleCloseGap;
|
||||
{ Команда должна отвечать всегда: молчание читается как «не работает». }
|
||||
if FTimeline.RippleCloseGap then
|
||||
SetStatus('Дыра схлопнута.')
|
||||
else
|
||||
SetStatus('Под курсором дыры нет: схлопывать нечего.');
|
||||
end;
|
||||
|
||||
{ К началу самого раннего клипа по всем дорожкам. Если монтаж начинается
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -94,6 +94,9 @@ uses
|
|||
Math, LazUTF8;
|
||||
|
||||
const
|
||||
{ Короче этого копируемые обрезки не делаем: возни с опорными кадрами
|
||||
больше, чем выгоды от копирования секунды. }
|
||||
MinCopyPiece = 1.0;
|
||||
{ Запас по ширине: наши мерки и мерки libass расходятся на проценты. }
|
||||
WidthSafety = 0.95;
|
||||
{ Поля от краёв кадра в долях ширины и высоты. }
|
||||
|
|
@ -483,38 +486,195 @@ begin
|
|||
Result := (Text <> '') and WriteText(Text, APath);
|
||||
end;
|
||||
|
||||
{ Отрезки, которые задевает текст, внутри указанного куска. Отрезки
|
||||
сливаются, если налезают друг на друга: две надписи подряд — это одна
|
||||
пересобираемая часть, а не две. }
|
||||
procedure CollectTextRanges(AProject: TProject; const AFrom, ATo: Double;
|
||||
out AStarts, AEnds: TDoubleArray; out ACount: Integer);
|
||||
var
|
||||
T, C, I, J: Integer;
|
||||
Clip: TClip;
|
||||
S, E, TmpS, TmpE: Double;
|
||||
begin
|
||||
ACount := 0;
|
||||
SetLength(AStarts, 0);
|
||||
SetLength(AEnds, 0);
|
||||
if AProject = nil then
|
||||
Exit;
|
||||
|
||||
for T := 0 to AProject.TrackCount - 1 do
|
||||
begin
|
||||
if AProject.Tracks[T].Kind <> tkText then
|
||||
Continue;
|
||||
if not AProject.TrackAudible(AProject.Tracks[T]) then
|
||||
Continue;
|
||||
for C := 0 to AProject.Tracks[T].Count - 1 do
|
||||
begin
|
||||
Clip := AProject.Tracks[T][C];
|
||||
if Trim(Clip.Text) = '' then
|
||||
Continue;
|
||||
S := Max(AFrom, Clip.TlStart);
|
||||
E := Min(ATo, Clip.TlEnd);
|
||||
if E <= S + 0.0005 then
|
||||
Continue;
|
||||
SetLength(AStarts, ACount + 1);
|
||||
SetLength(AEnds, ACount + 1);
|
||||
AStarts[ACount] := S;
|
||||
AEnds[ACount] := E;
|
||||
Inc(ACount);
|
||||
end;
|
||||
end;
|
||||
|
||||
{ Сортировка вставками: надписей в куске единицы. }
|
||||
for I := 1 to ACount - 1 do
|
||||
begin
|
||||
TmpS := AStarts[I];
|
||||
TmpE := AEnds[I];
|
||||
J := I - 1;
|
||||
while (J >= 0) and (AStarts[J] > TmpS) do
|
||||
begin
|
||||
AStarts[J + 1] := AStarts[J];
|
||||
AEnds[J + 1] := AEnds[J];
|
||||
Dec(J);
|
||||
end;
|
||||
AStarts[J + 1] := TmpS;
|
||||
AEnds[J + 1] := TmpE;
|
||||
end;
|
||||
|
||||
{ Слияние налезающих. }
|
||||
I := 0;
|
||||
while I < ACount - 1 do
|
||||
if AStarts[I + 1] <= AEnds[I] + MinCopyPiece then
|
||||
begin
|
||||
AEnds[I] := Max(AEnds[I], AEnds[I + 1]);
|
||||
for J := I + 1 to ACount - 2 do
|
||||
begin
|
||||
AStarts[J] := AStarts[J + 1];
|
||||
AEnds[J] := AEnds[J + 1];
|
||||
end;
|
||||
Dec(ACount);
|
||||
end
|
||||
else
|
||||
Inc(I);
|
||||
end;
|
||||
|
||||
function BurnTextIntoSegments(AProject: TProject;
|
||||
var ASegments: TFlatSegmentArray; const ACacheDir: string;
|
||||
const AStartTime: Double): Double;
|
||||
var
|
||||
I: Integer;
|
||||
At, SegEnd: Double;
|
||||
Res: TFlatSegmentArray;
|
||||
N, I, J, K, RangeCount: Integer;
|
||||
At, SegEnd, Cursor: Double;
|
||||
Starts, Ends: TDoubleArray;
|
||||
Path: string;
|
||||
PrevBurned: Boolean;
|
||||
|
||||
{ Часть исходного куска. AFrom и ATo — время монтажа. }
|
||||
procedure PushPiece(const ASeg: TFlatSegment; const AFrom, ATo: Double;
|
||||
ABurn: Boolean; const ASegFrom: Double);
|
||||
var
|
||||
Piece: TFlatSegment;
|
||||
Shift: Double;
|
||||
L: Integer;
|
||||
begin
|
||||
if ATo <= AFrom + 0.0005 then
|
||||
Exit;
|
||||
|
||||
Piece := ASeg;
|
||||
Shift := AFrom - ASegFrom;
|
||||
Piece.Duration := ATo - AFrom;
|
||||
Piece.SrcStart := ASeg.SrcStart + Shift;
|
||||
for L := 0 to High(Piece.Layers) do
|
||||
Piece.Layers[L].SrcStart := ASeg.Layers[L].SrcStart + Shift;
|
||||
|
||||
{ Часть, начавшаяся не с начала куска, начинается в произвольном
|
||||
месте потока. Копировать её с этого места нельзя: копия пойдёт от
|
||||
ближайшего опорного кадра, и картинка сдвинется. Поэтому помечаем
|
||||
начало точным — сборка перекодирует огрызок до опорного кадра, а
|
||||
дальше снова копирует. }
|
||||
if Shift > 0.0005 then
|
||||
Piece.PreciseIn := True;
|
||||
|
||||
if ABurn then
|
||||
begin
|
||||
Path := IncludeTrailingPathDelimiter(ACacheDir) +
|
||||
Format('burn_%.3d_%.2d.ass', [I, K]);
|
||||
Inc(K);
|
||||
if WriteAssForRange(AProject, AFrom, ATo, Path) then
|
||||
begin
|
||||
{ Кусок, который и так пересобирался, дороже не становится. }
|
||||
if not (Piece.Transcode or (Length(Piece.Layers) > 0)) then
|
||||
Result := Result + Piece.Duration;
|
||||
Piece.SubFile := Path;
|
||||
Piece.Transcode := True;
|
||||
end;
|
||||
end;
|
||||
|
||||
if N >= Length(Res) then
|
||||
SetLength(Res, N + 16);
|
||||
Res[N] := Piece;
|
||||
Inc(N);
|
||||
end;
|
||||
|
||||
begin
|
||||
Result := 0;
|
||||
if (AProject = nil) or (not HasText(AProject)) then
|
||||
Exit;
|
||||
|
||||
SetLength(Res, Length(ASegments) + 16);
|
||||
N := 0;
|
||||
At := AStartTime;
|
||||
|
||||
for I := 0 to High(ASegments) do
|
||||
begin
|
||||
SegEnd := At + ASegments[I].Duration;
|
||||
if TextCovers(AProject, At, SegEnd) then
|
||||
CollectTextRanges(AProject, At, SegEnd, Starts, Ends, RangeCount);
|
||||
|
||||
if RangeCount = 0 then
|
||||
begin
|
||||
Path := IncludeTrailingPathDelimiter(ACacheDir) +
|
||||
Format('burn_%.3d.ass', [I]);
|
||||
if WriteAssForRange(AProject, At, SegEnd, Path) then
|
||||
begin
|
||||
{ Кусок, который и так пересобирался, дороже не становится —
|
||||
считаем только те, что раньше копировались. }
|
||||
if not (ASegments[I].Transcode or (Length(ASegments[I].Layers) > 0)) then
|
||||
Result := Result + ASegments[I].Duration;
|
||||
ASegments[I].SubFile := Path;
|
||||
ASegments[I].Transcode := True;
|
||||
end;
|
||||
if N >= Length(Res) then
|
||||
SetLength(Res, N + 16);
|
||||
Res[N] := ASegments[I];
|
||||
Inc(N);
|
||||
At := SegEnd;
|
||||
Continue;
|
||||
end;
|
||||
|
||||
{ Кусок режется на части: до надписи, под надписью, после. Пересобрать
|
||||
придётся только середину.
|
||||
|
||||
Раньше пересобирался весь кусок целиком, и надпись на тридцать
|
||||
секунд внутри сорокаминутного куска заставляла пересобирать сорок
|
||||
минут — половину ролика. }
|
||||
K := 0;
|
||||
Cursor := At;
|
||||
PrevBurned := False;
|
||||
for J := 0 to RangeCount - 1 do
|
||||
begin
|
||||
{ Обрезок короче секунды копировать незачем: возни с опорными
|
||||
кадрами больше, чем выгоды. Отдаём его вжиганию. }
|
||||
if Starts[J] - Cursor > MinCopyPiece then
|
||||
begin
|
||||
PushPiece(ASegments[I], Cursor, Starts[J], False, At);
|
||||
Cursor := Starts[J];
|
||||
end;
|
||||
PushPiece(ASegments[I], Cursor, Ends[J], True, At);
|
||||
Cursor := Ends[J];
|
||||
PrevBurned := True;
|
||||
end;
|
||||
|
||||
if SegEnd - Cursor > MinCopyPiece then
|
||||
PushPiece(ASegments[I], Cursor, SegEnd, False, At)
|
||||
else if SegEnd > Cursor + 0.0005 then
|
||||
{ Хвост короче секунды — дожигаем вместе с надписью, чтобы не
|
||||
плодить огрызки. }
|
||||
PushPiece(ASegments[I], Cursor, SegEnd, PrevBurned, At);
|
||||
|
||||
At := SegEnd;
|
||||
end;
|
||||
|
||||
SetLength(Res, N);
|
||||
ASegments := Res;
|
||||
end;
|
||||
|
||||
function WriteAssFile(AProject: TProject; const APath: string): Boolean;
|
||||
|
|
|
|||
|
|
@ -282,7 +282,9 @@ type
|
|||
procedure CollectGroupClips(AClip: TClip);
|
||||
procedure ApplyGroupMove(const ADelta: Double);
|
||||
{ Сдвинуть клипы влево, закрыв дыру под курсором. }
|
||||
procedure RippleCloseGap;
|
||||
{ True — дыра нашлась и схлопнута. False — схлопывать было нечего:
|
||||
окно скажет об этом, чтобы команда не выглядела сломанной. }
|
||||
function RippleCloseGap: Boolean;
|
||||
procedure SelectAll;
|
||||
|
||||
{ Номер дорожки под указателем мыши, минус один если ни одной. }
|
||||
|
|
@ -294,6 +296,8 @@ type
|
|||
{ Куда встанет курсор, если поставить его в это время. Открыто, чтобы
|
||||
расчёт можно было проверить числами, не гоняя мышь по окну. }
|
||||
function SnapPlayhead(const ATime: Double): Double;
|
||||
{ Округление к кадровой сетке проекта: между кадрами времени нет. }
|
||||
function QuantizeTime(const ATime: Double): Double;
|
||||
|
||||
property Project: TProject read FProject write FProject;
|
||||
{ Усиление формы звука. Меняется по Shift со стрелками. }
|
||||
|
|
@ -1685,6 +1689,27 @@ begin
|
|||
end;
|
||||
|
||||
{ Примагничивание к краям соседних клипов и к курсору воспроизведения. }
|
||||
{ Округление к кадровой сетке проекта.
|
||||
|
||||
Между кадрами времени не существует: клип, поставленный на 23
|
||||
микросекунды дальше соседа, — это не «почти встык», а дыра, под которую
|
||||
программа честно закажет чёрную вставку. Поэтому всё, что ставит мышь,
|
||||
проходит через эту меру.
|
||||
|
||||
Частота кадров бывает неизвестна (пустой проект) — тогда не трогаем. }
|
||||
function TTimelineControl.QuantizeTime(const ATime: Double): Double;
|
||||
var
|
||||
Step: Double;
|
||||
begin
|
||||
Result := ATime;
|
||||
if FProject = nil then
|
||||
Exit;
|
||||
if (FProject.Master.FpsNum <= 0) or (FProject.Master.FpsDen <= 0) then
|
||||
Exit;
|
||||
Step := FProject.Master.FpsDen / FProject.Master.FpsNum;
|
||||
Result := Round(ATime / Step) * Step;
|
||||
end;
|
||||
|
||||
function TTimelineControl.SnapTime(const ATime: Double; AExclude: TClip): Double;
|
||||
var
|
||||
T, C: Integer;
|
||||
|
|
@ -2157,7 +2182,8 @@ begin
|
|||
taDragClip:
|
||||
if FDragClip <> nil then
|
||||
begin
|
||||
NewStart := SnapTime(XToTime(X) - FDragGrabOffset, FDragClip);
|
||||
NewStart := QuantizeTime(SnapTime(XToTime(X) - FDragGrabOffset,
|
||||
FDragClip));
|
||||
FDragClip.TlStart := Max(0, NewStart);
|
||||
{ Связка едет целиком: звук должен остаться под своей картинкой. }
|
||||
ApplyGroupMove(FDragClip.TlStart - FDragOrigStart);
|
||||
|
|
@ -2211,7 +2237,7 @@ begin
|
|||
taTrimIn:
|
||||
if FDragClip <> nil then
|
||||
begin
|
||||
NewStart := SnapTime(XToTime(X), FDragClip);
|
||||
NewStart := QuantizeTime(SnapTime(XToTime(X), FDragClip));
|
||||
{ Правый край стоит на месте, двигается левый: меняются и позиция
|
||||
на монтаже, и то, с какого места берётся материал. }
|
||||
Delta := NewStart - FDragOrigStart;
|
||||
|
|
@ -2236,7 +2262,7 @@ begin
|
|||
taTrimOut:
|
||||
if FDragClip <> nil then
|
||||
begin
|
||||
NewEnd := SnapTime(XToTime(X), FDragClip);
|
||||
NewEnd := QuantizeTime(SnapTime(XToTime(X), FDragClip));
|
||||
Delta := NewEnd - (FDragOrigStart + FDragOrigDuration);
|
||||
|
||||
MediaLen := MediaDurationOf(FDragClip);
|
||||
|
|
@ -2385,7 +2411,8 @@ var
|
|||
I: Integer;
|
||||
begin
|
||||
for I := 0 to High(FRippleClips) do
|
||||
FRippleClips[I].TlStart := Max(0, FRippleOrigStarts[I] + ADelta);
|
||||
FRippleClips[I].TlStart :=
|
||||
Max(0, QuantizeTime(FRippleOrigStarts[I] + ADelta));
|
||||
end;
|
||||
|
||||
procedure TTimelineControl.MouseUp(Button: TMouseButton; Shift: TShiftState;
|
||||
|
|
@ -2826,9 +2853,9 @@ begin
|
|||
FProject.Undo;
|
||||
end;
|
||||
|
||||
procedure TTimelineControl.RippleCloseGap;
|
||||
function TTimelineControl.RippleCloseGap: Boolean;
|
||||
var
|
||||
T, C: Integer;
|
||||
T, C, Source: Integer;
|
||||
Track: TTrack;
|
||||
GapStart, GapEnd, Shift: Double;
|
||||
AnyChange: Boolean;
|
||||
|
|
@ -2856,16 +2883,25 @@ var
|
|||
end;
|
||||
|
||||
begin
|
||||
Result := False;
|
||||
if FProject = nil then
|
||||
Exit;
|
||||
|
||||
{ Дыру ищем на той дорожке, где стоит указатель мыши: под курсором
|
||||
воспроизведения дыры бывают сразу на нескольких, и надо знать, какая
|
||||
имеется в виду. }
|
||||
if (FHoverTrack < 0) or (FHoverTrack >= FProject.TrackCount) then
|
||||
имеется в виду.
|
||||
|
||||
Если указатель не над дорожками — а он там и не обязан быть, руки на
|
||||
клавиатуре, — берём выбранную дорожку. Раньше в этом случае команда
|
||||
молча не делала ничего, и выглядело это как «горячая клавиша не
|
||||
работает». }
|
||||
Source := FHoverTrack;
|
||||
if (Source < 0) or (Source >= FProject.TrackCount) then
|
||||
Source := FActiveTrack;
|
||||
if (Source < 0) or (Source >= FProject.TrackCount) then
|
||||
Exit;
|
||||
|
||||
if not FindGap(FProject.Tracks[FHoverTrack], GapStart, GapEnd) then
|
||||
if not FindGap(FProject.Tracks[Source], GapStart, GapEnd) then
|
||||
Exit;
|
||||
|
||||
Shift := GapEnd - GapStart;
|
||||
|
|
@ -2883,7 +2919,7 @@ begin
|
|||
|
||||
Режим действует и при выключенном автосхлопывании: это осознанная
|
||||
команда, а не побочный эффект перетаскивания. }
|
||||
if (FProject.RippleMode = rmCurrentTrack) and (T <> FHoverTrack) then
|
||||
if (FProject.RippleMode = rmCurrentTrack) and (T <> Source) then
|
||||
Continue;
|
||||
|
||||
Track := FProject.Tracks[T];
|
||||
|
|
@ -2897,7 +2933,10 @@ begin
|
|||
end;
|
||||
|
||||
if AnyChange then
|
||||
StructureChanged
|
||||
begin
|
||||
StructureChanged;
|
||||
Result := True;
|
||||
end
|
||||
else
|
||||
FProject.Undo;
|
||||
end;
|
||||
|
|
|
|||
|
|
@ -247,6 +247,133 @@ begin
|
|||
end;
|
||||
end;
|
||||
|
||||
{ Схлопывание дыры без мыши.
|
||||
|
||||
Ctrl+Shift+R искал дыру только на дорожке под указателем мыши, а руки в
|
||||
этот момент на клавиатуре. Со стороны выглядело как «горячая клавиша не
|
||||
работает»: команда молча ничего не делала. }
|
||||
procedure CheckRippleWithoutMouse;
|
||||
var
|
||||
Proj: TProject;
|
||||
Info: TMediaInfo;
|
||||
Media: TMediaItem;
|
||||
Timeline: TTimelineControl;
|
||||
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;
|
||||
Timeline := TTimelineControl.Create(nil);
|
||||
try
|
||||
Proj.Clear;
|
||||
Proj.AddTrack('V1');
|
||||
Media := Proj.AddMedia('/несуществующий/видео.mp4', Info);
|
||||
Proj.Master := Info;
|
||||
{ Дыра с 5 по 12 секунду. }
|
||||
Proj.AddClip(Proj.Tracks[0], Media, 0, 5, 0);
|
||||
Proj.AddClip(Proj.Tracks[0], Media, 20, 5, 12);
|
||||
|
||||
Timeline.Project := Proj;
|
||||
Timeline.PixelsPerSecond := 100;
|
||||
Timeline.ActiveTrack := 0;
|
||||
Timeline.Playhead := 8;
|
||||
|
||||
{ Мышь не над дорожками: так и бывает, когда жмут сочетание клавиш. }
|
||||
CheckTrue('дыра схлопнута без мыши', Timeline.RippleCloseGap);
|
||||
CheckTrue('второй клип придвинут к первому',
|
||||
Abs(Proj.Tracks[0][1].TlStart - 5) < 0.001);
|
||||
{ Второй раз схлопывать нечего — и об этом надо сказать, а не молчать. }
|
||||
CheckTrue('повторный вызов честно отвечает «нечего»',
|
||||
not Timeline.RippleCloseGap);
|
||||
finally
|
||||
Timeline.Free;
|
||||
Proj.Free;
|
||||
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;
|
||||
|
|
@ -275,6 +402,8 @@ begin
|
|||
|
||||
CheckPlayheadSnap;
|
||||
CheckSplitKeepsSettings;
|
||||
CheckRippleWithoutMouse;
|
||||
CheckClipCopy;
|
||||
|
||||
WriteLn;
|
||||
if Failed = 0 then
|
||||
|
|
|
|||
|
|
@ -567,6 +567,7 @@ var
|
|||
Clip: TClip;
|
||||
Key: TColorKey;
|
||||
Tr: TMediaTransform;
|
||||
I, N: Integer;
|
||||
TempFile, Err: string;
|
||||
begin
|
||||
WriteLn('Проверка уплощения монтажа');
|
||||
|
|
@ -913,6 +914,63 @@ begin
|
|||
end;
|
||||
WriteLn;
|
||||
|
||||
{ --- 13. Длинная дыра набирается повторами короткой вставки --- }
|
||||
WriteLn(' 13. Дыра в сорок минут');
|
||||
Proj := MakeProject(1, Media);
|
||||
try
|
||||
Proj.Master := Media.Info;
|
||||
{ Два клипа, между ними сорок минут пустоты. }
|
||||
Proj.AddClip(Proj.Tracks[0], Media, 0, 5, 0);
|
||||
Proj.AddClip(Proj.Tracks[0], Media, 10, 5, 2405);
|
||||
FlattenStream(Proj, tkVideo, 0, Segs, Missing);
|
||||
|
||||
N := 0;
|
||||
for I := 0 to High(Segs) do
|
||||
if Segs[I].Kind = skBlack then
|
||||
Inc(N);
|
||||
Check(N = 40, 'дыра разложена на сорок минутных кусков: ' + IntToStr(N));
|
||||
|
||||
{ Файл нужен ровно один: все куски берут один и тот же. }
|
||||
TempFile := '';
|
||||
N := 0;
|
||||
for I := 0 to High(Missing) do
|
||||
if Missing[I].TargetPath <> TempFile then
|
||||
begin
|
||||
TempFile := Missing[I].TargetPath;
|
||||
Inc(N);
|
||||
end;
|
||||
Check(N = 1, 'а файл заказан один: ' + IntToStr(N));
|
||||
Check(Pos('black_60000', ExtractFileName(TempFile)) > 0,
|
||||
'минутный: ' + ExtractFileName(TempFile));
|
||||
finally
|
||||
Proj.Free;
|
||||
end;
|
||||
WriteLn;
|
||||
|
||||
{ --- 14. Зазор в микросекунды — не дыра --- }
|
||||
WriteLn(' 14. Зазор меньше кадра');
|
||||
Proj := MakeProject(1, Media);
|
||||
try
|
||||
Proj.Master := Media.Info;
|
||||
{ Так выглядят клипы, сдвинутые встык мышью: между ними остаются
|
||||
микросекунды. Чёрную вставку туда заказывать нельзя. }
|
||||
Proj.AddClip(Proj.Tracks[0], Media, 0, 5, 0);
|
||||
Proj.AddClip(Proj.Tracks[0], Media, 10, 5, 5.000023);
|
||||
FlattenStream(Proj, tkVideo, 0, Segs, Missing);
|
||||
DumpSegments(Segs);
|
||||
|
||||
N := 0;
|
||||
for I := 0 to High(Segs) do
|
||||
if Segs[I].Kind = skBlack then
|
||||
Inc(N);
|
||||
Check(N = 0, 'чёрных кусков нет: ' + IntToStr(N));
|
||||
Check(Length(Missing) = 0, 'и вставок не заказано: ' +
|
||||
IntToStr(Length(Missing)));
|
||||
finally
|
||||
Proj.Free;
|
||||
end;
|
||||
WriteLn;
|
||||
|
||||
WriteLn;
|
||||
if FailCount = 0 then
|
||||
begin
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue