Compare commits

...

4 commits

Author SHA1 Message Date
8258de7145 Логи сессии и бэклог: прокрутка, копирование клипов, непроверенная отмена 2026-09-11 21:19:43 +03:00
ab719dfd1e Копирование клипов: Ctrl+C и Ctrl+V
Отбивку или текстовый блок проще размножить копией, чем заводить заново
через меню и настраивать оформление с нуля.

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

Дорожка выбирается сама: сначала та, откуда копировали, если это время на
ней свободно; потом любая подходящая по виду; в крайнем случае заводится
новая. Наложить копию на соседа по дорожке нельзя — это означало бы
переход, которого никто не заказывал.

В модели появился TClip.CopyAllFrom: полная копия, кроме места на
монтаже. Проверка в lfmcheck перебирает перенесённые свойства и отдельно
убеждается, что место и связка не переносятся.
2026-09-11 21:14:52 +03:00
407eb02f2d Прокрутка во время воспроизведения больше не отскакивает
При выключенном «курсоре посередине» окно всё равно подтягивалось к
курсору, стоило тому выйти за край видимой части. Со стороны это выглядело
так: Shift с колесом, первый поворот прокручивает, второй возвращает
обратно. Человек отматывает осознанно, и возвращать его силой нельзя.

Теперь выключенный переключатель означает именно неподвижное окно.
Подтягивание при пуске воспроизведения осталось: там курсор надо привести
в поле зрения.

Проверено на невидимом экране при идущем воспроизведении: оба поворота
колеса двигают линейку (1651 и 1581 изменившихся точек), и к исходному
виду она не возвращается.
2026-09-11 20:44:32 +03:00
939184fce7 Чёрные вставки стоят копейки, текст режет кусок
Дыра в сорок минут требовала двухчасового чёрного файла: лестница готовых
длин доходила до восьми часов, и NVENC кодировал такое минутами. Отсюда
«Делаю чёрную вставку 1 из 3», которое не кончается. Теперь длины две, 15
и 60 секунд, длинная дыра набирается повторами минутного файла, а само
чёрное кодируется мегабитом вместо полусотни.

Промежуток короче половины кадра дырой больше не считается: между
клипами, сдвинутыми встык мышью, оставались микросекунды. Мышь при этом
кладёт клипы на кадровую сетку — между кадрами времени не существует.

Правка монтажа прекращает генерацию, ставшую ненужной: раньше закрытая
дыра не отменяла уже начатую работу.

Ctrl+Shift+R работает без мыши: дыра ищется на активной дорожке, если
указатель не над дорожками, и команда отвечает в строке состояния, а не
молчит.

Вжигание текста режет кусок на части и пересобирает только середину.
Надпись в тридцать секунд внутри сорокаминутного куска стоила сорока
минут пересборки. Часть после надписи помечается точным резом, иначе
копия пошла бы от опорного кадра и картинка сдвинулась бы.

Проверки: flattest 13 и 14, схлопывание без мыши в lfmcheck, сборка с
надписью внутри длинного куска — 1.000000 с пересборки вместо 6.
2026-09-11 19:35:26 +03:00
11 changed files with 843 additions and 40 deletions

View file

@ -119,6 +119,15 @@ NVENC, если он есть). Это разрозненно и не даёт
неподъёмным. Пока цифры замеряны только на одной машине, у человека
должна быть возможность выключить любую из этих трат.
## Отмена ненужной генерации не проверена живьём
Правка монтажа прекращает создание вставок, ставших ненужными, — код на
месте и вызывается на каждой пересборке предпросмотра. Но поймать отмену
глазами не на чем: на мелком проверочном материале минутная чёрная
вставка делается мгновенно. Нужен прогон на настоящем материале в
2688x1512: завести дыру, дождаться начала генерации, закрыть дыру и
убедиться по журналу, что ffmpeg остановлен.
## Мелочи, до которых не дошли руки
**Вид дорожки не виден в её заголовке.** Дорожки картинки, звука и текста

View file

@ -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 секунд. Дыра длиннее минуты
набирается повторами одного и того же минутного файла: и список кусков
предпросмотра, и список склейки повторы принимают. Раньше лестница длин
доходила до восьми часов, и дыра в сорок минут требовала двухчасового
чёрного файла — программа выглядела зависшей.
Чёрное кодируется отдельным скромным потоком в мегабит: целиться в
битрейт съёмки там нечему.
Если правка монтажа сделала идущую генерацию ненужной, она прекращается:
программа проверяет, нужна ли ещё та вставка, что делается сейчас.
## Прерванная сборка
Каждый доведённый до конца кусок помечается в списке рядом с самими

View file

@ -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);

View file

@ -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;

View file

@ -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;

View file

@ -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;
{ К началу самого раннего клипа по всем дорожкам. Если монтаж начинается

View file

@ -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

View file

@ -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;

View file

@ -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;

View file

@ -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

View file

@ -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