nlve/src/uMainForm.pas
4aiman 44c0a5de24 Показатель работы: число и полоса показывают одно и то же
Полоса ползла по догадке, а подпись показывала замер — и человек видел
полосу, заполненную на девять десятых, рядом с нулём процентов. Что в
этот момент происходит, по такой паре понять нельзя.

Теперь доля одна на обоих. Берётся она у самого ffmpeg: сборка участка с
затуханием идёт минуту и дольше, а «кусок 1 из 1» всё это время не
меняется, поэтому ffmpeg спрашивают ключом -progress, докуда он дошёл.
Пока он не ответил ни разу, полоса трогается с места на пару процентов и
там стоит — пустая полоса и есть правда о только что начатой работе.

Догадка заодно перестала закрывать промежуток до вехи целиком: предел —
девять десятых, остаток виден глазом. Проверка на это добавлена в
tests/progress.

Полоса в окне сборки была системной, и под gtk2 заполнялась ступенями по
восемь кусков на ширину. Теперь там та же своя полоса, что и внизу
главного окна, — заполняется сплошь. Чтобы она читалась из файла формы,
у неё опубликованы Anchors, Align и Visible: читатель формы берёт только
опубликованные свойства, и без этого окно падало при открытии.

Проверено глазами на невидимом экране: «Собираю наложение 1 из 1 — 2%»
при полосе в 2%, через четыре секунды 69% при полосе в 69%.
2026-10-01 15:50:10 +03:00

5212 lines
210 KiB
ObjectPascal
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{
uMainForm — главное окно редактора.
Собирает вместе всё остальное: список файлов проекта, монтажный стол,
предпросмотр через mpv и сборку через ffmpeg.
Как связаны монтаж и предпросмотр. После каждой правки монтаж
раскладывается в плоский список кусков, из него собирается строка EDL,
и она отдаётся плееру. Пересборка отложена на несколько десятых секунды:
иначе при перетаскивании клипа плеер дёргался бы на каждое движение мыши.
Время на монтаже и время в предпросмотре совпадают в точности, потому
что список кусков покрывает отрезок от нуля до конца монтажа без
пропусков — дыры превращаются в чёрные вставки. Благодаря этому курсор
можно двигать в обе стороны: щелчок по линейке перематывает плеер, а
воспроизведение двигает курсор.
}
unit uMainForm;
{$mode objfpc}{$H+}
interface
uses
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,
uRemuxForm, uMismatchForm,
uFfmpegJob, uSettings, uThumbs, uTransform, uPanCropForm,
uProjectInfoForm, ImgList, uTheme, uLogView, uDepsForm, uWaveform,
uIconData, uSubtitles, uTextMeasure, uTextForm, uExportForm,
uColorKeyForm;
const
{ Номера системных сообщений о перерисовке рамки окна. Берём числами, а
не из модуля Windows: тот переопределяет TRect и Point, и обычные
вызовы LCL начинают разбираться как системные. }
MsgNcPaint = $0085;
MsgNcActivate = $0086;
type
{ TMainForm }
TMainForm = class(TForm)
btnSnap: TThemedButton;
btnGlue: TThemedButton;
btnCenter: TThemedButton;
btnRipple: TThemedButton;
btnRippleMenu: TThemedButton;
btnSelectMode: TThemedButton;
lblPoolInfo: TLabel;
lblStatus: TLabel;
lblTime: TLabel;
lstPool: TListBox;
memoLog: TLogView;
OpenMediaDialog: TOpenDialog;
OpenProjectDialog: TOpenDialog;
pnlBottom: TPanel;
pnlLeft: TPanel;
pnlMain: TPanel;
pnlTimelineHost: TPanel;
pnlVideo: TPanel;
SaveExportDialog: TSaveDialog;
SaveProjectDialog: TSaveDialog;
splitVideo: TSplitter;
procedure btnSnapClick(Sender: TObject);
procedure btnGlueClick(Sender: TObject);
procedure btnCenterClick(Sender: TObject);
procedure btnRippleClick(Sender: TObject);
procedure btnRippleMenuClick(Sender: TObject);
procedure btnSelectModeClick(Sender: TObject);
procedure FormCloseQuery(Sender: TObject; var CanClose: Boolean);
procedure FormCreate(Sender: TObject);
procedure FormDestroy(Sender: TObject);
procedure FormKeyDown(Sender: TObject; var Key: Word; Shift: TShiftState);
procedure FormShow(Sender: TObject);
{$IFDEF LCLWIN32}
{ Остаток полосы меню закрашивается после того, как система нарисовала
рамку окна. Раньше — бесполезно: она закрасит поверх. }
procedure WMNCPaint(var Msg: TLMessage); message MsgNcPaint;
procedure WMNCActivate(var Msg: TLMessage); message MsgNcActivate;
{$ENDIF}
procedure lstPoolClick(Sender: TObject);
procedure lstPoolMouseDown(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
procedure lstPoolDrawItem(Control: TWinControl; Index: Integer;
ARect: TRect; State: TOwnerDrawState);
private
FProject: TProject;
FTimeline: TTimelineControl;
FPlayer: TMpvPlayer;
FStarted: Boolean;
FPreviewTimer: TTimer;
{ Автосохранение: копия проекта пишется сама собой, пока человек
работает. Нужна ровно на один случай — работа пропала, а сохранить
её забыли или сохранение не дошло до файла. }
FAutoSaveTimer: TTimer;
FPreviewDirty: Boolean;
FUpdatingFromPlayer: Boolean;
FLastLoadedEdl: string;
FLastLoadedAudioEdl: string;
{ Наложение для плеера: список кусков оверлея и окна показа. }
{ Журнал текущей сборки: файл рядом с готовым роликом. }
FExportLogPath: string;
{ Разметка текста, отданная плееру в прошлый раз. }
FLastAss: string;
{ Клип, с которого скопированы свойства. Хранится копией, а не
ссылкой: исходный клип могут удалить, а свойства должны остаться. }
FLookClip: TClip;
{ Скопированные клипы: сами копии, имя дорожки каждого, смещение от
начала выделения и прежняя связка. Хранятся копиями по той же
причине — исходные могут исчезнуть. }
FClipboard: TObjectList;
FClipTrackNames: array of string;
FClipOffsets: array of Double;
FClipGroups: array of string;
{ Проект открыт из командной строки: спросить про клипы на чужих
дорожках можно только когда окно уже показано. }
FCheckMisplacedOnShow: Boolean;
FPreviewOverlayUrl: string;
FPreviewOverlayWindows: TOverlayWindows;
{ Списки кусков звуковых дорожек для плеера. }
FPreviewAudioUrls: array of string;
{ Звуковые дорожки предпросмотра — их номера в проекте, а не сами
объекты.
Указатели здесь были ловушкой: отмена и открытие проекта
пересоздают все дорожки, а этот список продолжал показывать на
освобождённую память. Обращение к нему идёт на каждое обновление
времени при воспроизведении, то есть десятки раз в секунду. }
FPreviewAudioTracks: array of string;
FLastHwdec: string;
FLastAudioNote: string;
FLogFilePath: string;
FLogFileStarted: Boolean;
FLogFileBroken: Boolean;
FMenu: TMainMenu;
{ Отложенный показ списка меню. Номер списка и точка, где его
раскрыть; таймер срабатывает один раз. }
{ Показ фоновой работы. Пока что-то считается в стороне от окна,
здесь видно, что именно и сколько осталось. }
FJobLabel: TLabel;
FJobBar: TThemedProgress;
FJobTimer: TTimer;
{ Что выбрали в окне сборки в прошлый раз. Держится между вызовами,
чтобы не задавать один и тот же вопрос заново. }
FExportChoice: TExportChoice;
{ Сколько внутренних ошибок уже случилось. Окно показываем только на
первую. }
FErrorCount: Integer;
FLogButton: TThemedButton;
{ Пункт «Ключ и прозрачность в предпросмотре»: галку в нём приходится
держать за руку — она не следует за настройкой сама. }
FKeyPreviewItem: TMenuItem;
FSelectModeItem: TMenuItem;
{ Показанная доля выполнения. Отдельно от настоящей, потому что растёт
она по своим правилам — см. AdvanceJobBar. }
FJobShown: Double;
{ Настоящая доля и доля следующей вехи. Между вехами полоска ползёт
сама, замедляясь. }
FJobReal: Double;
FJobNext: Double;
{ Какая именно работа идёт. При смене занятия полоска начинается
заново: иначе новая работа унаследовала бы почти полную полосу от
предыдущей и стояла бы на месте до самого конца. }
FJobKind: Integer;
{ Уплощённый монтаж, отданный плееру. Хранится, чтобы по времени
находить звучащий кусок и брать у него громкость. }
FPreviewSegments: TFlatSegmentArray;
FLastVolumeSent: Double;
FLastVolumeKey: string;
{ Что сейчас показано обрезкой на выводе: лишний раз её не трогаем. }
FLastCropKey: string;
FGenerate: TGenerateThread;
FGenerateBusy: Boolean;
FExport: TExportThread;
FExportUi: TProgressForm;
FNormalize: TFfmpegJob;
FNormalizeUi: TProgressForm;
FSettings: TSettings;
FThumbs: TImageList;
FThumbThread: TThumbThread;
FPeaksThread: TPeaksThread;
FPeaksRestart: Boolean;
FPeaksFinished: Boolean;
{ Соответствие «путь к файлу -> номер картинки в списке изображений».
По пути, а не по номеру в списке проекта: пока миниатюра делается,
файлы могут добавиться или пропасть. Номер хранится увеличенным на
единицу, потому что ноль в Objects неотличим от «ничего нет». }
FThumbMap: TStringList;
{ Меню библиотеки и меню выбора режима схлопывания. }
FPoolMenu: TPopupMenu;
FPoolAddMenu: TMenuItem;
FRippleMenu: TPopupMenu;
{ Меню дорожки по правому щелчку в её заголовке. }
FTrackMenu: TPopupMenu;
{ Дорожка, для которой открыто меню. }
FTrackMenuIndex: Integer;
{ Пункт «Недавние проекты» в меню «Файл». Его содержимое собирается
заново при каждом изменении списка. }
FRecentMenu: TMenuItem;
{ Пока плеер не доехал до запрошенного места, его сообщения о позиции
игнорируются. Без этого перезагрузка предпросмотра после правки
сбрасывала бы курсор в начало. }
FSeekPending: Boolean;
FSeekTarget: Double;
{ Сколько сообщений о позиции ждём прибытия, прежде чем сдаться.
Без этого ограничения признак «ждём» мог зависнуть навсегда: если
после правки монтажа запрошенное время оказалось за его концом,
плеер туда никогда не доедет, и курсор переставал двигаться
совсем. }
FSeekWaitTicks: Integer;
{ До какого времени ответ на команду держится в строке состояния. }
FStatusHoldUntil: TDateTime;
{ Автосохранение не удалось — жалуемся один раз, а не каждые полминуты. }
FAutoSaveBroken: Boolean;
{ Что было в строке состояния до того, как её занял рассказ о кнопке
под указателем, и чья именно это подсказка. Кнопка нужна затем, что
при переходе с одной на соседнюю события приходят вперемешку: уход
со старой может прийти после прихода на новую, и без проверки он
затёр бы свежую подсказку. }
FStatusBeforeHint: string;
FHintControl: TControl;
procedure ApplyVolumeAt(const ATime: Double);
{ Показывает кадрирование клипа, который виден сейчас. }
procedure ApplyCropAt(const ATime: Double);
{ Переписывает файл субтитров и отдаёт его плееру. }
procedure UpdateSubtitles;
procedure UpdateRippleButton;
procedure CheckMisplacedClips;
procedure BuildTrackMenu;
procedure TimelineTrackMenu(Sender: TObject; ATrackIndex: Integer;
const AScreenPos: TPoint);
procedure TimelineRegionChange(Sender: TObject);
procedure TrackMenuDelete(Sender: TObject);
procedure TrackMenuRename(Sender: TObject);
procedure TimelineTrackRename(Sender: TObject; ATrackIndex: Integer);
procedure TrackMenuUp(Sender: TObject);
procedure TrackMenuDown(Sender: TObject);
procedure UpdateSnapButton;
procedure UpdateGlueButton;
procedure UpdateSelectModeButton;
{ Подсказки нижних кнопок показываются строкой состояния, а не
всплывающим окном. }
procedure BarButtonEnter(Sender: TObject);
procedure BarButtonLeave(Sender: TObject);
procedure UseStatusHints(const AButtons: array of TThemedButton);
{ Перечитывает подсказку кнопки, над которой стоит указатель. }
procedure RefreshBarHint;
{ Ширина строки состояния: до показателя работы или до края панели. }
procedure UpdateStatusWidth;
procedure TimelineSelectModeChange(Sender: TObject);
procedure UpdateCenterButton;
procedure ToggleCenterPlayhead;
procedure RippleModeChosen(Sender: TObject);
procedure ToggleRipple;
procedure BuildPoolMenu;
procedure RefreshRecentMenu;
procedure OpenRecentProject(Sender: TObject);
function OpenProjectFile(const APath: string): Boolean;
procedure PoolMenuPopup(Sender: TObject);
procedure PoolAddToTrack(Sender: TObject);
procedure PoolRemoveFromTracks(Sender: TObject);
procedure PoolRemoveFromProject(Sender: TObject);
procedure PoolEditTransform(Sender: TObject);
procedure TimelineClipTransform(Sender: TObject; AClip: TClip);
procedure MenuProjectInfo(Sender: TObject);
procedure MenuRepairProject(Sender: TObject);
procedure FormFilesDropped(Sender: TObject; const FileNames: array of string);
function ThumbIndexFor(const APath: string): Integer;
procedure ApplySettings;
procedure CollectSettings;
procedure StartThumbs;
procedure StartPeaks;
procedure PeaksReady(Sender: TObject; const ASourcePath: string);
procedure PeaksDone(Sender: TObject);
procedure ThumbReady(Sender: TObject; const ASourcePath,
AThumbPath: string);
procedure SeekPlayerTo(const ATime: Double);
procedure TogglePlayback;
procedure TimelineMediaDrop(Sender: TObject; ASource: TObject;
ATrackIndex: Integer; const ATime: Double);
procedure TimelineTrackAdded(Sender: TObject);
procedure TimelineActiveTrackChange(Sender: TObject);
function AddClipFromPool(APoolIndex, ATrackIndex: Integer;
const ATime: Double): Boolean;
{ --- окно и меню --- }
procedure BuildMenu;
{ Разбор внутренней ошибки: пишет в журнал место, где она случилась. }
procedure HandleException(Sender: TObject; E: Exception);
procedure ApplyWindowIcon;
procedure BuildJobIndicator;
procedure JobTimerTick(Sender: TObject);
procedure AdvanceJobBar;
procedure LogButtonClick(Sender: TObject);
procedure MenuToggleLog(Sender: TObject);
procedure MenuTogglePreviewKey(Sender: TObject);
procedure MenuAbout(Sender: TObject);
procedure MenuAddText(Sender: TObject);
{ Открывает редактор текста для клипа. True — что-то изменилось. }
function EditTextOf(AClip: TClip): Boolean;
procedure ApplyLogVisible(AVisible: Boolean);
procedure BuildTimeline;
procedure StartPlayer;
{ Чего не хватает для работы. Пустая строка — всё на месте. }
function MissingDeps: string;
procedure RemindAboutDeps;
procedure Log(const AText: string);
procedure LogToFile(const AText: string);
{ Урезает файл журнала, когда он перерос предел. }
procedure TrimLogFile;
{ AAnswer — это ответ на команду человека, а не проходное сообщение:
такой ответ держится в строке несколько секунд. }
procedure SetStatus(const AText: string; AAnswer: Boolean = False);
{ Автосохранение: копия рядом с проектом. }
function AutoSavePath: string;
procedure DropAutoSave;
procedure AutoSaveTick(Sender: TObject);
procedure OfferAutoSave;
procedure UpdateCaption;
procedure UpdateTimeLabel;
procedure RefreshPool;
{ --- предпросмотр --- }
procedure PrewarmInserts;
procedure SchedulePreview;
procedure PreviewTimerTick(Sender: TObject);
{ Есть ли на монтаже файл другого соотношения сторон. Только ради
таких файлов и нужен фильтр в предпросмотре. }
function PreviewNeedsFitting: Boolean;
{ Заставляет предпросмотр показывать кадр проекта, а не кадр исходника. }
procedure ApplyPreviewFrame;
procedure RebuildPreview;
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);
{ --- обработчики компонентов --- }
procedure TimelinePlayheadChange(Sender: TObject);
procedure TimelineStructureChange(Sender: TObject);
procedure TimelineSelectionChange(Sender: TObject);
procedure PlayerLog(Sender: TObject; const APrefix, ALevel, AText: string);
procedure PlayerTimePos(Sender: TObject);
procedure PlayerDuration(Sender: TObject);
procedure PlayerPauseChange(Sender: TObject);
{ --- пункты меню --- }
procedure MenuNewProject(Sender: TObject);
procedure MenuOpenProject(Sender: TObject);
procedure MenuSaveProject(Sender: TObject);
procedure MenuSaveProjectAs(Sender: TObject);
procedure MenuExport(Sender: TObject);
procedure MenuExportEncode(Sender: TObject);
procedure DoExport(const AProfile: TEncodeProfile);
procedure MenuRemux(Sender: TObject);
procedure MenuDeps(Sender: TObject);
procedure MenuQuit(Sender: TObject);
procedure MenuUndo(Sender: TObject);
procedure MenuRedo(Sender: TObject);
procedure MenuSplit(Sender: TObject);
procedure MenuDelete(Sender: TObject);
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);
procedure MenuRegionClear(Sender: TObject);
{ Что и откуда собирать: с учётом отмеченного участка. }
procedure DescribeRegion(out AFrom, ATo: Double);
procedure MenuSelectAll(Sender: TObject);
procedure MenuSelectMode(Sender: TObject);
procedure MenuRipple(Sender: TObject);
procedure MenuToggleRipple(Sender: TObject);
procedure MenuGoStart(Sender: TObject);
procedure MenuGoEnd(Sender: TObject);
procedure MenuZoomFit(Sender: TObject);
procedure MenuZoomIn(Sender: TObject);
procedure MenuZoomOut(Sender: TObject);
procedure MenuAddTrack(Sender: TObject);
procedure MenuRemoveTrack(Sender: TObject);
{ --- вспомогательное --- }
function SaveProjectAs: Boolean;
function DoSaveProject: Boolean;
function ConfirmDiscard: Boolean;
procedure AddMediaFile(const APath: string);
procedure ChooseAndAddFiles(Sender: TObject);
procedure ExportProgress(Sender: TObject);
procedure ExportLog(Sender: TObject);
procedure ExportDone(Sender: TObject);
procedure ExportCancel(Sender: TObject);
procedure ShowExportReport(const AReport: TExportReport);
{ Строка и в общий журнал программы, и в журнал этой сборки. }
procedure LogExport(const AText: string);
{ Предлагает продолжить прерванную сборку с готовых кусков. }
{ Предупреждение о выбранном контейнере. False — человек передумал
собирать. }
function AskContainer(AHasPrecise: Boolean): Boolean;
function AskResume(const AOutputPath: string): Boolean;
procedure NormalizeProgress(Sender: TObject);
procedure NormalizeLog(Sender: TObject);
procedure NormalizeDone(Sender: TObject);
procedure NormalizeCancel(Sender: TObject);
end;
var
MainFormInstance: TMainForm;
implementation
{$R *.lfm}
uses
Math, uProcRunner, uFfmpegCmd;
const
{ Задержка пересборки предпросмотра. Правка монтажа сообщается один раз,
по окончании перетаскивания, поэтому задержка нужна лишь чтобы склеить
несколько правок подряд — и должна быть маленькой: пока она идёт,
предпросмотр играет старую расстановку, а на монтаже уже новая. }
PreviewDebounceMs = 120;
{ Сколько сообщений о позиции ждать прибытия после перемотки.
Сообщения приходят на каждый кадр, так что это доли секунды. }
MaxSeekWaitTicks = 90;
{ Сколько ответ на команду держится в строке состояния, не давая себя
затереть проходным сообщением. Четыре секунды — успеть прочесть. }
StatusHoldTime = 4 / (24 * 60 * 60);
{ Как часто пишется копия проекта для восстановления. Полминуты — это
потолок потерь: больше получаса работы человек в неё не вложит, а
запись проекта стоит миллисекунды. }
AutoSaveIntervalMs = 30 * 1000;
{ Приставка к имени файла проекта у автосохранённой копии. }
AutoSaveSuffix = '.autosave';
{ Предел для файла журнала. Больше двух мегабайт читать всё равно
никто не станет, а место жалко. }
MaxLogFileSize = 2 * 1024 * 1024;
function FormatTimecode(const Seconds: Double): string;
var
Total, H, M, S, Ms: Integer;
begin
if Seconds < 0 then
Exit('--:--:--.---');
Total := Trunc(Seconds);
Ms := Round(Frac(Seconds) * 1000);
if Ms > 999 then
Ms := 999;
H := Total div 3600;
M := (Total mod 3600) div 60;
S := Total mod 60;
Result := Format('%.2d:%.2d:%.2d.%.3d', [H, M, S, Ms]);
end;
{ --- Создание окна --- }
procedure TMainForm.FormCreate(Sender: TObject);
begin
FSettings := TSettings.Create;
FSettings.Load;
FProject := TProject.Create;
FProject.NewProject;
BuildMenu;
BuildTimeline;
ApplySettings;
pnlVideo.Color := clBlack;
memoLog.Clear;
FLogFilePath := ProgramDir + 'nlve.log';
FPlayer := TMpvPlayer.Create(pnlVideo);
FPlayer.OnLog := @PlayerLog;
FPlayer.OnTimePos := @PlayerTimePos;
FPlayer.OnDuration := @PlayerDuration;
FPlayer.OnPauseChange := @PlayerPauseChange;
FPreviewTimer := TTimer.Create(Self);
FPreviewTimer.Enabled := False;
FPreviewTimer.Interval := PreviewDebounceMs;
FPreviewTimer.OnTimer := @PreviewTimerTick;
FAutoSaveTimer := TTimer.Create(Self);
FAutoSaveTimer.Interval := AutoSaveIntervalMs;
FAutoSaveTimer.OnTimer := @AutoSaveTick;
FAutoSaveTimer.Enabled := True;
{ Список изображений держит миниатюры. Размер задан жёстко, и ffmpeg
делает их ровно такими: список изображений в LCL требует одинакового
размера у всех элементов. }
FThumbs := TImageList.Create(Self);
FThumbs.Width := ThumbWidth;
FThumbs.Height := ThumbHeight;
FThumbMap := TStringList.Create;
FThumbMap.Sorted := True;
FThumbMap.Duplicates := dupIgnore;
{ Список рисуется вручную.
Готовый режим значков в LCL под gtk2 работает плохо: там TListView
построен на дереве, и крупные значки он попросту игнорирует. Своя
отрисовка надёжнее и заодно позволяет показать рядом с кадром
название и параметры файла. }
lstPool.Style := lbOwnerDrawFixed;
lstPool.ItemHeight := ThumbHeight + 10;
{ Перетаскивание из списка на монтаж начинается само. }
lstPool.DragMode := dmAutomatic;
BuildPoolMenu;
BuildTrackMenu;
{ Буфер владеет копиями: подлинники могут исчезнуть, а вставлять надо
по-прежнему. }
FClipboard := TObjectList.Create(True);
RefreshRecentMenu;
AllowDropFiles := True;
OnDropFiles := @FormFilesDropped;
RefreshPool;
UpdateRippleButton;
UpdateSnapButton;
UpdateGlueButton;
UpdateSelectModeButton;
UpdateCenterButton;
UpdateCaption;
SetStatus('Готов. Добавьте файлы в проект.');
{ Цвета задаём сами: системная тема может быть светлой, и тогда тёмный
таймлайн оказался бы в белой рамке. Вызов идёт последним — меню и
всплывающие меню строятся выше по ходу, и раньше их не существует. }
ApplyDarkTheme(Self);
end;
{ Каждый шаг закрытия отмечается в журнале.
Под Wine программа зависала при выходе, и по молчаливому журналу
понять, на чём именно, нельзя: последняя запись оставалась от работы, а
не от закрытия. Записи дешёвые, а без них искать пришлось бы наугад. }
procedure TMainForm.FormDestroy(Sender: TObject);
begin
Log('Закрытие: останавливаю предпросмотр.');
FreeAndNil(FLookClip);
FreeAndNil(FClipboard);
FPreviewTimer.Enabled := False;
if Assigned(FThumbThread) then
begin
Log('Закрытие: жду поток миниатюр.');
{ Поток обращается к форме через Synchronize, поэтому дожидаться его
обязательно: иначе он попробует достучаться до уже разрушенного окна. }
FThumbThread.Terminate;
FThumbThread.WaitFor;
FreeAndNil(FThumbThread);
end;
if Assigned(FPeaksThread) then
begin
Log('Закрытие: жду разбор звука.');
{ При закрытии ждать приходится: поток обращается к окну. Ждать
недолго — запущенный ffmpeg прерывается по тому же признаку. }
FPeaksRestart := False;
FPeaksThread.Terminate;
FPeaksThread.WaitFor;
FreeAndNil(FPeaksThread);
end;
FreeAndNil(FThumbMap);
Log('Закрытие: сохраняю настройки.');
CollectSettings;
FSettings.Save;
FreeAndNil(FSettings);
Log('Закрытие: закрываю плеер.');
FreeAndNil(FPlayer);
{ Библиотеку не выгружаем.
Плеер отсоединяется от mpv, не дожидаясь, пока ядро добьёт свои
потоки, — иначе под Wine ожидание не кончалось вовсе. Значит, в
момент выхода эти потоки могут ещё жить внутри библиотеки, и
выгружать её из-под них нельзя. Программа всё равно закрывается,
и система освободит всё сама. }
Log('Закрытие: освобождаю проект.');
FreeAndNil(FProject);
Log('Закрытие завершено.');
{ Сами списки принадлежат форме и уничтожатся с ней; здесь только
перечень ссылок на них. }
end;
procedure TMainForm.ApplySettings;
begin
if (FSettings.WindowLeft >= 0) and (FSettings.WindowTop >= 0) then
begin
Position := poDesigned;
Left := FSettings.WindowLeft;
Top := FSettings.WindowTop;
end;
Width := FSettings.WindowWidth;
Height := FSettings.WindowHeight;
if FSettings.WindowMaximized then
WindowState := wsMaximized;
pnlVideo.Height := FSettings.VideoPanelHeight;
FTimeline.TrackHeight := FSettings.TrackHeight;
FTimeline.WaveGain := FSettings.WaveGain;
{ Режим схлопывания и его состояние переживают перезапуск. }
if FSettings.RippleAllTracks then
FProject.RippleMode := rmAllTracks
else
FProject.RippleMode := rmCurrentTrack;
FProject.RippleEnabled := FSettings.RippleEnabled;
FTimeline.PlayheadSnap := FSettings.PlayheadSnap;
{ Каталоги подставляем в диалоги, чтобы не искать одно и то же место
заново при каждом запуске. }
if FSettings.LastMediaDir <> '' then
OpenMediaDialog.InitialDir := FSettings.LastMediaDir;
if FSettings.LastProjectDir <> '' then
begin
OpenProjectDialog.InitialDir := FSettings.LastProjectDir;
SaveProjectDialog.InitialDir := FSettings.LastProjectDir;
end;
if FSettings.LastExportDir <> '' then
SaveExportDialog.InitialDir := FSettings.LastExportDir;
end;
procedure TMainForm.CollectSettings;
begin
FSettings.WindowMaximized := WindowState = wsMaximized;
{ Развёрнутое окно запоминать по координатам бессмысленно: они не те,
что были до разворачивания. }
if WindowState = wsNormal then
begin
FSettings.WindowLeft := Left;
FSettings.WindowTop := Top;
FSettings.WindowWidth := Width;
FSettings.WindowHeight := Height;
end;
FSettings.VideoPanelHeight := pnlVideo.Height;
FSettings.LogVisible := memoLog.Visible;
FSettings.RippleAllTracks := FProject.RippleMode = rmAllTracks;
FSettings.RippleEnabled := FProject.RippleEnabled;
FSettings.PlayheadSnap := FTimeline.PlayheadSnap;
FSettings.WaveGain := FTimeline.WaveGain;
end;
procedure TMainForm.BuildTimeline;
begin
{ Компонент создаётся кодом, а не кладётся на форму: он свой, в палитре
его нет, а регистрировать пакет ради одного контрола незачем. }
FTimeline := TTimelineControl.Create(Self);
FTimeline.Parent := pnlTimelineHost;
FTimeline.Align := alClient;
FTimeline.Project := FProject;
FTimeline.OnPlayheadChange := @TimelinePlayheadChange;
FTimeline.OnStructureChange := @TimelineStructureChange;
FTimeline.OnSelectionChange := @TimelineSelectionChange;
FTimeline.OnMediaDrop := @TimelineMediaDrop;
FTimeline.OnClipTransform := @TimelineClipTransform;
FTimeline.OnTrackAdded := @TimelineTrackAdded;
FTimeline.OnTrackMenu := @TimelineTrackMenu;
FTimeline.OnTrackRename := @TimelineTrackRename;
FTimeline.OnRegionChange := @TimelineRegionChange;
FTimeline.OnSelectionModeChange := @TimelineSelectModeChange;
FTimeline.OnActiveTrackChange := @TimelineActiveTrackChange;
end;
{ Меню строится кодом: в файле формы оно заняло бы сотню строк описания
вложенных пунктов, где легко ошибиться и трудно что-то найти.
Полоса меню — настоящая, TMainMenu.
Некоторое время она была своя, из обычных кнопок: под Windows системную
полосу нашим цветом не закрасить, SetMenuInfo меняет подложку пунктов, но
не саму полосу. Цена оказалась велика, и вся она в поведении: наведение
на соседнее название не перекидывало на него раскрытый список, не
работал переход по меню с Alt и стрелками. Подражать настоящему меню
своими руками пришлось бы до бесконечности.
Цвет решается иначе, на каждой системе своим способом: под gtk2 строкой
стиля (ApplyMenuStyle), под win32 отрисовкой пунктов своими руками
(ApplyDarkMenu). Остаток полосы правее последнего названия под Windows
по-прежнему рисует система — это известная и принятая уступка. }
procedure TMainForm.BuildMenu;
function AddTop(const ACaption: string): TMenuItem;
begin
Result := TMenuItem.Create(Self);
Result.Caption := ACaption;
FMenu.Items.Add(Result);
end;
{ Возвращает созданный пункт: у пунктов с галкой её потом надо ставить
и снимать, а искать пункт по названию — способ ненадёжный. }
function AddItem(AParent: TMenuItem; const ACaption: string;
AHandler: TNotifyEvent; AShortCut: TShortCut = 0): TMenuItem;
var
Item: TMenuItem;
begin
Item := TMenuItem.Create(Self);
Item.Caption := ACaption;
Item.OnClick := AHandler;
Item.ShortCut := AShortCut;
AParent.Add(Item);
Result := Item;
end;
procedure AddSep(AParent: TMenuItem);
var
Item: TMenuItem;
begin
Item := TMenuItem.Create(Self);
Item.Caption := '-';
AParent.Add(Item);
end;
var
MFile, MEdit, MView, MProject, MHelp: TMenuItem;
begin
{ Подсказки всплывают почти сразу: на кнопке со значком они не
украшение, а единственный способ узнать, что она делает. Держатся
долго — читать там больше, чем одно слово. }
Application.ShowHint := True;
Application.HintPause := 100;
Application.HintHidePause := 15000;
{ Точный замер текста ставится до первой сборки субтитров: без него
размер подбирался бы по грубой оценке. }
InstallTextMeasure;
Application.OnException := @HandleException;
ApplyWindowIcon;
BuildJobIndicator;
FMenu := TMainMenu.Create(Self);
MFile := AddTop('Файл');
AddItem(MFile, 'Новый проект', @MenuNewProject, ShortCut(VK_N, [ssCtrl]));
AddItem(MFile, 'Открыть проект...', @MenuOpenProject, ShortCut(VK_O, [ssCtrl]));
FRecentMenu := TMenuItem.Create(Self);
FRecentMenu.Caption := 'Недавние проекты';
MFile.Add(FRecentMenu);
AddItem(MFile, 'Сохранить проект', @MenuSaveProject, ShortCut(VK_S, [ssCtrl]));
AddItem(MFile, 'Сохранить проект как...', @MenuSaveProjectAs);
AddSep(MFile);
AddItem(MFile, 'Добавить файлы в проект...', @ChooseAndAddFiles,
ShortCut(VK_I, [ssCtrl]));
AddSep(MFile);
AddItem(MFile, 'Собрать ролик...', @MenuExport, ShortCut(VK_E, [ssCtrl]));
AddItem(MFile, 'Собрать с перекодированием...', @MenuExportEncode,
ShortCut(VK_E, [ssCtrl, ssShift]));
AddItem(MFile, 'Перепаковка файлов...', @MenuRemux);
AddSep(MFile);
AddItem(MFile, 'Зависимости...', @MenuDeps);
AddSep(MFile);
AddItem(MFile, 'Выход', @MenuQuit, ShortCut(VK_Q, [ssCtrl]));
MEdit := AddTop('Правка');
AddItem(MEdit, 'Отменить', @MenuUndo, ShortCut(VK_Z, [ssCtrl]));
AddItem(MEdit, 'Повторить', @MenuRedo, ShortCut(VK_Y, [ssCtrl]));
AddSep(MEdit);
{ Вид реза теперь один. Раньше их было два — обычный, который при
сборке притягивался к опорному кадру, и точный. Притянутый рез уводил
картинку от звука, поэтому точным стал всякий, а отдельный пункт
потерял смысл. }
AddItem(MEdit, 'Разрезать по курсору', @MenuSplit, ShortCut(VK_S, []));
AddItem(MEdit, 'Удалить выделенное', @MenuDelete, ShortCut(VK_DELETE, []));
AddItem(MEdit, 'Выделить всё', @MenuSelectAll, ShortCut(VK_A, [ssCtrl]));
{ Пункт держит и подпись режима: по одному значку на кнопке не угадать,
что сейчас делает протяжка мышью. }
FSelectModeItem := AddItem(MEdit, 'Режим выделения', @MenuSelectMode,
ShortCut(VK_D, []));
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,
ShortCut(VK_V, [ssCtrl, ssShift]));
AddSep(MEdit);
AddItem(MEdit, 'Отметить начало участка', @MenuRegionIn, ShortCut(VK_I, []));
AddItem(MEdit, 'Отметить конец участка', @MenuRegionOut, ShortCut(VK_O, []));
AddItem(MEdit, 'Снять отметки участка', @MenuRegionClear);
AddSep(MEdit);
AddItem(MEdit, 'Схлопнуть дыру под курсором', @MenuRipple,
ShortCut(VK_R, [ssCtrl, ssShift]));
AddItem(MEdit, 'Автосхлопывание при перетаскивании', @MenuToggleRipple,
ShortCut(VK_K, [ssCtrl]));
MView := AddTop('Вид');
{ Home и End назначены пунктами меню, а не обрабатываются в компоненте:
так они работают независимо от того, где сейчас фокус. Иначе их
перехватывал бы список файлов, переводя в нём выделение. }
AddItem(MView, 'В начало монтажа', @MenuGoStart, ShortCut(VK_HOME, []));
AddItem(MView, 'В конец монтажа', @MenuGoEnd, ShortCut(VK_END, []));
AddSep(MView);
AddItem(MView, 'Вписать весь проект', @MenuZoomFit, ShortCut(VK_0, [ssCtrl]));
AddItem(MView, 'Приблизить', @MenuZoomIn, ShortCut(VK_ADD, [ssCtrl]));
AddItem(MView, 'Отдалить', @MenuZoomOut, ShortCut(VK_SUBTRACT, [ssCtrl]));
AddSep(MView);
AddItem(MView, 'Журнал сообщений', @MenuToggleLog, ShortCut(VK_L, [ssCtrl]));
AddSep(MView);
FKeyPreviewItem := AddItem(MView, 'Ключ и прозрачность в предпросмотре',
@MenuTogglePreviewKey);
FKeyPreviewItem.AutoCheck := False;
FKeyPreviewItem.Checked := FSettings.PreviewKey;
MProject := AddTop('Проект');
AddItem(MProject, 'Сведения о проекте...', @MenuProjectInfo);
AddItem(MProject, 'Проверить и починить проект...', @MenuRepairProject);
AddSep(MProject);
AddItem(MProject, 'Добавить текст', @MenuAddText, ShortCut(VK_T, [ssCtrl]));
AddSep(MProject);
AddItem(MProject, 'Добавить дорожку', @MenuAddTrack);
AddItem(MProject, 'Удалить последнюю дорожку', @MenuRemoveTrack);
MHelp := AddTop('Справка');
AddItem(MHelp, 'О программе...', @MenuAbout);
{ Дважды, и оба раза нужны.
До присваивания — чтобы обработчики отрисовки стояли к первому показу.
После — чтобы задать подложку полосы: она задаётся через SetMenuInfo по
дескриптору меню, а дескриптор появляется только когда меню отдано
окну. Первый вызов до этого просто пропускал подложку, и справа от
последнего названия оставалась светлая полоса. }
ApplyDarkMenu(FMenu);
Menu := FMenu;
ApplyDarkMenu(FMenu);
end;
{ Показ фоновой работы.
Разбор звука читает файл целиком — на шести гигабайтах это больше
десяти секунд, и всё это время программа выглядит просто задумчивой.
Молчать об этом нельзя: непонятно, идёт ли что-то вообще.
Место справа в нижней полосе отводится сразу и насовсем, а надпись с
полоской показываются только когда работа есть. Так строка состояния и
показатель не наезжают друг на друга. }
{ Внутренняя ошибка не должна оставаться загадкой.
Окно «Access violation» без единого слова о том, где именно, не даёт
ничего: ошибка повторяется на каждый кадр воспроизведения, а найти её
негде. Поэтому мы перехватываем её сами и пишем в журнал разбор стека —
сборка идёт с отладочными сведениями, поэтому в нём видны имена файлов
и номера строк.
Окно показывается только на первую ошибку. Дальше они лишь считаются:
повторяющееся окно поверх воспроизведения невозможно закрыть. }
procedure TMainForm.HandleException(Sender: TObject; E: Exception);
var
Trace: string;
I: Integer;
Frames: PPointer;
begin
Inc(FErrorCount);
Trace := BackTraceStrFunc(ExceptAddr);
Frames := ExceptFrames;
for I := 0 to ExceptFrameCount - 1 do
Trace := Trace + LineEnding + ' ' + BackTraceStrFunc(Frames[I]);
Log(Format('ВНУТРЕННЯЯ ОШИБКА %d: %s: %s', [FErrorCount, E.ClassName,
E.Message]));
Log(' где: ' + Trace);
if FErrorCount = 1 then
ThemedMessageDlg('nlve',
E.ClassName + ': ' + E.Message + LineEnding + LineEnding +
'Разбор записан в журнал (nlve.log рядом с программой). ' +
'Следующие такие же ошибки будут только записываться.',
mtError, [mbOK], 0);
end;
{ Значок окна. Вшит в программу, потому что рядом с ней может ничего не
лежать. Значок самого файла программы под Windows задаётся не здесь, а
ресурсом при сборке — это отдельная история. }
procedure TMainForm.ApplyWindowIcon;
var
Stream: TMemoryStream;
begin
Stream := TMemoryStream.Create;
try
Stream.WriteBuffer(IconAppData[0], Length(IconAppData));
Stream.Position := 0;
try
Icon.LoadFromStream(Stream);
Application.Icon.Assign(Icon);
except
{ Со значком не вышло — работать это не мешает. }
end;
finally
Stream.Free;
end;
end;
procedure TMainForm.BuildJobIndicator;
const
JobWidth = 330;
LogButtonWidth = 28;
{ Журнал стоит после переключателя выделения: сперва то, что меняет
работу с монтажом, потом то, что показывает сообщения. }
LogButtonLeft = 478;
begin
FJobLabel := TLabel.Create(Self);
FJobLabel.Parent := pnlBottom;
FJobLabel.AutoSize := False;
FJobLabel.Alignment := taRightJustify;
FJobLabel.Anchors := [akTop, akRight];
FJobLabel.SetBounds(pnlBottom.ClientWidth - JobWidth - 12, 6,
JobWidth, 16);
FJobLabel.ParentColor := False;
FJobLabel.Font.Color := ThemeTextDim;
FJobLabel.Visible := False;
FJobBar := TThemedProgress.Create(Self);
FJobBar.Parent := pnlBottom;
FJobBar.Anchors := [akTop, akRight];
FJobBar.SetBounds(pnlBottom.ClientWidth - JobWidth - 12, 26, JobWidth, 6);
FJobBar.Visible := False;
{ Переключатель журнала. Стоит рядом с показателем работы: это две
вещи про одно и то же — что программа делает и что она об этом
рассказала. }
FLogButton := TThemedButton.Create(Self);
FLogButton.Parent := pnlBottom;
{ Рядом с прилипанием и схлопыванием: три переключателя читаются как
один набор, а не как случайно разбросанные значки. }
FLogButton.SetBounds(LogButtonLeft, 6, LogButtonWidth, 28);
FLogButton.Icon := tiLog;
{ Фокус переключателям не нужен: по ним щёлкают, а обходить их клавишей
Tab при работе с монтажом только мешает. }
FLogButton.TabStop := False;
FLogButton.Down := True;
FLogButton.OnClick := @LogButtonClick;
FLogButton.Hint := 'Журнал: спрятать или показать полосу с сообщениями';
{ Все кнопки нижней панели рассказывают о себе строкой состояния. }
UseStatusHints([btnSnap, btnGlue, btnCenter, btnRipple, btnRippleMenu,
btnSelectMode, FLogButton]);
UpdateStatusWidth;
{ Сто миллисекунд, а не триста: полоска должна ползти, а не дёргаться
ступеньками. }
FJobTimer := TTimer.Create(Self);
FJobTimer.Interval := 100;
FJobTimer.OnTimer := @JobTimerTick;
FJobTimer.Enabled := True;
end;
{ Как растёт полоска — см. AdvanceProgress в uAppTypes: там же она и
проверяется числами. }
procedure TMainForm.AdvanceJobBar;
const
{ Доля оставшегося до вехи расстояния за один шаг таймера. При ста
миллисекундах на шаг это примерно половина расстояния за секунду. }
StepShare = 0.07;
begin
FJobShown := AdvanceProgress(FJobShown, FJobReal, FJobNext, StepShare);
end;
procedure TMainForm.LogButtonClick(Sender: TObject);
begin
ApplyLogVisible(not memoLog.Visible);
end;
{ Новая надпись.
Кладётся на текстовую дорожку в место курсора. Дорожки нет — заводится:
просить сначала создать дорожку, а потом положить на неё текст значило
бы заставлять человека знать наше устройство. }
procedure TMainForm.MenuAddText(Sender: TObject);
var
Track: TTrack;
Clip: TClip;
I: Integer;
begin
Track := nil;
for I := 0 to FProject.TrackCount - 1 do
if FProject.Tracks[I].Kind = tkText then
begin
Track := FProject.Tracks[I];
Break;
end;
FProject.BeginChange;
if Track = nil then
Track := FProject.AddTrackOfKind(FProject.UniqueTrackName('Т'), tkText);
Clip := TClip.Create;
Clip.Id := FProject.NewId('c');
Clip.Kind := ckText;
Clip.TlStart := Max(0, FTimeline.Playhead);
{ Три секунды — столько держится короткая надпись, чтобы её успели
прочесть. Дальше человек растянет клип как захочет. }
Clip.Duration := 3;
Clip.Text := 'Надпись';
Clip.TextStyle := DefaultTextStyle;
Track.Add(Clip);
Track.Sort;
if not EditTextOf(Clip) then
begin
{ Передумал — клипа быть не должно, иначе на монтаже останется
пустая надпись, которую он не заказывал. }
Track.Remove(Clip);
FProject.CancelChange;
FTimeline.Invalidate;
Exit;
end;
FProject.ClearSelection;
Clip.Selected := True;
TimelineStructureChange(nil);
end;
function TMainForm.EditTextOf(AClip: TClip): Boolean;
var
{ Не Text: так называется свойство самой формы. }
Body: string;
Style: TTextStyle;
W, H: Integer;
begin
Result := False;
if AClip = nil then
Exit;
Body := AClip.Text;
Style := AClip.TextStyle;
W := FProject.Master.Width;
H := FProject.Master.Height;
if (W < 2) or (H < 2) then
begin
W := 1920;
H := 1080;
end;
if not EditTextClip(Body, Style, W, H) then
Exit;
AClip.Text := Body;
AClip.TextStyle := Style;
Result := True;
end;
procedure TMainForm.MenuAbout(Sender: TObject);
begin
ShowAboutForm;
end;
procedure TMainForm.MenuToggleLog(Sender: TObject);
begin
ApplyLogVisible(not memoLog.Visible);
end;
procedure TMainForm.ApplyLogVisible(AVisible: Boolean);
begin
memoLog.Visible := AVisible;
if not Assigned(FLogButton) then
Exit;
FLogButton.Down := AVisible;
if AVisible then
FLogButton.Hint := 'Журнал виден. Нажмите, чтобы спрятать.'
else
FLogButton.Hint := 'Журнал спрятан. Нажмите, чтобы показать.';
RefreshBarHint;
end;
{ Как растёт полоска.
Настоящая доля известна только вехами: файл разобран, вставка готова.
Между вехами не происходит ничего видимого, и полоска, стоящая на месте,
выглядит как зависшая программа.
Поэтому она ползёт сама — к следующей вехе, но никогда её не достигая:
каждый шаг покрывает малую долю оставшегося расстояния. Пока работа идёт
как задумано, веха приходит быстро и полоска прыгает к ней. Если работа
затянулась, шаги становятся всё мельче, и заполнение само собой
замедляется — ровно то поведение, которого ждут от такой полоски.
Назад она не идёт никогда: доля, единожды показанная, остаётся. }
{ Опрос по таймеру, а не уведомления из потоков: поток сообщает о готовом
файле, а не о ходе внутри файла, и на шестигигабайтном файле между
сообщениями проходят десятки секунд. Опрос показывает движение и там. }
procedure TMainForm.JobTimerTick(Sender: TObject);
var
{ Не Text: так называется свойство самой формы, а имена в Паскале
нечувствительны к регистру. }
Note: string;
Frac, Step: Double;
StepKnown: Boolean;
Total, Done, Kind: Integer;
begin
{$IFDEF LCLWIN32}
{ Заодно подновляем закраску остатка полосы меню.
Обработчика перерисовки рамки может не хватить: система трогает
полосу и по другим поводам, а рисуем мы поверх неё. Прямоугольник тот
же и цвет тот же, поэтому подновление не видно. }
PaintMenuBarTail(Self);
{$ENDIF}
Note := '';
Total := 0;
Done := 0;
Kind := 0;
{ Доля сделанного внутри текущего шага и есть ли она вообще. }
Step := 0;
StepKnown := False;
{ Порядок важен: показываем то, чего человек ждёт прямо сейчас.
Сборка вставок держит предпросмотр — до её конца монтаж играет
по-старому. Разбор звука и миниатюры не держат ничего, они лишь
дорисовывают то, что уже видно. }
if FGenerateBusy and Assigned(FGenerate) then
begin
Kind := 1;
Total := FGenerate.Total;
Done := FGenerate.Done;
Step := FGenerate.StepFraction;
StepKnown := FGenerate.StepKnown;
Note := FGenerate.StageText;
if Note = '' then
Note := 'Готовлю участки монтажа';
{ Заодно показываем на монтаже, какой именно участок пересобирается. }
FTimeline.SetBusyRange(FGenerate.BusyFrom, FGenerate.BusyTo);
end
else if Assigned(FPeaksThread) and (not FPeaksFinished) then
begin
Kind := 2;
Total := FPeaksThread.Total;
Done := FPeaksThread.Done;
if Total > 0 then
Note := Format('Разбор звука: %d из %d', [Min(Done + 1, Total), Total]);
end
else if Assigned(FThumbThread) then
begin
Kind := 3;
Total := FThumbThread.Total;
Done := FThumbThread.Done;
if (Total > 0) and (Done < Total) then
Note := Format('Миниатюры: %d из %d', [Min(Done + 1, Total), Total]);
end;
if Note = '' then
begin
if FJobLabel.Visible then
begin
FJobLabel.Visible := False;
FJobBar.Visible := False;
UpdateStatusWidth;
end;
{ Следующая работа начинается с пустой полоски, а не с того места, где
кончилась прошлая. }
FJobShown := 0;
FJobReal := 0;
FJobNext := 0;
FJobKind := 0;
FTimeline.SetBusyRange(0, 0);
Exit;
end;
if Kind <> FJobKind then
begin
FJobKind := Kind;
FJobShown := 0;
end;
if Total > 0 then
begin
FJobReal := (Done + Step) / Total;
if StepKnown then
{ Работа сама говорит, докуда дошла. Догадываться тут нечего и
нельзя: догадка разойдётся с числом, которое мы же и показываем,
и получится полная полоса рядом с нулём процентов. }
FJobNext := FJobReal
else
{ Замера ещё нет — только запустились. Полоса трогается с места,
чтобы было видно, что работа жива, но уходит недалеко: пустая
полоса и есть правда о начале работы. }
FJobNext := FJobReal + 0.05 / Total;
end
else
begin
{ Работа без счёта: ползём к концу, никогда его не достигая. }
FJobReal := 0;
FJobNext := 1;
end;
AdvanceJobBar;
Frac := FJobShown;
{ Число и полоса — одно и то же число.
Раньше полоса показывала догадку, а подпись — замер, и человек видел
полосу, заполненную на девять десятых, рядом с нулём процентов. Что
в этот момент происходит, по такой паре понять нельзя. Теперь
показывается ровно то, что нарисовано. }
FJobLabel.Caption := Note + Format(' — %d%%', [Round(Frac * 100)]);
FJobBar.Fraction := Frac;
if not FJobLabel.Visible then
begin
FJobLabel.Visible := True;
FJobBar.Visible := True;
UpdateStatusWidth;
end;
end;
{$IFDEF LCLWIN32}
procedure TMainForm.WMNCPaint(var Msg: TLMessage);
begin
inherited;
PaintMenuBarTail(Self);
end;
procedure TMainForm.WMNCActivate(var Msg: TLMessage);
begin
inherited;
PaintMenuBarTail(Self);
end;
{$ENDIF}
procedure TMainForm.FormShow(Sender: TObject);
var
I: Integer;
Added: Boolean;
Err: string;
begin
if FStarted then
Exit;
FStarted := True;
{ Журнал прячется здесь, а не при чтении настроек.
Спрятанный до первого показа окна, он не получает своего виджета
вовсе, и вернуть его потом уже нечем: свойство переключается,
а на экране ничего не меняется. Проверено на невидимом экране:
щелчок по кнопке журнала не менял ни пикселя, при том что запись в
журнале показывала Visible=True, высоту 135 и верх 743.
HandleNeeded — на случай, если до этого места дескриптор всё же не
создан: прятать можно только то, что уже существует. }
memoLog.HandleNeeded;
ApplyLogVisible(FSettings.LogVisible);
{ Подложка полосы меню задаётся здесь, а не при создании формы.
Она задаётся через SetMenuInfo по дескриптору меню, а тот появляется
вместе с дескриптором окна — то есть уже после создания формы. Вызов
из BuildMenu поэтому попадал на несозданный дескриптор и молча ничего
не делал: пункты рисовались нашими руками, а полоса правее последнего
названия оставалась светлой. }
ApplyDarkMenu(Menu);
PaintMenuBarTail(Self);
{$IFDEF LCLWIN32}
Log(MenuBarTailNote);
{$ENDIF}
StartPlayer;
RemindAboutDeps;
{ Файлы из командной строки попадают в список проекта. Так на программу
можно перетащить файлы из файлового менеджера.
Файл проекта — особый случай: его надо открыть, а не пытаться
разобрать как видео. }
Added := False;
for I := 1 to ParamCount do
begin
if not FileExists(ParamStr(I)) then
Continue;
if LowerCase(ExtractFileExt(ParamStr(I))) = '.nlve' then
begin
if FProject.LoadFromFile(ExpandFileName(ParamStr(I)), Err) then
begin
Log('Открыт проект: ' + FProject.FileName);
FTimeline.ZoomToFit;
FLastLoadedEdl := '';
RefreshPool;
UpdateRippleButton;
UpdateCaption;
SchedulePreview;
FCheckMisplacedOnShow := True;
end
else
Log('Не удалось открыть проект: ' + Err);
Continue;
end;
AddMediaFile(ExpandFileName(ParamStr(I)));
Added := True;
end;
if Added then
begin
RefreshPool;
UpdateCaption;
if lstPool.Items.Count > 0 then
lstPool.ItemIndex := 0;
end;
{ Спрашивать можно только теперь: окно показано, и вопрос не окажется
за кадром. Автосохранённая копия — первым делом: если прошлая работа
не дошла до файла, человек должен узнать об этом до того, как начнёт
правку поверх старого. }
if FCheckMisplacedOnShow then
begin
FCheckMisplacedOnShow := False;
OfferAutoSave;
CheckMisplacedClips;
end;
end;
{ Проверка не по одному месту, а разом: человеку нужен ответ «чего не
хватает», а не «на чём именно споткнулись первым». }
function TMainForm.MissingDeps: string;
var
Err: string;
begin
Result := '';
if not MpvLibLoaded then
Result := 'libmpv — без неё нет предпросмотра';
if not ToolsAvailable(Err) then
begin
if Result <> '' then
Result := Result + LineEnding;
Result := Result + 'ffmpeg — без него не собрать ролик';
end;
end;
{ Напоминание при запуске.
Программа без ffmpeg и libmpv открывается и даже даёт разложить клипы,
но ничего не покажет и ничего не соберёт. Молча об этом сообщать в
журнал мало: журнал можно и спрятать.
Три кнопки: открыть окно зависимостей, отложить, больше не напоминать.
Последняя запоминается в настройках. }
procedure TMainForm.RemindAboutDeps;
var
Missing: string;
Choice: Integer;
begin
if not FSettings.DepsReminder then
Exit;
Missing := MissingDeps;
if Missing = '' then
Exit;
Choice := ThemedChoiceDlg('nlve',
'Не хватает того, на чём программа работает:' + LineEnding +
LineEnding + Missing + LineEnding + LineEnding +
'Окно «Зависимости» умеет скачать недостающее с официальных сайтов.' +
LineEnding + 'Открыть его сейчас?',
['Открыть', 'Потом', 'Не напоминать']);
case Choice of
0: MenuDeps(nil);
2: FSettings.DepsReminder := False;
end;
end;
procedure TMainForm.StartPlayer;
var
Err: string;
begin
if not LoadMpvLib(Err) then
begin
Log('ОШИБКА: ' + Err);
SetStatus('Предпросмотр недоступен: нет libmpv.');
Exit;
end;
Log('libmpv загружена: ' + MpvLibFileName);
pnlVideo.HandleNeeded;
if not FPlayer.Initialize(Err) then
begin
Log('ОШИБКА: ' + Err);
SetStatus('Предпросмотр недоступен.');
Exit;
end;
Log('Предпросмотр готов.');
if not ToolsAvailable(Err) then
begin
Log('ВНИМАНИЕ: ' + Err);
SetStatus('ffmpeg не найден — сборка и генерация работать не будут.');
end;
end;
{ --- Мелочи интерфейса --- }
{ Урезание журнала до последних строк, когда файл перерос предел.
Читается целиком: предел небольшой, и возиться с чтением с конца ради
пары мегабайт незачем. }
procedure TMainForm.TrimLogFile;
var
L: TStringList;
Keep: Integer;
Found: TSearchRec;
Size: Int64;
begin
if not FileExists(FLogFilePath) then
Exit;
Size := 0;
if FindFirst(FLogFilePath, faAnyFile, Found) = 0 then
begin
Size := Found.Size;
FindClose(Found);
end;
if Size <= MaxLogFileSize then
Exit;
L := TStringList.Create;
try
try
L.LoadFromFile(FLogFilePath);
Keep := L.Count div 2;
while L.Count > Keep do
L.Delete(0);
L.Insert(0, '=== начало журнала урезано ===');
L.SaveToFile(FLogFilePath);
except
{ Не вышло урезать — не беда: журнал не работа, ради которой сюда
пришли. }
end;
finally
L.Free;
end;
end;
{ Дублирование журнала в файл рядом с программой.
Нужно ровно для одного случая: программа падает до того, как окно
успело появиться. Тогда всё, что она о себе рассказала, пропадает
вместе с окном, и причина остаётся неизвестной.
Файл не переписывается при запуске, а дописывается: разбираться обычно
приходится не с тем запуском, который идёт сейчас, а с прошлым, и
затирать его нельзя — второй запуск программы уносил бы единственное
свидетельство. Каждый запуск отделён строкой с датой и временем. Файл
урезается, когда перерастёт предел: бесконечно растущий журнал никому
не нужен.
Неудача записи ничего не значит — каталог может быть закрыт на запись,
и работать это мешать не должно. }
procedure TMainForm.LogToFile(const AText: string);
var
F: TextFile;
begin
if FLogFileBroken then
Exit;
try
if not FLogFileStarted then
begin
TrimLogFile;
FLogFileStarted := True;
LogToFile('');
LogToFile('=== запуск ' + FormatDateTime('dd.mm.yyyy hh:nn:ss', Now) +
' ===');
end;
AssignFile(F, FLogFilePath);
if FileExists(FLogFilePath) then
Append(F)
else
Rewrite(F);
try
WriteLn(F, AText);
finally
CloseFile(F);
end;
except
FLogFileBroken := True;
end;
end;
procedure TMainForm.Log(const AText: string);
begin
memoLog.Add(AText);
LogToFile(AText);
end;
{ Строка состояния — единственное место, где программа отвечает на
команду. Ответ команды нельзя затирать проходным сообщением: пересборка
предпросмотра случается после каждой правки и приходит через долю
секунды после нажатия, так что «Разрезано 3 клипа» человек просто не
успевал прочесть.
Поэтому у ответа на команду есть небольшой срок, в течение которого
проходные сообщения его не трогают. Другой ответ на команду его
заменяет сразу: свежий ответ важнее прежнего. }
procedure TMainForm.SetStatus(const AText: string; AAnswer: Boolean);
begin
if (not AAnswer) and (FStatusHoldUntil > Now) then
Exit;
if AAnswer then
FStatusHoldUntil := Now + StatusHoldTime
else
FStatusHoldUntil := 0;
{ Указатель стоит на кнопке нижней панели, и в строке сейчас её
подсказка. Затирать подсказку нельзя: человек читает её ровно
потому, что навёл мышь. Ответ ложится под подсказку и покажется,
как только указатель уйдёт с кнопки. }
if FHintControl <> nil then
FStatusBeforeHint := AText
else
lblStatus.Caption := AText;
end;
procedure TMainForm.UpdateCaption;
var
{ Не Name и не Position: оба имени заняты свойствами формы. }
ProjectName: string;
begin
if FProject.FileName = '' then
ProjectName := 'без имени'
else
ProjectName := ExtractFileName(FProject.FileName);
if FProject.Modified then
ProjectName := ProjectName + ' *';
Caption := 'nlve — ' + ProjectName;
end;
procedure TMainForm.UpdateTimeLabel;
begin
lblTime.Caption := FormatTimecode(FTimeline.Playhead) + ' / ' +
FormatTimecode(FProject.EndTime);
end;
procedure TMainForm.RefreshPool;
var
I: Integer;
Item: TMediaItem;
begin
lstPool.Items.BeginUpdate;
try
lstPool.Items.Clear;
{ Строки только несут номер: всё, что видно, рисуется вручную по
данным проекта, поэтому дублировать их в списке незачем. }
for I := 0 to FProject.PoolCount - 1 do
lstPool.Items.Add(ExtractFileName(FProject.Pool[I].Path));
finally
lstPool.Items.EndUpdate;
end;
lblPoolInfo.Caption := Format('%d %s в проекте',
[FProject.PoolCount,
Plural(FProject.PoolCount, 'файл', 'файла', 'файлов')]);
StartThumbs;
StartPeaks;
end;
{ Запускает изготовление недостающих миниатюр.
Один поток на всю очередь: миниатюры делаются последовательно, чтобы
не устраивать драку за диск при добавлении десятка файлов сразу. }
function TMainForm.ThumbIndexFor(const APath: string): Integer;
var
Idx: Integer;
begin
Result := -1;
if FThumbMap = nil then
Exit;
Idx := FThumbMap.IndexOf(APath);
if Idx >= 0 then
Result := PtrUInt(FThumbMap.Objects[Idx]) - 1;
end;
procedure TMainForm.StartThumbs;
var
Requests: TThumbRequests = nil;
I, N: Integer;
Item: TMediaItem;
begin
if Assigned(FThumbThread) then
begin
{ Предыдущая очередь ещё идёт — прерываем её: список изменился, и
часть работы могла стать ненужной. }
FThumbThread.Terminate;
FThumbThread.WaitFor;
FreeAndNil(FThumbThread);
end;
if FProject.PoolCount = 0 then
Exit;
SetLength(Requests, FProject.PoolCount);
N := 0;
for I := 0 to FProject.PoolCount - 1 do
begin
Item := FProject.Pool[I];
if ThumbIndexFor(Item.Path) >= 0 then
Continue;
{ Кадр берём из исходника, а не из приведённой копии: копии может ещё
не быть, а миниатюра нужна сразу. Ключом при этом остаётся исходный
путь — он не меняется. }
Requests[N].SourcePath := Item.Path;
Requests[N].TargetPath := ThumbPath(FProject.CacheDir, Item.Path);
Requests[N].Key := Item.Path;
if Item.Info.Kind = mkImage then
Requests[N].AtTime := 0
else
Requests[N].AtTime := ThumbTimeFor(Item.Info.Duration);
Inc(N);
end;
SetLength(Requests, N);
if N = 0 then
Exit;
FThumbThread := TThumbThread.Create(Requests);
FThumbThread.OnReady := @ThumbReady;
FThumbThread.Start;
end;
{ Разбор звука для формы волны. Отдельная очередь от миниатюр: разбор
идёт дольше, и держать миниатюры в ожидании звука незачем. }
procedure TMainForm.StartPeaks;
var
Requests: TPeaksRequests = nil;
I, N: Integer;
Item: TMediaItem;
Dummy: TPeaks;
begin
{ Ждать окончания разбора нельзя: он читает файл целиком, а это
гигабайты и десяток секунд. Ожидание идёт в главном потоке, и всё это
время окно не отвечает — меню не открывается вовсе.
Поэтому просто отмечаем, что список изменился: текущий проход
доработает и запустится заново. }
if Assigned(FPeaksThread) then
begin
if not FPeaksFinished then
begin
FPeaksRestart := True;
Exit;
end;
{ Уже завершился — ожидание мгновенное. }
FPeaksThread.WaitFor;
FreeAndNil(FPeaksThread);
end;
FPeaksRestart := False;
FPeaksFinished := False;
if FProject.PoolCount = 0 then
Exit;
SetLength(Requests, FProject.PoolCount);
N := 0;
for I := 0 to FProject.PoolCount - 1 do
begin
Item := FProject.Pool[I];
if not Item.Info.HasAudio then
Continue;
if CachedPeaks(FProject.CacheDir, Item.EffectivePath, Dummy) then
Continue;
Requests[N].SourcePath := Item.EffectivePath;
Requests[N].TargetPath := PeaksPath(FProject.CacheDir, Item.EffectivePath);
Inc(N);
end;
SetLength(Requests, N);
if N = 0 then
Exit;
FPeaksThread := TPeaksThread.Create(Requests);
FPeaksThread.OnReady := @PeaksReady;
FPeaksThread.OnTerminate := @PeaksDone;
{ Разбор звука не должен мешать работе: он тяжёлый и никуда не спешит. }
FPeaksThread.Priority := tpLowest;
FPeaksThread.Start;
end;
{ Проход разбора закончился. Освобождаем поток и, если за это время
список файлов менялся, начинаем заново. }
procedure TMainForm.PeaksDone(Sender: TObject);
begin
{ Освобождать поток прямо здесь нельзя: он ещё доживает свой цикл.
Отмечаем, что он закончил, а освободит его следующий заход. }
FPeaksFinished := True;
if FPeaksRestart then
StartPeaks;
end;
procedure TMainForm.PeaksReady(Sender: TObject; const ASourcePath: string);
begin
{ Запомненную неудачу надо забыть: пики только что появились. }
ForgetPeaks;
FTimeline.Invalidate;
end;
procedure TMainForm.ThumbReady(Sender: TObject; const ASourcePath,
AThumbPath: string);
var
Png: TPortableNetworkGraphic;
Bmp: TBitmap;
ImgIdx: Integer;
begin
if not FileExists(AThumbPath) then
Exit;
if ThumbIndexFor(ASourcePath) >= 0 then
Exit;
Png := TPortableNetworkGraphic.Create;
Bmp := TBitmap.Create;
try
try
Png.LoadFromFile(AThumbPath);
{ Список изображений принимает растр, а не картинку произвольного
формата, поэтому перерисовываем PNG в растр нужного размера. }
Bmp.SetSize(ThumbWidth, ThumbHeight);
Bmp.Canvas.Brush.Color := clBlack;
Bmp.Canvas.FillRect(0, 0, ThumbWidth, ThumbHeight);
Bmp.Canvas.Draw(0, 0, Png);
ImgIdx := FThumbs.Add(Bmp, nil);
except
{ Битая миниатюра — просто оставляем строку без картинки. }
Exit;
end;
finally
Bmp.Free;
Png.Free;
end;
FThumbMap.AddObject(ASourcePath, TObject(PtrUInt(ImgIdx + 1)));
lstPool.Invalidate;
end;
{ Колесо с Shift над журналом прокручивает его вбок.
Строки там бывают длиной в несколько экранов — команды ffmpeg целиком, —
и добраться до их конца иначе нечем: полоса прокрутки внизу есть, но
колесо само по себе на неё не действует. }
{ Своя отрисовка строки: кадр слева, название и параметры справа. }
procedure TMainForm.lstPoolDrawItem(Control: TWinControl; Index: Integer;
ARect: TRect; State: TOwnerDrawState);
var
C: TCanvas;
Item: TMediaItem;
ImgIdx, TextLeft: Integer;
begin
C := lstPool.Canvas;
{ Цвета берём свои, а не системные. clWindowText под Windows почти
чёрный, и на тёмном списке подписи к файлам пропадали. }
if odSelected in State then
C.Brush.Color := ThemeSelection
else
C.Brush.Color := ThemeSurface;
C.FillRect(ARect);
if (Index < 0) or (Index >= FProject.PoolCount) then
Exit;
Item := FProject.Pool[Index];
ImgIdx := ThumbIndexFor(Item.Path);
if ImgIdx >= 0 then
FThumbs.Draw(C, ARect.Left + 4, ARect.Top + 5, ImgIdx)
else
begin
{ Кадр ещё готовится — рисуем заглушку того же размера, чтобы строки
не прыгали, когда картинка появится. }
C.Brush.Color := clBlack;
C.FillRect(ARect.Left + 4, ARect.Top + 5,
ARect.Left + 4 + ThumbWidth, ARect.Top + 5 + ThumbHeight);
C.Font.Color := ThemeTextDim;
C.Brush.Style := bsClear;
C.TextOut(ARect.Left + 20, ARect.Top + 5 + ThumbHeight div 2 - 8, 'готовится...');
C.Brush.Style := bsSolid;
end;
TextLeft := ARect.Left + ThumbWidth + 12;
C.Brush.Style := bsClear;
if odSelected in State then
C.Font.Color := ThemeSelText
else
C.Font.Color := ThemeText;
C.Font.Style := [fsBold];
C.TextOut(TextLeft, ARect.Top + 8, ExtractFileName(Item.Path));
C.Font.Style := [];
if not (odSelected in State) then
C.Font.Color := ThemeTextDim;
C.TextOut(TextLeft, ARect.Top + 30, DescribeMedia(Item.Info));
if Item.Info.Duration > 0 then
C.TextOut(TextLeft, ARect.Top + 50,
SecondsToStr(Item.Info.Duration) + ' с');
if Item.NormalizedPath <> '' then
C.TextOut(TextLeft, ARect.Top + 70, 'приведён к проекту');
C.Brush.Style := bsSolid;
end;
{ Номер выбранного файла в списке проекта, минус один если ничего не выбрано. }
function SelectedPoolIndex(AList: TListBox): Integer;
begin
Result := AList.ItemIndex;
end;
procedure TMainForm.lstPoolClick(Sender: TObject);
var
Item: TMediaItem;
Idx: Integer;
begin
Idx := SelectedPoolIndex(lstPool);
if (Idx < 0) or (Idx >= FProject.PoolCount) then
Exit;
Item := FProject.Pool[Idx];
SetStatus(ExtractFileName(Item.Path) + ' — ' + DescribeMedia(Item.Info) +
', ' + SecondsToStr(Item.Info.Duration) + ' с');
end;
{ --- Список файлов --- }
procedure TMainForm.AddMediaFile(const APath: string);
var
Info: TMediaInfo;
Err: string;
Item: TMediaItem;
begin
if FProject.FindMediaByPath(APath) <> nil then
begin
Log('Уже в проекте: ' + ExtractFileName(APath));
Exit;
end;
Screen.Cursor := crHourGlass;
try
if not ProbeMedia(APath, Info, Err) then
begin
Log('Не удалось прочитать ' + ExtractFileName(APath) + ': ' + Err);
Exit;
end;
finally
Screen.Cursor := crDefault;
end;
FProject.BeginChange;
Item := FProject.AddMedia(APath, Info);
Log('Добавлен ' + ExtractFileName(APath) + ' — ' + DescribeMedia(Info));
if Info.ExtraStreams > 0 then
Log(' в файле ещё ' + IntToStr(Info.ExtraStreams) +
' поток(ов) — телеметрия или обложка, при сборке они отбрасываются');
if Item = FProject.Pool[0] then
begin
Log(' параметры проекта взяты из этого файла');
PrewarmInserts;
end;
end;
procedure TMainForm.ChooseAndAddFiles(Sender: TObject);
var
I: Integer;
begin
if not OpenMediaDialog.Execute then
Exit;
FSettings.LastMediaDir := ExtractFilePath(OpenMediaDialog.FileName);
for I := 0 to OpenMediaDialog.Files.Count - 1 do
AddMediaFile(OpenMediaDialog.Files[I]);
RefreshPool;
UpdateCaption;
end;
{ Кладёт файл из списка проекта на дорожку.
Общая точка для кнопки «На таймлайн» и для перетаскивания мышью:
предупреждение о несовпадении параметров и выбор места должны работать
одинаково, каким бы способом файл ни попал на монтаж.
ATrackIndex = -1 означает «завести новую дорожку внизу». }
function TMainForm.AddClipFromPool(APoolIndex, ATrackIndex: Integer;
const ATime: Double): Boolean;
var
Item: TMediaItem;
Track: TTrack;
Mismatches: TMismatches;
Duration, PlaceAt: Double;
NormPath, Wanted: string;
NeedKind: TTrackKind;
I: Integer;
begin
Result := False;
if (APoolIndex < 0) or (APoolIndex >= FProject.PoolCount) then
begin
SetStatus('Сначала выберите файл в списке.');
Exit;
end;
Item := FProject.Pool[APoolIndex];
if ATrackIndex < 0 then
begin
FProject.AddTrack(FProject.UniqueTrackName('V'));
ATrackIndex := FProject.TrackCount - 1;
Log('Заведена дорожка ' + FProject.Tracks[ATrackIndex].Name);
end;
if ATrackIndex >= FProject.TrackCount then
ATrackIndex := FProject.TrackCount - 1;
Track := FProject.Tracks[ATrackIndex];
{ Дорожка чужого вида не годится: клип на ней не увидит ни сборка, ни
предпросмотр — уплощение обходит дорожки по видам. Поэтому не кладём
туда молча, а находим подходящую и говорим, куда положили. }
{ Файл без картинки — только звук; всё остальное идёт на дорожку
картинки, а парный звук программа положит сама. }
if Item.Info.HasVideo or (Item.Info.Kind = mkImage) then
NeedKind := tkVideo
else
NeedKind := tkAudio;
if Track.Kind <> NeedKind then
begin
Wanted := Track.Name;
Track := nil;
for I := 0 to FProject.TrackCount - 1 do
if FProject.Tracks[I].Kind = NeedKind then
begin
Track := FProject.Tracks[I];
Break;
end;
if Track = nil then
begin
if NeedKind = tkAudio then
Track := FProject.AddTrackOfKind(FProject.UniqueTrackName('A'), tkAudio)
else
Track := FProject.AddTrack(FProject.UniqueTrackName('V'));
end;
Log(Format('Дорожка %s не для этого вида клипов — положил на %s.',
[Wanted, Track.Name]));
end;
{ Предупреждаем именно здесь, при попадании на монтаж: пока файл просто
лежит в списке, он ни с чем не склеивается. }
Mismatches := CompareToMaster(FProject.Master, Item.Info);
if Length(Mismatches) > 0 then
case AskAboutMismatch(Self, Item.Path, Mismatches) of
mcCancel:
Exit;
mcNormalize:
begin
NormPath := FProject.CacheDir + 'norm' + PathDelim +
ChangeFileExt(ExtractFileName(Item.Path), '') + '.mkv';
if not ForceDirectories(ExtractFilePath(NormPath)) then
begin
Log('Не удалось создать каталог для приведённых файлов.');
Exit;
end;
FNormalize := TFfmpegJob.Create(
ArgsNormalize(Item.Path, NormPath,
FProject.Master.Width, FProject.Master.Height,
FProject.Master.FpsNum, FProject.Master.FpsDen,
FProject.Master.VideoCodec, FProject.Master.PixFmt,
FProject.Master.AudioCodec,
FProject.Master.SampleRate, FProject.Master.Channels,
TransformFilter(Item.Transform, Item.Info.Width, Item.Info.Height,
FProject.Master.Width, FProject.Master.Height)),
Item.Info.Duration, 'Привожу к параметрам проекта', NormPath);
FNormalize.OnProgress := @NormalizeProgress;
FNormalize.OnLog := @NormalizeLog;
FNormalize.OnTerminate := @NormalizeDone;
FNormalizeUi := TProgressForm.Create(Self);
try
FNormalizeUi.Caption := 'Приведение к параметрам проекта';
FNormalizeUi.OnCancelRequest := @NormalizeCancel;
FNormalize.Start;
FNormalizeUi.ShowModal;
finally
FreeAndNil(FNormalizeUi);
end;
FNormalize.WaitFor;
if not FNormalize.Succeeded then
begin
Log('Приведение не удалось: ' + FNormalize.Message);
FreeAndNil(FNormalize);
Exit;
end;
FreeAndNil(FNormalize);
Item.NormalizedPath := NormPath;
{ Индекс опорных кадров относился к исходнику, у приведённой
копии он свой. }
Item.IndexLoaded := False;
Log('Файл приведён к параметрам проекта: ' + NormPath);
end;
end;
if Item.Info.Kind = mkImage then
Duration := DefaultImageDuration
else
Duration := Item.Info.Duration;
if Duration <= 0 then
Duration := DefaultImageDuration;
{ Ставим туда, куда попросили. Если там занято — в конец дорожки: молча
накладывать клипы друг на друга нельзя, порядок перекрытия должен быть
осознанным. }
PlaceAt := Max(0, ATime);
if (Track.ClipAt(PlaceAt) <> nil) or
(Track.ClipAt(PlaceAt + Duration - 0.001) <> nil) then
begin
PlaceAt := Track.EndTime;
Log('На дорожке ' + Track.Name + ' в этом месте занято — поставил в конец.');
end;
FProject.BeginChange;
{ Парой: клип картинки и его звук на соответствующей звуковой дорожке.
Связка держит их вместе, пока её не развяжут по Ctrl+G. }
FProject.AddClipPair(Track, Item, 0, Duration, PlaceAt);
FTimeline.Invalidate;
SchedulePreview;
UpdateCaption;
UpdateTimeLabel;
SetStatus('Добавлен клип на дорожку ' + Track.Name);
Result := True;
end;
procedure TMainForm.TimelineActiveTrackChange(Sender: TObject);
begin
if (FTimeline.ActiveTrack >= 0) and
(FTimeline.ActiveTrack < FProject.TrackCount) then
SetStatus('Выбрана дорожка ' +
FProject.Tracks[FTimeline.ActiveTrack].Name);
end;
{ Файл притащили мышью прямо на дорожку. }
procedure TMainForm.TimelineMediaDrop(Sender: TObject; ASource: TObject;
ATrackIndex: Integer; const ATime: Double);
begin
if ASource <> lstPool then
Exit;
AddClipFromPool(SelectedPoolIndex(lstPool), ATrackIndex, ATime);
end;
{ Дорожку завёл сам компонент, когда клип утащили ниже последней. }
procedure TMainForm.TimelineTrackAdded(Sender: TObject);
begin
UpdateCaption;
end;
{ --- Предпросмотр --- }
{ Готовит заготовку чёрной вставки заранее, чтобы первая дыра на монтаже
появилась мгновенно. }
procedure TMainForm.PrewarmInserts;
var
Requests: TGenRequests;
begin
if FGenerateBusy or (not FProject.MasterSet) then
Exit;
Requests := PrewarmRequests(FProject.CacheDir);
if Length(Requests) = 0 then
Exit;
FGenerateBusy := True;
FGenerate := TGenerateThread.Create(Requests, FProject.Master);
FGenerate.OnProgress := @GenerateProgress;
FGenerate.OnLog := @GenerateLog;
FGenerate.OnTerminate := @GenerateDone;
FGenerate.Start;
end;
procedure TMainForm.SchedulePreview;
begin
FPreviewDirty := True;
FPreviewTimer.Enabled := False;
FPreviewTimer.Enabled := True;
end;
procedure TMainForm.PreviewTimerTick(Sender: TObject);
begin
FPreviewTimer.Enabled := False;
if FPreviewDirty then
begin
FPreviewDirty := False;
RebuildPreview;
end;
end;
function TMainForm.PreviewNeedsFitting: Boolean;
var
T, C: Integer;
Track: TTrack;
Item: TMediaItem;
begin
Result := False;
if not FProject.MasterSet then
Exit;
for T := 0 to FProject.TrackCount - 1 do
begin
Track := FProject.Tracks[T];
for C := 0 to Track.Count - 1 do
begin
Item := FProject.FindMedia(Track.Clips[C].MediaId);
if (Item = nil) or (not Item.Info.HasVideo) then
Continue;
if (Item.Info.Width <= 0) or (Item.Info.Height <= 0) then
Continue;
{ Сравниваем именно соотношение сторон, а не размеры: файл 3840x2160
в проекте 2688x1512 показывается верно и без всякого фильтра —
mpv сам впишет его в панель, и картинка совпадёт до пикселя.
Умножение вместо деления, чтобы не ловить погрешность дробей. }
if Item.Info.Width * FProject.Master.Height <>
Item.Info.Height * FProject.Master.Width then
Exit(True);
end;
end;
end;
procedure TMainForm.ApplyPreviewFrame;
begin
if (FPlayer = nil) or (not FPlayer.Initialized) then
Exit;
if PreviewNeedsFitting then
begin
{ Порядок важен: сначала переводим декодирование в обычную память,
иначе фильтру нечего будет обрабатывать и он откажется собираться. }
FPlayer.SetHwdec('auto-copy');
FPlayer.SetVideoFilter(
PreviewFitFilter(FProject.Master.Width, FProject.Master.Height));
end
else
begin
{ И обратно: сначала снимаем фильтр, потом возвращаем быстрый путь. }
FPlayer.SetVideoFilter('');
FPlayer.SetHwdec('auto-safe');
end;
end;
procedure TMainForm.RebuildPreview;
var
Segments, AudioSegs, TrackSegs: TFlatSegmentArray;
Overlays: TPreviewOverlays;
GenMaster: TMediaInfo;
Missing, TrackMissing: TGenRequests;
I, J: Integer;
TotalLen: Double;
begin
if (FPlayer = nil) or (not FPlayer.Initialized) then
Exit;
if FGenerateBusy then
begin
{ Пока идёт создание вставок, пересборку откладываем: иначе запустим
второй такой же поток.
Но сперва смотрим, нужна ли ещё та работа, что идёт. Дыру могли
закрыть, клип подвинуть — и тогда вставка делается впустую, а
чёрная минута в 2688x1512 считается не мгновенно. Ненужное
прекращаем: поток сам доложит о завершении, и пересборка пойдёт по
свежей расстановке. }
if Assigned(FGenerate) and (not GenerationStillNeeded) then
begin
Log('Расстановка изменилась — прекращаю делать ненужные вставки.');
SetStatus('Расстановка изменилась, лишние вставки отменены.');
FGenerate.RequestCancel;
end;
FPreviewDirty := True;
FPreviewTimer.Enabled := True;
Exit;
end;
{ Уплощение для предпросмотра: клипы, которые кладутся поверх, идут
отдельно. Плеер положит их сам, ничего не собирая. }
if not FlattenPreview(FProject, Segments, AudioSegs, Overlays, Missing,
FSettings.PreviewKey) then
begin
FPlayer.Stop;
FLastLoadedEdl := '';
FPreviewOverlayUrl := '';
FPreviewOverlayWindows := nil;
UpdateTimeLabel;
Exit;
end;
BuildOverlayPreview(Overlays, TotalDuration(Segments));
{ Текст поверх картинки рисует сам плеер: ему отдаётся файл разметки,
а не пересобранный кусок. Поэтому правка текста видна сразу. }
UpdateSubtitles;
{ Участки с прозрачностью и переходами плееру не по зубам: список
кусков накладывать не умеет. Подставляем вместо них заранее
посчитанные файлы, а чего ещё нет — заказываем. }
ResolveLayers(FProject, Segments, Missing);
{ Каждая звуковая дорожка идёт своим списком кусков: они смешиваются, а
не побеждают друг друга. Дыры в каждой дорожке свои, и вставки тишины
под них нужны свои — без этого список ссылается на несуществующий
файл, плеер его не открывает, и дорожка молчит целиком. }
FPreviewAudioUrls := nil;
FPreviewAudioTracks := nil;
TotalLen := Max(AudibleEnd(FProject, tkVideo), AudibleEnd(FProject, tkAudio));
for I := 0 to FProject.TrackCount - 1 do
begin
if FProject.Tracks[I].Kind <> tkAudio then
Continue;
if not FProject.TrackAudible(FProject.Tracks[I]) then
Continue;
if FProject.Tracks[I].Count = 0 then
Continue;
if not FlattenAudioTrack(FProject, FProject.Tracks[I], TotalLen,
TrackSegs, TrackMissing) then
Continue;
for J := 0 to High(TrackMissing) do
begin
SetLength(Missing, Length(Missing) + 1);
Missing[High(Missing)] := TrackMissing[J];
end;
SetLength(FPreviewAudioUrls, Length(FPreviewAudioUrls) + 1);
FPreviewAudioUrls[High(FPreviewAudioUrls)] := BuildEdlUri(TrackSegs);
{ Запоминаем сами дорожки: громкости потом берутся ровно по этому
списку. Пересчитывать его отдельным проходом нельзя — стоит одному
проходу пропустить дорожку, и громкости съедут на соседние. Тогда
дорожка с нулевой громкостью заглушила бы чужую. }
SetLength(FPreviewAudioTracks, Length(FPreviewAudioTracks) + 1);
FPreviewAudioTracks[High(FPreviewAudioTracks)] := FProject.Tracks[I].Id;
end;
if Length(Missing) > 0 then
begin
FGenerateBusy := True;
SetStatus('Готовлю вставки для предпросмотра...');
{ Вставки — картинки и связки — попадают в ролик наравне со съёмкой,
поэтому целятся в тот же битрейт. }
GenMaster := FProject.Master;
GenMaster.VideoBitrate := FProject.MaxVideoBitrate;
FGenerate := TGenerateThread.Create(Missing, GenMaster);
FGenerate.OnProgress := @GenerateProgress;
FGenerate.OnLog := @GenerateLog;
FGenerate.OnTerminate := @GenerateDone;
FGenerate.Start;
Exit;
end;
LoadPreviewEdl(Segments, AudioSegs);
end;
{ Список кусков оверлея и окна его показа.
Список должен покрывать монтаж от начала и без разрывов: плеер
выравнивает дорожки по времени, и дыра сдвинула бы всё, что после неё.
Промежутки заполняются тем же файлом — показывать его там всё равно
никто не будет, за это отвечают окна. }
procedure TMainForm.BuildOverlayPreview(const AOverlays: TPreviewOverlays;
const ATotal: Double);
var
Segs: TFlatSegmentArray;
Win: TOverlayWindow;
I, N: Integer;
At: Double;
procedure Push(const APath: string; const AStart, ADuration: Double);
begin
if ADuration <= 0.001 then
Exit;
SetLength(Segs, N + 1);
Segs[N] := Default(TFlatSegment);
Segs[N].FilePath := APath;
Segs[N].SrcStart := AStart;
Segs[N].Duration := ADuration;
Inc(N);
end;
begin
FPreviewOverlayUrl := '';
FPreviewOverlayWindows := nil;
if Length(AOverlays) = 0 then
Exit;
N := 0;
At := 0;
for I := 0 to High(AOverlays) do
begin
{ Промежуток до этого куска — заполнитель из того же файла. }
if AOverlays[I].TlStart > At + 0.001 then
Push(AOverlays[I].FilePath, 0, AOverlays[I].TlStart - At);
Push(AOverlays[I].FilePath, AOverlays[I].SrcStart,
AOverlays[I].TlEnd - AOverlays[I].TlStart);
At := AOverlays[I].TlEnd;
Win := Default(TOverlayWindow);
Win.TlStart := AOverlays[I].TlStart;
Win.TlEnd := AOverlays[I].TlEnd;
Win.Filter := AOverlays[I].Filter;
Win.X := AOverlays[I].X;
Win.Y := AOverlays[I].Y;
Win.Width := AOverlays[I].Width;
Win.Height := AOverlays[I].Height;
SetLength(FPreviewOverlayWindows, Length(FPreviewOverlayWindows) + 1);
FPreviewOverlayWindows[High(FPreviewOverlayWindows)] := Win;
end;
{ Хвост после последнего куска не нужен: наложение там выключено, а
плеер спокойно доигрывает основу без второй дорожки. }
if N = 0 then
begin
FPreviewOverlayWindows := nil;
Exit;
end;
FPreviewOverlayUrl := BuildEdlUri(Segs);
end;
{ Делает ли идущая генерация то, что монтажу ещё нужно.
Уплощение считается заново — оно дешёвое, ffmpeg не запускает. Если ни
одна из готовящихся вставок в новом списке не нужна, работу можно
прекращать. Ошибиться тут не страшно: отменённое, если оно всё же
понадобится, закажут заново на следующей пересборке. }
{ Нужна ли ещё та работа, что идёт сейчас.
Спрашивать по одному только списку недостающего нельзя, и это стоило
повисшей сборки. Недостающее — то, чего нет на диске; а у куска,
который делается прямо сейчас, файл уже есть: ffmpeg создаёт его в
первую же секунду и пишет до конца. Поэтому сведённый кусок в списке
недостающего не появлялся, проверка отвечала «не нужен», работа
отменялась в разгаре — и начиналась заново с нуля.
На коротком затухании это проходило незаметно: кусок успевал
посчитаться между правками. На затухании в семьдесят секунд, которое
считается полторы минуты, любая следующая правка сбрасывала работу, и
сборка не заканчивалась никогда.
Поэтому спрашиваем иначе: нужен ли расстановке сам этот путь — неважно,
лежит он уже на диске или ещё пишется. }
function TMainForm.GenerationStillNeeded: Boolean;
var
Segs, Auds, TrackSegs: TFlatSegmentArray;
Ovl: TPreviewOverlays;
Missing, TrackMissing: TGenRequests;
Wanted: TStringList;
TotalLen: Double;
I, J: Integer;
begin
Result := False;
if not Assigned(FGenerate) then
Exit;
if not FlattenPreview(FProject, Segs, Auds, Ovl, Missing,
FSettings.PreviewKey) then
Exit;
Wanted := TStringList.Create;
try
ResolveLayers(FProject, Segs, Missing, Wanted);
{ Звуковые дорожки считаются тем же путём, что и при пересборке:
вставки тишины под дыры у каждой дорожки свои, и они тоже могут
делаться прямо сейчас. }
TotalLen := Max(AudibleEnd(FProject, tkVideo), AudibleEnd(FProject, tkAudio));
for I := 0 to FProject.TrackCount - 1 do
begin
if FProject.Tracks[I].Kind <> tkAudio then
Continue;
if not FProject.TrackAudible(FProject.Tracks[I]) then
Continue;
if FProject.Tracks[I].Count = 0 then
Continue;
FlattenAudioTrack(FProject, FProject.Tracks[I], TotalLen,
TrackSegs, TrackMissing, Wanted);
end;
for J := 0 to FGenerate.TargetCount - 1 do
if Wanted.IndexOf(FGenerate.TargetPath(J)) >= 0 then
Exit(True);
finally
Wanted.Free;
end;
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;
Pos, Total: Double;
WasPaused: Boolean;
I: Integer;
begin
{ Кадр проекта задаётся до загрузки: mpv должен показывать то, что
получится в готовом ролике, а не размер очередного исходника. Файл
другого соотношения сторон иначе выглядел бы в предпросмотре во весь
экран, а в ролике — с полями по краям. }
ApplyPreviewFrame;
Edl := BuildEdlUri(ASegments);
{ Списки кусков звуковых дорожек уже построены при пересборке: там же
проверено, что все вставки тишины существуют. }
AudioEdl := '';
for I := 0 to High(FPreviewAudioUrls) do
AudioEdl := AudioEdl + FPreviewAudioUrls[I] + '|';
{ Наложение входит в сравнение наравне со звуком: сдвинули оверлей —
список кусков основы мог не измениться, а показывать надо иначе. }
AudioEdl := AudioEdl + '#' + FPreviewOverlayUrl;
for I := 0 to High(FPreviewOverlayWindows) do
AudioEdl := AudioEdl + Format('|%s@%d:%d:%s-%s',
[FPreviewOverlayWindows[I].Filter,
FPreviewOverlayWindows[I].X, FPreviewOverlayWindows[I].Y,
SecondsToStr(FPreviewOverlayWindows[I].TlStart),
SecondsToStr(FPreviewOverlayWindows[I].TlEnd)]);
{ Список кусков запоминаем всегда, даже если строка не изменилась:
громкость в неё не входит, а брать её потом неоткуда. }
FPreviewSegments := AAudio;
FLastVolumeSent := -1;
if (Edl = FLastLoadedEdl) and (AudioEdl = FLastLoadedAudioEdl) then
Exit;
Pos := FTimeline.Playhead;
{ Курсор мог оказаться за концом монтажа, если правка его укоротила.
Просить у плеера несуществующее время бессмысленно. }
Total := TotalDuration(ASegments);
if Pos > Total then
Pos := Max(0, Total - 0.05);
WasPaused := FPlayer.Paused;
{ Позиция и состояние паузы задаются прямо при загрузке, а не отдельной
перемоткой следом. Иначе mpv успевает начать с нуля, и на каждой
правке монтажа слышен и виден кусок не оттуда. }
{ Внешний звук назначается до загрузки: он часть того, что открывается. }
FPlayer.SetAudioSources(FPreviewAudioUrls);
FPlayer.SetVideoOverlay(FPreviewOverlayUrl, FPreviewOverlayWindows);
FPlayer.LoadUrl(Edl, Format('start=%s,pause=%s',
[SecondsToStr(Pos), BoolToStr(WasPaused, 'yes', 'no')]));
FLastLoadedEdl := Edl;
FLastLoadedAudioEdl := AudioEdl;
{ Пока плеер не доедет, его сообщения о позиции игнорируются — иначе
курсор на монтаже прыгнул бы в начало при каждой правке. }
FSeekTarget := Pos;
FSeekPending := True;
FSeekWaitTicks := 0;
{ Кадрирование выставляется заново после каждой загрузки.
Обрезка на выводе — свойство плеера, а не списка кусков: она
переживает загрузку и осталась бы от прошлого монтажа. А при открытии
проекта курсор не двигается вовсе, и без этого вызова первый клип
показывался бы без своего кадрирования до первого щелчка по монтажу. }
FLastCropKey := '';
ApplyCropAt(Pos);
SetStatus(Format('Предпросмотр: %d %s, %s с',
[Length(ASegments), Plural(Length(ASegments), 'кусок', 'куска', 'кусков'),
SecondsToStr(TotalDuration(ASegments))]));
UpdateTimeLabel;
end;
{ Ставит плееру громкость того куска, который звучит сейчас.
Композитинга нет, поэтому в каждый момент звучит ровно один кусок, и
громкость всего вывода можно просто приравнять его громкости. Ставим
только при изменении: команда на каждый кадр была бы напрасной. }
{ --- Недавние проекты --- }
procedure TMainForm.RefreshRecentMenu;
var
I: Integer;
Item: TMenuItem;
begin
if FRecentMenu = nil then
Exit;
FRecentMenu.Clear;
for I := 0 to FSettings.RecentProjects.Count - 1 do
begin
Item := TMenuItem.Create(FRecentMenu);
{ Номер в начале даёт быстрый выбор глазами, а полный путь в подсказке
различает одноимённые проекты из разных папок. }
Item.Caption := Format('%d. %s',
[I + 1, ExtractFileName(FSettings.RecentProjects[I])]);
Item.Hint := FSettings.RecentProjects[I];
Item.Tag := I;
Item.OnClick := @OpenRecentProject;
FRecentMenu.Add(Item);
end;
FRecentMenu.Enabled := FSettings.RecentProjects.Count > 0;
{ Пункты добавлены только что, своих цветов у них ещё нет. }
ApplyDarkMenu(FRecentMenu.GetParentMenu);
end;
procedure TMainForm.OpenRecentProject(Sender: TObject);
var
Idx: Integer;
Path: string;
begin
Idx := TMenuItem(Sender).Tag;
if (Idx < 0) or (Idx >= FSettings.RecentProjects.Count) then
Exit;
Path := FSettings.RecentProjects[Idx];
if not FileExists(Path) then
begin
ThemedMessageDlg('nlve', 'Файл больше не существует:' + LineEnding + Path,
mtWarning, [mbOK], 0);
FSettings.RecentProjects.Delete(Idx);
RefreshRecentMenu;
Exit;
end;
if not ConfirmDiscard then
Exit;
OpenProjectFile(Path);
end;
{ Открытие проекта из файла. Общая точка для меню, списка недавних и
командной строки: иначе часть из них забывала бы что-нибудь обновить. }
function TMainForm.OpenProjectFile(const APath: string): Boolean;
var
Err: string;
begin
Result := FProject.LoadFromFile(APath, Err);
if not Result then
begin
ThemedMessageDlg('nlve', Err, mtError, [mbOK], 0);
Exit;
end;
FTimeline.Playhead := 0;
FTimeline.ZoomToFit;
FLastLoadedEdl := '';
RefreshPool;
UpdateRippleButton;
UpdateCaption;
SchedulePreview;
FSettings.NoteRecentProject(FProject.FileName);
RefreshRecentMenu;
Log('Открыт проект: ' + FProject.FileName);
{ Если рядом лежит копия новее проекта — значит прошлая работа не дошла
до файла, и об этом надо сказать сразу при открытии. }
OfferAutoSave;
CheckMisplacedClips;
end;
{ --- Схлопывание --- }
{ Кнопка показывает и состояние, и режим: утоплена — автосхлопывание
включено, надпись говорит, на какие дорожки оно действует. }
{ Клипы на дорожках чужого вида: находим и предлагаем перенести.
Такой клип нарисован на монтаже, но в ролик не попадает: уплощение
обходит дорожки по видам и клип с чужой дорожки не видит. Пропажа тихая
— сборка проходит без единой жалобы, а в готовом файле фотографии или
надписи нет. Проверено на живом проекте, где фотография оказалась на
текстовой дорожке, а надпись — на дорожке картинки.
Молча переносить нельзя: человек мог положить клип туда осознанно, и
внезапно переехавший клип — сюрприз хуже пропажи. Поэтому спрашиваем. }
procedure TMainForm.CheckMisplacedClips;
var
N: Integer;
begin
N := FProject.MisplacedClipCount;
if N = 0 then
Exit;
Log(Format('ВНИМАНИЕ: %d %s на дорожках чужого вида — в сборку они не попадут.',
[N, Plural(N, 'клип', 'клипа', 'клипов')]));
if ThemedMessageDlg('nlve',
Format('В проекте %d %s лежит на дорожках не своего вида: ' +
'картинка на звуковой или текстовой, надпись на дорожке картинки.' +
LineEnding + LineEnding +
'Такой клип виден на монтаже, но в готовый ролик не попадает — ' +
'ни в сборку, ни в предпросмотр.' + LineEnding + LineEnding +
'Перенести их на подходящие дорожки? Время клипов не изменится.',
[N, Plural(N, 'клип', 'клипа', 'клипов')]),
mtWarning, [mbYes, mbNo], 0) <> mrYes then
begin
SetStatus(Format('%d %s не попадёт в сборку: они на дорожках чужого вида.',
[N, Plural(N, 'клип', 'клипа', 'клипов')]));
Exit;
end;
FProject.BeginChange;
N := FProject.FixMisplacedClips;
FTimeline.Invalidate;
FLastLoadedEdl := '';
SchedulePreview;
UpdateCaption;
Log(Format('Перенесено на подходящие дорожки: %d.', [N]));
SetStatus(Format('Перенесено клипов: %d.', [N]), True);
end;
procedure TMainForm.UpdateRippleButton;
var
ModeText: string;
begin
if FProject.RippleMode = rmAllTracks then
ModeText := 'все дорожки'
else
ModeText := 'текущая дорожка';
btnRipple.Down := FProject.RippleEnabled;
if FProject.RippleEnabled then
btnRipple.Hint := 'Автосхлопывание включено, режим: ' + ModeText + '.'
else
btnRipple.Hint := 'Автосхлопывание выключено, режим: ' + ModeText + '.';
{ Коротко: подсказка идёт одной строкой в нижней панели, и длинный
хвост там просто обрезается. }
btnRipple.Hint := btnRipple.Hint + LineEnding +
'Ctrl+K — включить или выключить. Режим действует и на Ctrl+Shift+R.';
btnRippleMenu.Hint := 'Выбрать режим схлопывания';
RefreshBarHint;
end;
{ Прилипание краёв клипов друг к другу.
Shift работает как переключатель наоборот: при включённом прилипании
временно отключает его, при выключенном — временно включает. Так одна
клавиша полезна в обоих режимах, и не нужно помнить, какой сейчас. }
{ Кнопка со значком не может сказать словами, что она делает и в каком
она состоянии. Слова переезжают в подсказку — целиком, вместе с
состоянием: иначе о состоянии пришлось бы догадываться по цвету. }
procedure TMainForm.UpdateSnapButton;
var
State: string;
begin
btnSnap.Down := FTimeline.SnapEnabled;
if FTimeline.SnapEnabled then
State := 'включено'
else
State := 'выключено';
btnSnap.Hint := 'Прилипание: ' + State + '.' + LineEnding +
'Shift во время перетаскивания меняет решение на противоположное.';
RefreshBarHint;
end;
procedure TMainForm.btnSnapClick(Sender: TObject);
begin
FTimeline.SnapEnabled := not FTimeline.SnapEnabled;
UpdateSnapButton;
end;
{ Прилипание курсора — отдельный переключатель, а не общий с клипами.
Это разные привычки: клипы прилипают при перетаскивании, курсор — при
щелчке. Одному нужно одно, другому другое, и объединять их в один
выключатель значило бы навязывать связь, которой нет. }
procedure TMainForm.UpdateGlueButton;
var
State: string;
begin
btnGlue.Down := FTimeline.PlayheadSnap;
if FTimeline.PlayheadSnap then
State := 'включено'
else
State := 'выключено';
btnGlue.Hint := 'Прилипание курсора к границам клипов: ' + State + '.' +
LineEnding + 'Shift при щелчке меняет решение на противоположное.';
RefreshBarHint;
end;
procedure TMainForm.btnGlueClick(Sender: TObject);
begin
FTimeline.PlayheadSnap := not FTimeline.PlayheadSnap;
UpdateGlueButton;
end;
{ Подсказка к кнопке нижней панели показывается в строке состояния.
Всплывающее окно здесь мешало: оно появлялось прямо поверх кнопки, о
которой рассказывает, перехватывало щелчок, и нажать кнопку было нельзя.
Строка состояния стоит в той же панели, ничего не закрывает и читается
без наведения мыши. }
procedure TMainForm.BarButtonEnter(Sender: TObject);
begin
if not (Sender is TControl) then
Exit;
if TControl(Sender).Hint = '' then
Exit;
if FHintControl = nil then
FStatusBeforeHint := lblStatus.Caption;
FHintControl := TControl(Sender);
RefreshBarHint;
end;
procedure TMainForm.BarButtonLeave(Sender: TObject);
begin
{ Ушли не с той кнопки, чья подсказка сейчас показана — значит указатель
уже на соседней, и трогать строку нельзя. }
if (FHintControl = nil) or (Sender <> FHintControl) then
Exit;
FHintControl := nil;
lblStatus.Caption := FStatusBeforeHint;
end;
{ Подсказка переключателя меняется от щелчка по нему: «спрятан» —
«виден», «включено» — «выключено». Указатель при этом остаётся на
кнопке, второго наведения не будет, и без этого вызова в строке
состояния висел бы прежний текст — прямо противоположный тому, что
кнопка теперь делает. }
procedure TMainForm.RefreshBarHint;
var
Line: string;
begin
if FHintControl = nil then
Exit;
Line := FHintControl.Hint;
if Line = '' then
Line := FStatusBeforeHint
else
{ Подсказки писались в несколько строк для всплывающего окна; в
строке состояния они идут одной. }
Line := StringReplace(Line, LineEnding, ' ', [rfReplaceAll]);
lblStatus.Caption := Line;
end;
{ Строка состояния занимает всё, что свободно справа.
Показатель работы появляется только пока что-то собирается или
разбирается. Всё остальное время его место пустует, а подсказки к
кнопкам — длинные, и в отведённые под них шестьсот точек не влезали:
человек видел «Переключит…» вместо целой фразы. Поэтому ширина
пересчитывается каждый раз, когда показатель появляется или прячется. }
procedure TMainForm.UpdateStatusWidth;
var
Right: Integer;
begin
if (FJobLabel = nil) or (lblStatus = nil) then
Exit;
if FJobLabel.Visible then
Right := FJobLabel.Left - 12
else
Right := pnlBottom.ClientWidth - 12;
lblStatus.Width := Max(120, Right - lblStatus.Left);
end;
{ Переводит кнопки на подсказки в строке состояния. }
procedure TMainForm.UseStatusHints(const AButtons: array of TThemedButton);
var
I: Integer;
begin
for I := 0 to High(AButtons) do
begin
if AButtons[I] = nil then
Continue;
AButtons[I].ShowHint := False;
AButtons[I].OnMouseEnter := @BarButtonEnter;
AButtons[I].OnMouseLeave := @BarButtonLeave;
end;
end;
{ Кнопка режима выделения.
Режима два, и кнопка показывает не «включено-выключено», а какой сейчас:
значок рамки — выделение клипов, значок промежутка — выделение по
времени. Поэтому она не утапливается, а меняет картинку. }
procedure TMainForm.UpdateSelectModeButton;
begin
if FSelectModeItem <> nil then
if FTimeline.SelectionMode = smRange then
FSelectModeItem.Caption := 'Режим выделения: по времени (сейчас)'
else
FSelectModeItem.Caption := 'Режим выделения: рамкой по клипам (сейчас)';
if FTimeline.SelectionMode = smRange then
begin
btnSelectMode.Icon := tiSelectRange;
btnSelectMode.Hint := 'Выделение по времени: протяжка метит ' +
'промежуток на всех дорожках, S режет по его краям.' + LineEnding +
'Переключить (D) на выделение клипов.';
end
else
begin
btnSelectMode.Icon := tiSelectClips;
btnSelectMode.Hint := 'Выделение клипов: рамка выделяет всё, что ' +
'задела, S режет по курсору.' + LineEnding +
'Переключить (D) на выделение промежутка.';
end;
RefreshBarHint;
end;
procedure TMainForm.btnSelectModeClick(Sender: TObject);
begin
FTimeline.ToggleSelectionMode;
end;
procedure TMainForm.MenuSelectMode(Sender: TObject);
begin
FTimeline.ToggleSelectionMode;
end;
{ Режим сменили — сказать об этом словами: по одному значку не угадать,
что теперь делает протяжка мышью. }
procedure TMainForm.TimelineSelectModeChange(Sender: TObject);
begin
UpdateSelectModeButton;
if FTimeline.SelectionMode = smRange then
SetStatus('Выделение по времени: протяжка отмечает промежуток, ' +
'S режет по его краям.', True)
else
SetStatus('Выделение клипов: рамка выделяет всё, что задела, ' +
'S режет по курсору.', True);
end;
{ Держать ли курсор посередине при воспроизведении.
Включено — монтаж едет под курсором, и всегда видно, что будет дальше.
Выключено — картинка стоит на месте, пока курсор не уйдёт за край; так
спокойнее, когда разглядываешь конкретное место, а ролик при этом
играет. }
procedure TMainForm.UpdateCenterButton;
var
State: string;
begin
btnCenter.Down := FSettings.CenterPlayhead;
if FSettings.CenterPlayhead then
State := 'включено'
else
State := 'выключено';
btnCenter.Hint := 'Курсор посередине при воспроизведении: ' + State + '.' +
LineEnding + 'Клавиша \ переключает.';
RefreshBarHint;
end;
procedure TMainForm.ToggleCenterPlayhead;
begin
FSettings.CenterPlayhead := not FSettings.CenterPlayhead;
UpdateCenterButton;
if FSettings.CenterPlayhead then
begin
FTimeline.ScrollToPlayhead(True);
SetStatus('Курсор держится посередине монтажа.', True);
end
else
SetStatus('Монтаж стоит на месте, курсор идёт по нему.', True);
end;
procedure TMainForm.btnCenterClick(Sender: TObject);
begin
ToggleCenterPlayhead;
end;
procedure TMainForm.ToggleRipple;
begin
FProject.RippleEnabled := not FProject.RippleEnabled;
UpdateRippleButton;
if FProject.RippleEnabled then
SetStatus('Автосхлопывание включено: клипы правее едут вместе с перетаскиваемым.', True)
else
SetStatus('Автосхлопывание выключено: клипы правее остаются на месте.', True);
end;
procedure TMainForm.btnRippleClick(Sender: TObject);
begin
ToggleRipple;
end;
procedure TMainForm.RippleModeChosen(Sender: TObject);
begin
if TMenuItem(Sender).Tag = 1 then
FProject.RippleMode := rmAllTracks
else
FProject.RippleMode := rmCurrentTrack;
{ Выбор режима из списка заодно включает автосхлопывание: иначе выбор
выглядел бы так, будто ничего не произошло. }
FProject.RippleEnabled := True;
UpdateRippleButton;
end;
procedure TMainForm.btnRippleMenuClick(Sender: TObject);
var
P: TPoint;
I: Integer;
begin
for I := 0 to FRippleMenu.Items.Count - 1 do
FRippleMenu.Items[I].Checked :=
(FRippleMenu.Items[I].Tag = 1) = (FProject.RippleMode = rmAllTracks);
P := btnRippleMenu.ClientToScreen(Point(0, btnRippleMenu.Height));
FRippleMenu.PopUp(P.X, P.Y);
end;
{ --- Меню дорожки --- }
{ Меню по правому щелчку в заголовке дорожки.
Раньше дорожку можно было только добавить и убрать последнюю, а убрать
чаще нужно не последнюю. Порядок дорожек тоже правится здесь — тем, кому
перетаскивание заголовка неудобно. }
{ Участок подвинули мышью: показываем, что вышло, и помечаем проект
изменённым — отметки в нём и хранятся. }
procedure TMainForm.TimelineRegionChange(Sender: TObject);
begin
FProject.Modified := True;
UpdateCaption;
if FProject.HasRegion then
SetStatus(Format('Участок: %s — %s',
[SecondsToStr(FProject.RegionStart), SecondsToStr(FProject.RegionEnd)]), True)
else
SetStatus('Отметки участка сняты: собирается весь монтаж.', True);
end;
procedure TMainForm.BuildTrackMenu;
var
Item: TMenuItem;
begin
FTrackMenu := TPopupMenu.Create(Self);
Item := TMenuItem.Create(FTrackMenu);
Item.Caption := 'Поднять дорожку';
Item.OnClick := @TrackMenuUp;
FTrackMenu.Items.Add(Item);
Item := TMenuItem.Create(FTrackMenu);
Item.Caption := 'Опустить дорожку';
Item.OnClick := @TrackMenuDown;
FTrackMenu.Items.Add(Item);
Item := TMenuItem.Create(FTrackMenu);
Item.Caption := 'Переименовать...';
Item.OnClick := @TrackMenuRename;
FTrackMenu.Items.Add(Item);
Item := TMenuItem.Create(FTrackMenu);
Item.Caption := '-';
FTrackMenu.Items.Add(Item);
Item := TMenuItem.Create(FTrackMenu);
Item.Caption := 'Удалить дорожку';
Item.OnClick := @TrackMenuDelete;
FTrackMenu.Items.Add(Item);
end;
procedure TMainForm.TimelineTrackMenu(Sender: TObject; ATrackIndex: Integer;
const AScreenPos: TPoint);
begin
if (ATrackIndex < 0) or (ATrackIndex >= FProject.TrackCount) then
Exit;
FTrackMenuIndex := ATrackIndex;
FTrackMenu.Items[0].Enabled := ATrackIndex > 0;
FTrackMenu.Items[1].Enabled := ATrackIndex < FProject.TrackCount - 1;
FTrackMenu.PopUp(AScreenPos.X, AScreenPos.Y);
end;
procedure TMainForm.TrackMenuUp(Sender: TObject);
begin
if FTrackMenuIndex <= 0 then
Exit;
FProject.BeginChange;
FProject.MoveTrack(FTrackMenuIndex, FTrackMenuIndex - 1);
TimelineStructureChange(nil);
SetStatus('Дорожка поднята.', True);
end;
procedure TMainForm.TrackMenuDown(Sender: TObject);
begin
if (FTrackMenuIndex < 0) or (FTrackMenuIndex >= FProject.TrackCount - 1) then
Exit;
FProject.BeginChange;
FProject.MoveTrack(FTrackMenuIndex, FTrackMenuIndex + 1);
TimelineStructureChange(nil);
SetStatus('Дорожка опущена.', True);
end;
{ Удаление дорожки. С клипами — только с подтверждением: вместе с
дорожкой уходит всё, что на ней лежит, а отменить можно только одним
шагом назад. }
{ Переименование дорожки.
Имя — только подпись, но на монтаже из десятка дорожек «карта», «звук
мотора» и «титры» читаются несравнимо лучше, чем V1, A2 и Т1. }
procedure TMainForm.TimelineTrackRename(Sender: TObject; ATrackIndex: Integer);
var
NewName: string;
begin
if (ATrackIndex < 0) or (ATrackIndex >= FProject.TrackCount) then
Exit;
NewName := FProject.Tracks[ATrackIndex].Name;
if not ThemedInputDlg('Имя дорожки', 'Как назвать дорожку:', NewName) then
Exit;
FProject.BeginChange;
FProject.Tracks[ATrackIndex].Name := NewName;
FProject.EnsureUniqueTrackNames;
FTimeline.Invalidate;
UpdateCaption;
SetStatus('Дорожка названа: ' + FProject.Tracks[ATrackIndex].Name, True);
end;
procedure TMainForm.TrackMenuRename(Sender: TObject);
begin
TimelineTrackRename(nil, FTrackMenuIndex);
end;
procedure TMainForm.TrackMenuDelete(Sender: TObject);
var
Track: TTrack;
{ Не Name: так называется свойство самой формы, и локальная переменная
его перекроет — компилятор ловит, но сообщение неочевидное. }
TrackName: string;
begin
if (FTrackMenuIndex < 0) or (FTrackMenuIndex >= FProject.TrackCount) then
Exit;
Track := FProject.Tracks[FTrackMenuIndex];
TrackName := Track.Name;
if (Track.Count > 0) and (ThemedMessageDlg('nlve',
Format('На дорожке %s %d %s. Удалить её вместе с ними?',
[TrackName, Track.Count,
Plural(Track.Count, 'клип', 'клипа', 'клипов')]),
mtConfirmation, [mbYes, mbNo], 0) <> mrYes) then
Exit;
FProject.BeginChange;
FProject.RemoveTrack(FTrackMenuIndex);
TimelineStructureChange(nil);
SetStatus('Дорожка ' + TrackName + ' удалена.', True);
end;
{ --- Меню библиотеки --- }
procedure TMainForm.BuildPoolMenu;
var
Item: TMenuItem;
begin
FPoolMenu := TPopupMenu.Create(Self);
FPoolMenu.OnPopup := @PoolMenuPopup;
FPoolAddMenu := TMenuItem.Create(FPoolMenu);
FPoolAddMenu.Caption := 'Добавить на дорожку';
FPoolMenu.Items.Add(FPoolAddMenu);
Item := TMenuItem.Create(FPoolMenu);
Item.Caption := '-';
FPoolMenu.Items.Add(Item);
Item := TMenuItem.Create(FPoolMenu);
Item.Caption := 'Кадрирование и поворот...';
Item.OnClick := @PoolEditTransform;
FPoolMenu.Items.Add(Item);
Item := TMenuItem.Create(FPoolMenu);
Item.Caption := '-';
FPoolMenu.Items.Add(Item);
Item := TMenuItem.Create(FPoolMenu);
Item.Caption := 'Убрать со всех дорожек';
Item.OnClick := @PoolRemoveFromTracks;
FPoolMenu.Items.Add(Item);
Item := TMenuItem.Create(FPoolMenu);
Item.Caption := 'Удалить из проекта';
Item.OnClick := @PoolRemoveFromProject;
FPoolMenu.Items.Add(Item);
lstPool.PopupMenu := FPoolMenu;
{ Меню режимов схлопывания. }
FRippleMenu := TPopupMenu.Create(Self);
Item := TMenuItem.Create(FRippleMenu);
Item.Caption := 'Только текущая дорожка';
Item.RadioItem := True;
Item.Tag := 0;
Item.OnClick := @RippleModeChosen;
FRippleMenu.Items.Add(Item);
Item := TMenuItem.Create(FRippleMenu);
Item.Caption := 'Все дорожки';
Item.RadioItem := True;
Item.Tag := 1;
Item.OnClick := @RippleModeChosen;
FRippleMenu.Items.Add(Item);
end;
{ Список дорожек в подменю строится при каждом открытии: дорожки
добавляются и удаляются, а держать меню в согласии с ними постоянно
дороже, чем собрать его заново. }
{ Правый щелчок выделяет файл под курсором.
Сам по себе список этого не делает: правая кнопка выделение не меняет.
А меню без выделенного файла отключается целиком и выглядит так, будто
оно не работает вовсе. }
procedure TMainForm.lstPoolMouseDown(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
var
Idx: Integer;
begin
if Button <> mbRight then
Exit;
Idx := lstPool.ItemAtPos(Point(X, Y), True);
if Idx >= 0 then
lstPool.ItemIndex := Idx;
end;
procedure TMainForm.PoolMenuPopup(Sender: TObject);
var
I: Integer;
Item: TMenuItem;
{ Не Enabled: это имя занято свойством формы. }
HasSelection: Boolean;
begin
FPoolAddMenu.Clear;
for I := 0 to FProject.TrackCount - 1 do
begin
Item := TMenuItem.Create(FPoolAddMenu);
Item.Caption := FProject.Tracks[I].Name;
Item.Tag := I;
Item.OnClick := @PoolAddToTrack;
FPoolAddMenu.Add(Item);
end;
HasSelection := SelectedPoolIndex(lstPool) >= 0;
for I := 0 to FPoolMenu.Items.Count - 1 do
FPoolMenu.Items[I].Enabled := HasSelection;
end;
procedure TMainForm.PoolAddToTrack(Sender: TObject);
begin
AddClipFromPool(SelectedPoolIndex(lstPool), TMenuItem(Sender).Tag,
FTimeline.Playhead);
end;
{ Убирает клипы файла со всех дорожек, оставляя сам файл в проекте.
Дыры намеренно не схлопываются: убрать клип и переставить всё остальное —
два разных намерения. }
procedure TMainForm.PoolRemoveFromTracks(Sender: TObject);
var
Idx, T, C, Removed: Integer;
Item: TMediaItem;
Track: TTrack;
begin
Idx := SelectedPoolIndex(lstPool);
if (Idx < 0) or (Idx >= FProject.PoolCount) then
Exit;
Item := FProject.Pool[Idx];
FProject.BeginChange;
Removed := 0;
for T := 0 to FProject.TrackCount - 1 do
begin
Track := FProject.Tracks[T];
for C := Track.Count - 1 downto 0 do
if Track[C].MediaId = Item.Id then
begin
Track.Remove(Track[C]);
Inc(Removed);
end;
end;
if Removed = 0 then
begin
FProject.CancelChange;
SetStatus('Клипов этого файла на монтаже нет.');
Exit;
end;
FTimeline.Invalidate;
SchedulePreview;
UpdateCaption;
SetStatus(Format('Убрано %d %s',
[Removed, Plural(Removed, 'клип', 'клипа', 'клипов')]), True);
end;
{ Кадрирование отдельного клипа. Вызывается кнопкой в углу клипа.
Настройка принадлежит клипу: два куска одного файла бывает нужно
кадрировать по-разному. У файла хранится такая же, но она лишь
заготовка для новых клипов. }
procedure TMainForm.TimelineClipTransform(Sender: TObject; AClip: TClip);
var
Item: TMediaItem;
T: TMediaTransform;
Key: TColorKey;
Applied: Boolean;
At: Double;
begin
if AClip = nil then
Exit;
{ У надписи кнопка на клипе открывает не кадрирование, а правку текста:
кадрировать там нечего. }
if AClip.Kind = ckText then
begin
FProject.BeginChange;
if EditTextOf(AClip) then
TimelineStructureChange(nil)
else
FProject.CancelChange;
Exit;
end;
Item := FProject.FindMedia(AClip.MediaId);
if Item = nil then
Exit;
if not FProject.MasterSet then
begin
ThemedMessageDlg('nlve',
'Параметры проекта ещё не заданы: добавьте сначала видеофайл.',
mtInformation, [mbOK], 0);
Exit;
end;
T := AClip.Transform;
{ Кадр окну — из самого клипа, а не из середины исходного файла.
Клип обычно вырезан из середины долгой съёмки, и кадр «десятая часть
от начала файла» показывал совсем другую сцену: прицелиться
кадрированием было нечем. Берём то место, где стоит курсор, если он
внутри клипа, иначе середину клипа — то, на что человек и смотрит. }
At := FTimeline.Playhead;
if (At < AClip.TlStart) or (At > AClip.TlEnd) then
At := AClip.TlStart + AClip.Duration / 2;
At := AClip.SrcIn + (At - AClip.TlStart);
{ Ключ отдаётся окну по ссылке: правку он подтверждает сам, и «Отмена»
кадрирования её не отменяет. }
Key := AClip.ColorKey;
Applied := EditTransform(Self, Item.EffectivePath, Item.Info,
FProject.Master.Width, FProject.Master.Height, T,
FProject.CacheDir + 'pancrop.png', @Key, At);
if (not Applied) and SameColorKey(Key, AClip.ColorKey) then
Exit;
FProject.BeginChange;
if Applied then
AClip.Transform := T;
AClip.ColorKey := Key;
FTimeline.Invalidate;
FLastLoadedEdl := '';
SchedulePreview;
UpdateCaption;
end;
{ Заготовка кадрирования для файла: её берёт новый клип при добавлении. }
procedure TMainForm.PoolEditTransform(Sender: TObject);
var
Idx: Integer;
Item: TMediaItem;
T: TMediaTransform;
begin
Idx := SelectedPoolIndex(lstPool);
if (Idx < 0) or (Idx >= FProject.PoolCount) then
Exit;
Item := FProject.Pool[Idx];
if not FProject.MasterSet then
begin
ThemedMessageDlg('nlve',
'Параметры проекта ещё не заданы: добавьте сначала видеофайл.',
mtInformation, [mbOK], 0);
Exit;
end;
T := Item.Transform;
if not EditTransform(Self, Item.Path, Item.Info,
FProject.Master.Width, FProject.Master.Height, T,
FProject.CacheDir + 'pancrop.png') then
Exit;
FProject.BeginChange;
Item.Transform := T;
{ Приведённая копия сделана со старыми настройками и больше не годится. }
if Item.NormalizedPath <> '' then
begin
Item.NormalizedPath := '';
Item.IndexLoaded := False;
Log('Настройки кадра изменились — приведённую копию надо сделать заново.');
end;
UpdateCaption;
SchedulePreview;
SetStatus('Кадрирование обновлено: ' + FitModeCaption(T.Fit), True);
end;
procedure TMainForm.MenuProjectInfo(Sender: TObject);
begin
ShowProjectInfo(Self, FProject);
end;
{ Проверка и починка проекта.
Зачем. В проекте копятся вещи, которые сами по себе не видны, а в
готовом ролике оборачиваются сюрпризом: клип на дорожке чужого вида
(нарисован, но в сборку не попадает), одинаковые имена дорожек, времена
мимо кадровой сетки. Последнее — наследство: доли кадра в монтаже
означают, что предпросмотр и сборка округлят их каждый по-своему.
Молча ничего не правится: сначала показываем, что нашли, и спрашиваем.
Отмена работает как у любой правки — Ctrl+Z. }
procedure TMainForm.MenuRepairProject(Sender: TObject);
var
OffGrid, Misplaced, Renamed, MovedClips, SnappedClips: Integer;
Report: string;
Names: TStringList;
T: Integer;
begin
OffGrid := FProject.OffGridClipCount;
Misplaced := FProject.MisplacedClipCount;
{ Одинаковые имена дорожек: считаем до правки, иначе не о чем говорить. }
Renamed := 0;
Names := TStringList.Create;
try
Names.Sorted := True;
Names.Duplicates := dupAccept;
for T := 0 to FProject.TrackCount - 1 do
begin
if Names.IndexOf(FProject.Tracks[T].Name) >= 0 then
Inc(Renamed);
Names.Add(FProject.Tracks[T].Name);
end;
finally
Names.Free;
end;
if (OffGrid = 0) and (Misplaced = 0) and (Renamed = 0) then
begin
ThemedMessageDlg('nlve',
'Проект в порядке: клипы стоят на кадрах, все они на дорожках ' +
'своего вида, имена дорожек не повторяются.',
mtInformation, [mbOK], 0);
SetStatus('Проект проверен: чинить нечего.', True);
Exit;
end;
Report := 'Найдено:' + LineEnding + LineEnding;
if OffGrid > 0 then
Report := Report + Format('— клипов мимо кадровой сетки: %d.' + LineEnding +
'Между кадрами времени нет, и доля кадра означает, что предпросмотр ' +
'и сборка округлят её каждый по-своему. Сдвиг при починке не больше ' +
'полкадра.' + LineEnding + LineEnding, [OffGrid]);
if Misplaced > 0 then
Report := Report + Format('— клипов на дорожках чужого вида: %d.' +
LineEnding + 'Такой клип нарисован на монтаже, но в готовый ролик не ' +
'попадает.' + LineEnding + LineEnding, [Misplaced]);
if Renamed > 0 then
Report := Report + Format('— дорожек с повторяющимся именем: %d.' +
LineEnding + LineEnding, [Renamed]);
Report := Report + 'Починить? Отменить можно будет через Ctrl+Z.';
if ThemedMessageDlg('nlve', Report, mtConfirmation, [mbYes, mbNo], 0) <> mrYes then
Exit;
FProject.BeginChange;
SnappedClips := FProject.SnapClipsToFrameGrid;
MovedClips := FProject.FixMisplacedClips;
FProject.EnsureUniqueTrackNames;
if (SnappedClips = 0) and (MovedClips = 0) and (Renamed = 0) then
begin
FProject.CancelChange;
SetStatus('Чинить не пришлось.', True);
Exit;
end;
FProject.Modified := True;
FTimeline.Invalidate;
RefreshPool;
UpdateCaption;
FLastLoadedEdl := '';
SchedulePreview;
Report := '';
if SnappedClips > 0 then
Report := Report + Format('на кадры поставлено %d %s', [SnappedClips,
Plural(SnappedClips, 'клип', 'клипа', 'клипов')]);
if MovedClips > 0 then
begin
if Report <> '' then
Report := Report + ', ';
Report := Report + Format('перенесено %d %s', [MovedClips,
Plural(MovedClips, 'клип', 'клипа', 'клипов')]);
end;
if Renamed > 0 then
begin
if Report <> '' then
Report := Report + ', ';
Report := Report + Format('переименовано %d %s', [Renamed,
Plural(Renamed, 'дорожка', 'дорожки', 'дорожек')]);
end;
Log('Починка проекта: ' + Report + '.');
SetStatus('Проект починен: ' + Report + '. Отменить — Ctrl+Z.', True);
end;
procedure TMainForm.PoolRemoveFromProject(Sender: TObject);
var
Idx: Integer;
Item: TMediaItem;
begin
Idx := SelectedPoolIndex(lstPool);
if (Idx < 0) or (Idx >= FProject.PoolCount) then
Exit;
Item := FProject.Pool[Idx];
if ThemedMessageDlg('nlve', 'Удалить ' + ExtractFileName(Item.Path) +
' из проекта? Его клипы пропадут со всех дорожек.',
mtConfirmation, [mbYes, mbNo], 0) <> mrYes then
Exit;
FProject.BeginChange;
FProject.RemoveMedia(Item);
RefreshPool;
FTimeline.Invalidate;
SchedulePreview;
UpdateCaption;
end;
{ Файлы бросили на окно программы.
Место броска решает, что с ними делать: над монтажом — сразу и в проект,
и на дорожку под указателем; в любом другом месте — только в проект.
Своих координат обработчик не получает, поэтому берём положение
указателя. }
procedure TMainForm.FormFilesDropped(Sender: TObject;
const FileNames: array of string);
var
I, TrackIdx: Integer;
Pt: TPoint;
OverTimeline: Boolean;
DropTime: Double;
Added: Boolean;
begin
Pt := FTimeline.ScreenToClient(Mouse.CursorPos);
OverTimeline := (Pt.X >= 0) and (Pt.Y >= 0) and
(Pt.X < FTimeline.Width) and (Pt.Y < FTimeline.Height);
Added := False;
for I := 0 to High(FileNames) do
if FileExists(FileNames[I]) then
begin
AddMediaFile(ExpandFileName(FileNames[I]));
Added := True;
end;
if not Added then
Exit;
RefreshPool;
UpdateCaption;
if not OverTimeline then
begin
SetStatus('Файлы добавлены в проект.');
Exit;
end;
{ Бросили над монтажом — кладём последний добавленный на дорожку под
указателем. }
lstPool.ItemIndex := FProject.PoolCount - 1;
TrackIdx := FTimeline.TrackIndexAtPoint(Pt.Y);
DropTime := FTimeline.TimeAtPoint(Pt.X);
AddClipFromPool(FProject.PoolCount - 1, TrackIdx, DropTime);
end;
{ Громкость клипов в предпросмотре.
Дорожки смешиваются, поэтому одной общей громкостью не обойтись: у
каждой дорожки под курсором свой клип со своей громкостью. Значения
собираются по дорожкам и отдаются плееру; он подставляет их в свою
цепочку смешивания.
Пересобирать цепочку на каждый кадр нельзя — она создаётся заново и
звук щёлкает. Поэтому только при изменении. }
{ Разметка текста для предпросмотра.
Сравнивается сама разметка, а не имя файла: имя у неё одно на весь
проект, и правка текста его не меняет. Плеер же, увидев прежнее имя,
файл не перечитывал — и новая надпись в окне не появлялась вовсе, пока
не перезагрузится весь список кусков. }
procedure TMainForm.UpdateSubtitles;
var
{ Не Text: так называется свойство формы. }
Path, Markup: string;
begin
if (FProject = nil) or (FPlayer = nil) then
Exit;
if FProject.CacheDir = '' then
Exit;
Markup := BuildAss(FProject);
if Markup = FLastAss then
Exit;
FLastAss := Markup;
Path := IncludeTrailingPathDelimiter(FProject.CacheDir) + 'subs.ass';
if (Markup <> '') and WriteAssFile(FProject, Path) then
FPlayer.SetSubtitleFile(Path, True)
else
FPlayer.SetSubtitleFile('');
end;
{ Кадрирование клипа в предпросмотре.
Цепочка фильтров у mpv одна на весь список кусков, а кадрирование у
каждого клипа своё, и включать его по времени в цепочке нечем: из
фильтров ffmpeg по времени переключается только наложение. Зато у
вывода есть своя обрезка, и делает её видеокарта даром.
Раз она действует на весь вывод, переключать её обязаны мы: курсор
зашёл в клип со своим кадрированием — выставили, вышел — сняли.
Участки с наложением сюда не попадают: там кадр уже сведён цепочкой, и
обрезка на выводе резала бы сведённое целиком, вместе с нижним слоем.
Отражения обрезкой не выразить, растяжение — тоже: такие клипы
по-прежнему показываются как есть, точную картинку для них даёт окно
«Кадрирование и поворот». }
procedure TMainForm.ApplyCropAt(const ATime: Double);
var
I: Integer;
Clip: TClip;
Item: TMediaItem;
Sel: TRect;
Rotation: Integer;
Key: string;
begin
if (FPlayer = nil) or (not FPlayer.Initialized) or (FProject = nil) then
Exit;
Sel := Rect(0, 0, 0, 0);
Rotation := 0;
for I := 0 to High(FPreviewOverlayWindows) do
if (ATime >= FPreviewOverlayWindows[I].TlStart - 0.0005) and
(ATime <= FPreviewOverlayWindows[I].TlEnd + 0.0005) then
begin
FPlayer.SetOutputCrop(Sel, 0);
Exit;
end;
Clip := VisibleClipAtTime(FProject, tkVideo, ATime);
if (Clip <> nil) and (Clip.Kind = ckVideo) and
TransformFitsOutputCrop(Clip.Transform) then
begin
Item := FProject.FindMedia(Clip.MediaId);
if (Item <> nil) and Item.Info.Valid and
(Item.Info.Width > 0) and (Item.Info.Height > 0) then
begin
Rotation := Clip.Transform.Rotation;
if not TransformSourceRect(Clip.Transform,
Item.Info.Width, Item.Info.Height,
FProject.Master.Width, FProject.Master.Height, Sel) then
Sel := Rect(0, 0, 0, 0)
{ Рамка во весь кадр — это отсутствие обрезки. Ставить её значило бы
зря перестраивать вывод на каждом клипе. }
else if (Sel.Left <= 0) and (Sel.Top <= 0) and
(Sel.Right >= Item.Info.Width) and
(Sel.Bottom >= Item.Info.Height) then
Sel := Rect(0, 0, 0, 0);
end;
end;
Key := Format('%d,%d,%d,%d,%d',
[Sel.Left, Sel.Top, Sel.Right, Sel.Bottom, Rotation]);
if Key = FLastCropKey then
Exit;
FLastCropKey := Key;
FPlayer.SetOutputCrop(Sel, Rotation);
end;
procedure TMainForm.ApplyVolumeAt(const ATime: Double);
var
I: Integer;
Clip: TClip;
Track: TTrack;
Vols: array of Double;
Key: string;
begin
if (FPlayer = nil) or (not FPlayer.Initialized) then
Exit;
if FProject = nil then
Exit;
Vols := nil;
Key := '';
for I := 0 to High(FPreviewAudioTracks) do
begin
Track := FProject.FindTrack(FPreviewAudioTracks[I]);
SetLength(Vols, Length(Vols) + 1);
Vols[High(Vols)] := 1;
if Track <> nil then
begin
Clip := Track.ClipAt(ATime);
if Clip <> nil then
Vols[High(Vols)] := Clip.Volume;
end;
Key := Key + SecondsToStr(Vols[High(Vols)]) + ',';
end;
if Key = FLastVolumeKey then
Exit;
FLastVolumeKey := Key;
{ Кто какую громкость получил — в журнал. Иначе разбираться, почему в
одном месте звук есть, а в соседнем нет, приходится по снимкам
экрана. }
Key := '';
for I := 0 to High(FPreviewAudioTracks) do
begin
if Key <> '' then
Key := Key + ', ';
Track := FProject.FindTrack(FPreviewAudioTracks[I]);
if Track <> nil then
Key := Key + Track.Name + '='
else
Key := Key + '?=';
Key := Key + SecondsToStr(Vols[I]);
end;
Log(Format('Громкости на %s: %s', [SecondsToStr(ATime), Key]));
FPlayer.SetTrackVolumes(Vols);
if FPlayer.AudioGraph <> '' then
Log('Цепочка звука: ' + FPlayer.AudioGraph);
end;
procedure TMainForm.GenerateProgress(Sender: TObject);
begin
SetStatus(FGenerate.StageText);
end;
procedure TMainForm.GenerateLog(Sender: TObject);
begin
Log(FGenerate.PendingLog);
end;
procedure TMainForm.GenerateDone(Sender: TObject);
var
Ok, WasCancelled: Boolean;
Msg: string;
begin
Ok := FGenerate.Succeeded;
WasCancelled := FGenerate.Cancelled;
Msg := FGenerate.Message;
FGenerate := nil;
FGenerateBusy := False;
{ Отмену просили мы сами, когда правка сделала работу ненужной. Это не
беда, и говорить «не удалось» тут нельзя: в журнале появлялась строка
об ошибке там, где всё шло по плану. Пересобираем предпросмотр по
свежей расстановке. }
if WasCancelled then
begin
Log('Подготовка вставок прекращена: расстановка изменилась.');
SchedulePreview;
Exit;
end;
if not Ok then
begin
Log('Не удалось подготовить вставки: ' + Msg);
SetStatus('Вставки подготовить не удалось.');
Exit;
end;
{ Файлы появились — теперь список кусков соберётся целиком. }
SchedulePreview;
end;
{ --- Обработчики компонентов --- }
{ Перемотка с отметкой «ждём прибытия».
Плеер после перемотки, а тем более после перезагрузки списка кусков,
какое-то время сообщает старую позицию или ноль. Если верить этим
сообщениям, курсор на монтаже прыгнет в начало — именно это и
происходило при перетаскивании клипа. Поэтому до тех пор, пока плеер
не окажется рядом с запрошенным местом, его сообщения о позиции
игнорируются. }
procedure TMainForm.SeekPlayerTo(const ATime: Double);
begin
if (FPlayer = nil) or (not FPlayer.Initialized) then
Exit;
FSeekTarget := ATime;
FSeekPending := True;
FSeekWaitTicks := 0;
FPlayer.SeekAbsolute(ATime);
end;
procedure TMainForm.TimelinePlayheadChange(Sender: TObject);
begin
UpdateTimeLabel;
{ Кадрирование показывается и на паузе: курсор переставили мышью —
картинка обязана смениться сразу, а не с началом воспроизведения. }
ApplyCropAt(FTimeline.Playhead);
{ Если курсор двинул сам плеер, перематывать его обратно нельзя —
получится бесконечная переброска. }
if FUpdatingFromPlayer then
Exit;
SeekPlayerTo(FTimeline.Playhead);
end;
procedure TMainForm.TimelineStructureChange(Sender: TObject);
begin
SchedulePreview;
UpdateCaption;
UpdateTimeLabel;
end;
procedure TMainForm.TimelineSelectionChange(Sender: TObject);
begin
SetStatus(Format('Выделено %d %s', [FProject.SelectedCount,
Plural(FProject.SelectedCount, 'клип', 'клипа', 'клипов')]), True);
end;
procedure TMainForm.PlayerLog(Sender: TObject; const APrefix, ALevel, AText: string);
begin
Log('[' + ALevel + '] ' + APrefix + ': ' + TrimRight(AText));
end;
procedure TMainForm.PlayerTimePos(Sender: TObject);
begin
if FSeekPending then
begin
Inc(FSeekWaitTicks);
{ Ещё не доехали до запрошенного места — не трогаем курсор.
Но ждём не бесконечно: запрошенное время может оказаться за концом
монтажа, и тогда плеер до него не доедет никогда. }
if (Abs(FPlayer.TimePos - FSeekTarget) > 0.35) and
(FSeekWaitTicks < MaxSeekWaitTicks) then
Exit;
FSeekPending := False;
{ Ждать перестали, а плеер так и не доехал. На паузе это значит, что
курсор надо оставить там, куда его поставил человек: при перезагрузке
списка кусков mpv сбрасывает отсчёт времени («Reset playback due to
audio timestamp reset») и сообщает что угодно, вплоть до конца
монтажа. Раньше курсор туда и улетал после каждой правки — например
после схлопывания дыры, и следующее схлопывание приходилось начинать
с прицеливания мышью заново. }
if FPlayer.Paused and (Abs(FPlayer.TimePos - FSeekTarget) > 0.35) then
Exit;
end;
{ Обновляем курсор только при заметном сдвиге: mpv сообщает позицию на
каждый кадр, и перерисовывать монтаж 60 раз в секунду незачем. }
if Abs(FPlayer.TimePos - FTimeline.Playhead) < 0.01 then
Exit;
ApplyVolumeAt(FPlayer.TimePos);
ApplyCropAt(FPlayer.TimePos);
FUpdatingFromPlayer := True;
try
FTimeline.Playhead := FPlayer.TimePos;
{ Держать курсор посередине или дать монтажу стоять — решает человек.
Выключено — не двигаем окно вовсе, даже когда курсор ушёл за край.
Раньше в этом случае монтаж всё равно подтягивался к курсору, стоило
тому выйти из видимой части: прокрутил колесом с Shift — первый
поворот сдвинул, второй вернул обратно. Человек отматывал
осознанно, и возвращать его силой нельзя. }
if (not FPlayer.Paused) and FSettings.CenterPlayhead then
FTimeline.ScrollToPlayhead(True);
finally
FUpdatingFromPlayer := False;
end;
end;
procedure TMainForm.PlayerDuration(Sender: TObject);
var
Hw: string;
begin
UpdateTimeLabel;
{ Момент, когда файл уже открыт и про декодирование известно всё.
Сообщаем один раз на загрузку и только при смене: если декодирует
процессор, тяжёлый материал поедет рывками, и знать об этом надо
заранее, а не гадать, почему предпросмотр дёргается. }
Hw := FPlayer.HwdecCurrent;
if Hw = FLastHwdec then
Exit;
FLastHwdec := Hw;
if (Hw = '') or (Hw = 'no') then
begin
Log('Внимание: видео декодирует процессор. ' +
'Тяжёлый материал может идти рывками.');
{ Больше не пробуем. Иначе на каждой правке монтажа mpv заново
перебирает способы аппаратного декодирования, каждый раз впустую и
каждый раз с десятком строк ошибок в журнале. Так ведёт себя Wine,
где ни DXVA2, ни CUDA не работают. }
FPlayer.SetHwdec('no');
end
else
Log('Декодирование видео: ' + Hw);
{ Откуда взялся звук. Строка меняется редко, поэтому пишется только при
изменении — иначе журнал забился бы повторами. }
if FPlayer.AudioTrackNote <> FLastAudioNote then
begin
FLastAudioNote := FPlayer.AudioTrackNote;
if FLastAudioNote <> '' then
Log(FLastAudioNote);
end;
end;
procedure TMainForm.PlayerPauseChange(Sender: TObject);
begin
{ Отдельной кнопки пуска нет — пробел делает то же самое, а состояние
видно по тому, едет ли курсор. Здесь остаётся только отметка в
строке состояния. }
if FPlayer.Paused then
SetStatus('Пауза')
else
SetStatus('Воспроизведение');
end;
{ Пуск и пауза. При запуске подтягиваем окно просмотра к курсору: если
он уехал за край, воспроизведение шло бы вслепую. Масштаб при этом не
трогаем — он выставлен осознанно. }
procedure TMainForm.TogglePlayback;
begin
if (FPlayer = nil) or (not FPlayer.Initialized) then
Exit;
if FPlayer.Paused then
FTimeline.ScrollToPlayhead(False);
FPlayer.TogglePause;
end;
procedure TMainForm.FormKeyDown(Sender: TObject; var Key: Word;
Shift: TShiftState);
begin
{ Пробел обрабатываем здесь, а не пунктом меню: пункт меню с пробелом
в качестве сочетания мешает вводу текста в других полях. }
if (Key = VK_SPACE) and (Shift = []) then
begin
Key := 0;
TogglePlayback;
Exit;
end;
{ Обратная косая — «покажи, где курсор»: окно едет к нему и ставит его
посередине. Жмут её чаще всего после реза или выделения, когда курсор
стоит на границе и надо к ней вернуться.
Раньше она переключала режим «курсор посередине». Переключатель
остался кнопкой с целью внизу: включают его раз и надолго, а
подтягивать окно к курсору нужно постоянно.
Пункта меню для неё нет намеренно — сочетание из одной такой клавиши
в меню выглядит как опечатка, а работать должно. }
if (Key = VK_OEM_5) and (Shift = []) then
begin
Key := 0;
FTimeline.ScrollToPlayhead(True);
SetStatus('Окно у курсора: ' + FormatTimecode(FTimeline.Playhead), True);
end;
end;
{ --- Пункты меню --- }
procedure TMainForm.MenuNewProject(Sender: TObject);
begin
if not ConfirmDiscard then
Exit;
FProject.NewProject;
FTimeline.Playhead := 0;
FTimeline.ZoomToFit;
FLastLoadedEdl := '';
RefreshPool;
UpdateCaption;
SchedulePreview;
Log('Новый проект.');
end;
procedure TMainForm.MenuOpenProject(Sender: TObject);
var
Err: string;
begin
if not ConfirmDiscard then
Exit;
if not OpenProjectDialog.Execute then
Exit;
FSettings.LastProjectDir := ExtractFilePath(OpenProjectDialog.FileName);
OpenProjectFile(OpenProjectDialog.FileName);
end;
function TMainForm.SaveProjectAs: Boolean;
var
Err: string;
begin
Result := False;
if not SaveProjectDialog.Execute then
Exit;
FSettings.LastProjectDir := ExtractFilePath(SaveProjectDialog.FileName);
if not FProject.SaveToFile(SaveProjectDialog.FileName, Err) then
begin
ThemedMessageDlg('nlve', Err, mtError, [mbOK], 0);
Exit;
end;
UpdateCaption;
FSettings.NoteRecentProject(FProject.FileName);
RefreshRecentMenu;
Log('Проект сохранён: ' + FProject.FileName);
Result := True;
end;
function TMainForm.DoSaveProject: Boolean;
var
Err: string;
begin
if FProject.FileName = '' then
Exit(SaveProjectAs);
Result := FProject.SaveToFile(FProject.FileName, Err);
if not Result then
ThemedMessageDlg('nlve', Err, mtError, [mbOK], 0)
else
begin
UpdateCaption;
{ Сохранение должно быть видно. Раньше о нём говорила только строчка
в журнале, а журнал по умолчанию закрыт: человек нажимал Ctrl+S,
ничего не происходило, и понять, дошло сохранение до файла или нет,
было не по чему. }
SetStatus('Проект сохранён: ' + ExtractFileName(FProject.FileName) +
', ' + FormatDateTime('hh:nn:ss', Now), True);
Log('Проект сохранён: ' + FProject.FileName);
{ Копия для восстановления больше не нужна: в файле то же самое. }
DropAutoSave;
end;
end;
{ Где лежит автосохранённая копия проекта. Рядом с самим проектом, чтобы
она нашлась там же, где его ищут. У безымянного проекта её нет: некуда
положить, и восстанавливать потом нечего. }
function TMainForm.AutoSavePath: string;
begin
if FProject.FileName = '' then
Result := ''
else
Result := FProject.FileName + AutoSaveSuffix;
end;
procedure TMainForm.DropAutoSave;
var
Path: string;
begin
Path := AutoSavePath;
if (Path <> '') and FileExists(Path) then
DeleteFile(Path);
end;
{ Копия проекта пишется, пока в нём есть несохранённое.
Это не замена сохранению: файл проекта не трогается, имя не меняется,
признак «изменён» остаётся. Копия нужна на случай, когда работа всё же
пропала — программу закрыли, она упала, сохранение не дошло до файла. }
procedure TMainForm.AutoSaveTick(Sender: TObject);
var
Path, Err: string;
begin
if FProject = nil then
Exit;
Path := AutoSavePath;
if Path = '' then
Exit;
{ Несохранённого нет — писать нечего. Копию при этом не трогаем: она
может быть единственным следом прошлой работы, а спросить о ней ещё
не успели. Убирается она там, где точно больше не нужна: после
сохранения проекта и после ответа на вопрос при открытии. }
if not FProject.Modified then
Exit;
if not FProject.SaveCopyTo(Path, Err) then
begin
{ Жалуемся один раз и больше не мешаем: автосохранение — подстраховка,
а не работа, ради которой человек сюда пришёл. }
if not FAutoSaveBroken then
begin
FAutoSaveBroken := True;
Log('Автосохранение не работает: ' + Err);
end;
end
else
FAutoSaveBroken := False;
end;
{ Проверка при открытии: не осталась ли копия новее самого проекта.
Так бывает ровно в одном случае — работа не дошла до файла проекта.
Молча подменять файл нельзя, поэтому спрашиваем и показываем время. }
procedure TMainForm.OfferAutoSave;
var
Path, Err: string;
CopyStamp, ProjStamp: LongInt;
CopyTime, ProjTime: TDateTime;
begin
Path := AutoSavePath;
if (Path = '') or (not FileExists(Path)) then
Exit;
CopyStamp := FileAge(Path);
ProjStamp := FileAge(FProject.FileName);
if (CopyStamp < 0) or (ProjStamp < 0) then
Exit;
CopyTime := FileDateToDateTime(CopyStamp);
ProjTime := FileDateToDateTime(ProjStamp);
{ Копия старше проекта — значит проект сохранён позже неё, и она уже
ни о чём не говорит. }
if CopyTime <= ProjTime + 1 / (24 * 60 * 60) then
begin
DeleteFile(Path);
Exit;
end;
Log(Format('Найдена автосохранённая копия от %s, проект сохранён %s.',
[FormatDateTime('dd.mm hh:nn:ss', CopyTime),
FormatDateTime('dd.mm hh:nn:ss', ProjTime)]));
if ThemedMessageDlg('nlve',
Format('Рядом с проектом лежит автосохранённая копия от %s, ' +
'а сам проект сохранён %s.' + LineEnding + LineEnding +
'Похоже, последние правки не дошли до файла проекта. ' +
'Открыть копию?' + LineEnding + LineEnding +
'Проект при этом не перезаписывается: копия просто откроется, и ' +
'сохранить её поверх проекта можно будет самому. Откажетесь — ' +
'копия останется лежать рядом, пока проект не сохранят заново.',
[FormatDateTime('dd.mm.yyyy hh:nn:ss', CopyTime),
FormatDateTime('dd.mm.yyyy hh:nn:ss', ProjTime)]),
mtConfirmation, [mbYes, mbNo], 0) <> mrYes then
Exit;
if not FProject.LoadFromFile(Path, Err) then
begin
ThemedMessageDlg('nlve', Err, mtError, [mbOK], 0);
Exit;
end;
{ Имя возвращаем проектное: копия открыта, но работать человек
продолжает с проектом, и Ctrl+S должен писать в него. }
FProject.FileName := Copy(Path, 1, Length(Path) - Length(AutoSaveSuffix));
FProject.Modified := True;
RefreshPool;
UpdateCaption;
FLastLoadedEdl := '';
SchedulePreview;
SetStatus('Открыта автосохранённая копия. Проверьте и сохраните: Ctrl+S.',
True);
end;
procedure TMainForm.MenuSaveProject(Sender: TObject);
begin
DoSaveProject;
end;
procedure TMainForm.MenuSaveProjectAs(Sender: TObject);
begin
SaveProjectAs;
end;
function TMainForm.ConfirmDiscard: Boolean;
begin
Result := True;
if not FProject.Modified then
Exit;
case ThemedMessageDlg('nlve', 'Проект изменён. Сохранить?',
mtConfirmation, [mbYes, mbNo, mbCancel], 0) of
mrYes: Result := DoSaveProject;
mrNo: Result := True;
else
Result := False;
end;
end;
procedure TMainForm.FormCloseQuery(Sender: TObject; var CanClose: Boolean);
begin
CanClose := ConfirmDiscard;
end;
procedure TMainForm.MenuQuit(Sender: TObject);
begin
Close;
end;
procedure TMainForm.MenuDeps(Sender: TObject);
begin
ShowDeps(Self);
{ Библиотека могла появиться только что. Перезапуск ради этого требовать
незачем: загрузка идёт по имени, и вторая попытка ничем не хуже
первой. }
if (FPlayer <> nil) and (not FPlayer.Initialized) then
begin
Log('Проверяю, появилась ли libmpv...');
StartPlayer;
if FPlayer.Initialized then
begin
FLastLoadedEdl := '';
SchedulePreview;
SetStatus('Предпросмотр заработал.');
end;
end;
end;
procedure TMainForm.MenuRemux(Sender: TObject);
var
Form: TRemuxForm;
begin
Form := TRemuxForm.Create(Self);
try
Form.ShowModal;
finally
Form.Free;
end;
end;
procedure TMainForm.MenuUndo(Sender: TObject);
begin
if not FProject.CanUndo then
begin
SetStatus('Отменять нечего.');
Exit;
end;
FProject.Undo;
RefreshPool;
FTimeline.Invalidate;
SchedulePreview;
UpdateCaption;
SetStatus('Отменено.', True);
end;
procedure TMainForm.MenuRedo(Sender: TObject);
begin
if not FProject.CanRedo then
begin
SetStatus('Повторять нечего.');
Exit;
end;
FProject.Redo;
RefreshPool;
FTimeline.Invalidate;
SchedulePreview;
UpdateCaption;
SetStatus('Повторено.', True);
end;
{ Рез отчитывается числом. Молчание после нажатия клавиши читается как
«она не работает»: именно так выглядел рез, когда курсор стоял на стыке
или на пустом месте. }
function SplitReport(ACount: Integer): string;
begin
if ACount = 0 then
Result := 'Резать нечего: под курсором нет клипов или он ровно на стыке.'
else
Result := Format('Разрезано %d %s.', [ACount,
Plural(ACount, 'клип', 'клипа', 'клипов')]);
end;
procedure TMainForm.MenuSplit(Sender: TObject);
begin
SetStatus(SplitReport(FTimeline.SplitAtPlayhead(False)), True);
end;
procedure TMainForm.MenuDelete(Sender: TObject);
begin
FTimeline.DeleteSelected;
end;
{ Цветовой ключ выделенного клипа.
Ключ принадлежит клипу, а не файлу: один и тот же кусок может лежать в
монтаже дважды, и выбивать цвет нужно не всегда.
Клип с ключом перестаёт быть сплошным, и участок под ним собирается
слоями — тем же способом, что и полупрозрачный. Значит, эти секунды
придётся пересчитать; об этом окно предупреждает. }
procedure TMainForm.MenuColorKey(Sender: TObject);
var
T, C: Integer;
Track: TTrack;
Found: TClip;
Item: TMediaItem;
Key: TColorKey;
begin
Found := nil;
for T := 0 to FProject.TrackCount - 1 do
begin
Track := FProject.Tracks[T];
for C := 0 to Track.Count - 1 do
if Track[C].Selected and (Track[C].Kind in [ckVideo, ckImage]) then
begin
Found := Track[C];
Break;
end;
if Found <> nil then
Break;
end;
if Found = nil then
begin
SetStatus('Выделите клип с картинкой: ключ выбивает цвет из неё.');
Exit;
end;
Item := FProject.FindMedia(Found.MediaId);
if Item = nil then
Exit;
Key := Found.ColorKey;
if not EditColorKey(Key, Item.EffectivePath,
Found.SrcIn + Found.Duration / 2, FProject.CacheDir + 'colorkey.png') then
Exit;
FProject.BeginChange;
Found.ColorKey := Key;
FTimeline.Invalidate;
FLastLoadedEdl := '';
SchedulePreview;
UpdateCaption;
if Key.Enabled then
SetStatus('Цветовой ключ включён: участок будет пересобран.', True)
else
SetStatus('Цветовой ключ выключен.', True);
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, 'клип', 'клипа', 'клипов')]), True);
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, 'клип', 'клипа', 'клипов')]), True);
end;
{ Первый выделенный клип: с него берут свойства. }
function TMainForm.FirstSelectedClip: TClip;
var
T, C: Integer;
begin
Result := nil;
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
Exit(FProject.Tracks[T][C]);
end;
{ Копирование оформления с клипа на клип.
Настроить кадрирование, ключ и прозрачность — работа на минуты, а
клипов с одинаковой настройкой в монтаже бывает десяток: та же
миникарта, разрезанная на куски, или несколько снимков в одной рамке. }
procedure TMainForm.MenuCopyLook(Sender: TObject);
var
Src: TClip;
begin
Src := FirstSelectedClip;
if Src = nil then
begin
SetStatus('Сначала выделите клип, с которого брать свойства.');
Exit;
end;
if FLookClip = nil then
FLookClip := TClip.Create;
FLookClip.Kind := Src.Kind;
FLookClip.CopyLookFrom(Src);
SetStatus('Свойства клипа скопированы. Применить: Ctrl+Shift+V.', True);
end;
procedure TMainForm.MenuPasteLook(Sender: TObject);
var
T, C, N: Integer;
begin
if FLookClip = nil then
begin
SetStatus('Сначала скопируйте свойства клипа: Ctrl+Shift+C.');
Exit;
end;
N := 0;
FProject.BeginChange;
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
begin
FProject.Tracks[T][C].CopyLookFrom(FLookClip);
Inc(N);
end;
if N = 0 then
begin
FProject.CancelChange;
SetStatus('Выделите клипы, к которым применить свойства.');
Exit;
end;
FTimeline.Invalidate;
FLastLoadedEdl := '';
SchedulePreview;
UpdateCaption;
SetStatus(Format('Свойства применены к %d %s.',
[N, Plural(N, 'клипу', 'клипам', 'клипам')]), True);
end;
procedure TMainForm.MenuRegionIn(Sender: TObject);
begin
FProject.RegionStart := FTimeline.Playhead;
if FProject.RegionEnd <= FProject.RegionStart then
FProject.RegionEnd := FProject.EndTime;
FTimeline.Invalidate;
UpdateCaption;
SetStatus(Format('Участок: %s — %s',
[SecondsToStr(FProject.RegionStart), SecondsToStr(FProject.RegionEnd)]), True);
end;
procedure TMainForm.MenuRegionOut(Sender: TObject);
var
Tmp: Double;
begin
FProject.RegionEnd := FTimeline.Playhead;
if FProject.RegionEnd < FProject.RegionStart then
begin
Tmp := FProject.RegionStart;
FProject.RegionStart := FProject.RegionEnd;
FProject.RegionEnd := Tmp;
end;
FTimeline.Invalidate;
UpdateCaption;
SetStatus(Format('Участок: %s — %s',
[SecondsToStr(FProject.RegionStart), SecondsToStr(FProject.RegionEnd)]), True);
end;
procedure TMainForm.MenuRegionClear(Sender: TObject);
begin
FProject.RegionStart := 0;
FProject.RegionEnd := 0;
FTimeline.Invalidate;
UpdateCaption;
SetStatus('Отметки участка сняты: собирается весь монтаж.', True);
end;
{ Границы того, что собираем. Без отметок — весь монтаж. }
procedure TMainForm.DescribeRegion(out AFrom, ATo: Double);
begin
if FProject.HasRegion then
begin
AFrom := Max(0, FProject.RegionStart);
ATo := FProject.RegionEnd;
end
else
begin
AFrom := 0;
ATo := FProject.EndTime;
end;
end;
procedure TMainForm.MenuSelectAll(Sender: TObject);
begin
FTimeline.SelectAll;
end;
procedure TMainForm.MenuRipple(Sender: TObject);
begin
{ Команда должна отвечать всегда: молчание читается как «не работает».
Дорожку называем: дыра могла найтись не на той, где стоял указатель,
и сдвиг чужой дорожки должен быть виден. }
if FTimeline.RippleCloseGap then
SetStatus('Дыра на дорожке ' + FTimeline.LastGapTrack + ' схлопнута.', True)
else
SetStatus('Под курсором дыры нет: схлопывать нечего.', True);
end;
{ К началу самого раннего клипа по всем дорожкам. Если монтаж начинается
с пятой секунды, ноль — не то место, куда хотят попасть. }
{ Показывать ли в предпросмотре то, что даст сборка.
Выключено — оверлей виден целиком, со своим фоном: так понятно, куда он
встал и где его края. Включено — выбитый цвет и прозрачность считаются
на лету, и окно совпадает с готовым роликом.
Считается это не бесплатно, хотя и недорого: фильтр работает по площади
оверлея, а не всего кадра. Замерено на 2688x1512: наложение стоит 0.36 с
на десять секунд материала, ключ добавляет ещё 0.23 с. }
procedure TMainForm.MenuTogglePreviewKey(Sender: TObject);
begin
FSettings.PreviewKey := not FSettings.PreviewKey;
FKeyPreviewItem.Checked := FSettings.PreviewKey;
{ Список кусков не изменился, а показывать надо иначе — заставляем
пересобрать цепочку. }
FLastLoadedEdl := '';
SchedulePreview;
if FSettings.PreviewKey then
SetStatus('Предпросмотр показывает ключ и прозрачность.')
else
SetStatus('Предпросмотр показывает оверлей как есть.');
end;
procedure TMainForm.MenuToggleRipple(Sender: TObject);
begin
ToggleRipple;
end;
procedure TMainForm.MenuGoStart(Sender: TObject);
begin
FTimeline.Playhead := FTimeline.FirstClipTime;
FTimeline.ScrollToPlayhead(False);
end;
{ К концу самого позднего клипа по всем дорожкам. }
procedure TMainForm.MenuGoEnd(Sender: TObject);
begin
FTimeline.Playhead := FTimeline.LastClipTime;
FTimeline.ScrollToPlayhead(False);
end;
procedure TMainForm.MenuZoomFit(Sender: TObject);
begin
FTimeline.ZoomToFit;
end;
procedure TMainForm.MenuZoomIn(Sender: TObject);
begin
FTimeline.ZoomAt(1.5, FTimeline.ClientWidth div 2);
end;
procedure TMainForm.MenuZoomOut(Sender: TObject);
begin
FTimeline.ZoomAt(1 / 1.5, FTimeline.ClientWidth div 2);
end;
procedure TMainForm.MenuAddTrack(Sender: TObject);
begin
FProject.BeginChange;
FProject.AddTrack(FProject.UniqueTrackName('V'));
FTimeline.Invalidate;
UpdateCaption;
end;
procedure TMainForm.MenuRemoveTrack(Sender: TObject);
begin
if FProject.TrackCount <= 1 then
begin
SetStatus('Последнюю дорожку удалить нельзя.');
Exit;
end;
FProject.BeginChange;
FProject.RemoveTrack(FProject.TrackCount - 1);
if FTimeline.ActiveTrack >= FProject.TrackCount then
FTimeline.ActiveTrack := FProject.TrackCount - 1;
FTimeline.Invalidate;
SchedulePreview;
UpdateCaption;
end;
{ --- Приведение файла к параметрам проекта --- }
procedure TMainForm.NormalizeProgress(Sender: TObject);
begin
if Assigned(FNormalizeUi) then
begin
FNormalizeUi.SetStage(FNormalize.Title);
FNormalizeUi.SetFraction(FNormalize.Fraction);
end;
end;
procedure TMainForm.NormalizeLog(Sender: TObject);
begin
if Assigned(FNormalizeUi) then
FNormalizeUi.AddLog(FNormalize.PendingLog);
end;
procedure TMainForm.NormalizeDone(Sender: TObject);
begin
if Assigned(FNormalizeUi) then
FNormalizeUi.Finish(FNormalize.Succeeded, FNormalize.Message);
end;
procedure TMainForm.NormalizeCancel(Sender: TObject);
begin
if Assigned(FNormalize) then
FNormalize.RequestCancel;
end;
{ --- Сборка ролика --- }
{ Обычная сборка: копирование потоков, ради чего редактор и делался. }
procedure TMainForm.MenuExport(Sender: TObject);
begin
DoExport(Default(TEncodeProfile));
end;
{ Сборка с перекодированием: отдельный пункт, потому что это осознанный
выбор — ролик пересобирается целиком, и быстрым это уже не будет. }
procedure TMainForm.MenuExportEncode(Sender: TObject);
var
Profile: TEncodeProfile;
begin
if not AskEncodeProfile(Profile, FProject.Master.VideoCodec) then
Exit;
DoExport(Profile);
end;
procedure TMainForm.DoExport(const AProfile: TEncodeProfile);
var
Segments, AudioSegs: TFlatSegmentArray;
Missing: TGenRequests;
HasPrecise: Boolean;
T, C: Integer;
Rebuilt, RegionFrom, RegionTo: Double;
SubsPath, RegionCaption, Extra: string;
Master: TMediaInfo;
begin
if not FlattenProject(FProject, Segments, AudioSegs, Missing) then
begin
ThemedMessageDlg('nlve', 'На монтаже нечего собирать.', mtInformation, [mbOK], 0);
Exit;
end;
if Length(Missing) > 0 then
begin
ThemedMessageDlg('nlve',
'Ещё не готовы вставки для картинок или пустых мест. ' +
'Подождите, пока они создадутся, и повторите.',
mtInformation, [mbOK], 0);
SchedulePreview;
Exit;
end;
DescribeRegion(RegionFrom, RegionTo);
RegionCaption := '';
if FProject.HasRegion then
RegionCaption := SecondsToStr(RegionFrom) + ' — ' + SecondsToStr(RegionTo);
{ Сколько материала придётся пересобрать. Наложение, переход,
полупрозрачность и чужие параметры файла — всё это перекодирование, и
человек должен знать цену заранее, а не обнаружить её по времени
сборки. }
Rebuilt := 0;
{ Если есть хоть один точный рез, предупреждаем про контейнер: в MP4
перекодированный кусок и скопированный уживаются не всегда, а MKV
к смене параметров потока терпимее.
Признак — клип, начинающийся не с начала файла: попасть началом ровно
на опорный кадр можно только случайно, а во всех прочих случаях
огрызок до ближайшего опорного кадра перекодируется. Отметка edPrecise
сюда больше не годится: резы теперь точные всегда, независимо от неё. }
HasPrecise := False;
for T := 0 to FProject.TrackCount - 1 do
for C := 0 to FProject.Tracks[T].Count - 1 do
if (FProject.Tracks[T][C].Kind = ckVideo) and
(FProject.Tracks[T][C].SrcIn > 0.0001) then
HasPrecise := True;
{ Куда собирать, в каком контейнере и что делать с текстом — один
вопрос: ответ на любую его часть меняет допустимое в остальных. }
if not AskExport(FExportChoice, HasText(FProject), HasPrecise,
FSettings.LastExportDir, RegionCaption) then
Exit;
FSettings.LastExportDir := ExtractFilePath(FExportChoice.Path);
{ Участок режется после вопроса: человек мог снять галку и собрать всё.
Картинка и звук подрезаются по одному времени, иначе они разъедутся. }
if not (FProject.HasRegion and FExportChoice.RegionOnly) then
begin
RegionFrom := 0;
RegionTo := TotalDuration(Segments);
end
else
begin
{ Края участка — тоже на кадровую сетку. Иначе подрезка вернёт в
крайние куски доли кадра, которые уплощение только что убрало, и
картинка с звуком на краях участка разойдутся на полкадра. }
RegionFrom := FrameAlign(RegionFrom, FProject.Master);
RegionTo := FrameAlign(RegionTo, FProject.Master);
ClipSegmentsToRange(Segments, RegionFrom, RegionTo);
ClipSegmentsToRange(AudioSegs, RegionFrom, RegionTo);
if Length(Segments) = 0 then
begin
ThemedMessageDlg('nlve',
'В отмеченном участке нечего собирать.', mtInformation, [mbOK], 0);
Exit;
end;
end;
if not AskContainer(HasPrecise) then
Exit;
{ Сколько материала придётся пересобрать — считаем уже по тому списку,
который пойдёт в сборку: с участком он короче, и обещать человеку
работу на весь ролик было бы неправдой. }
for T := 0 to High(Segments) do
if Segments[T].Transcode or (Length(Segments[T].Layers) > 0) then
Rebuilt := Rebuilt + Segments[T].Duration;
{ Вжигание меняет расклад: куски с текстом перестают копироваться.
Готовим их до подсчёта того, сколько придётся пересобрать. }
SubsPath := '';
if HasText(FProject) then
begin
if FExportChoice.BurnText then
{ Начало списка — не обязательно начало монтажа: при отмеченном
участке текст надо брать с его начала. }
Rebuilt := Rebuilt +
BurnTextIntoSegments(FProject, Segments, FProject.CacheDir, RegionFrom)
else
begin
SubsPath := IncludeTrailingPathDelimiter(FProject.CacheDir) +
'export.ass';
{ Разметка тоже по участку: иначе субтитры в готовом файле
появлялись бы со сдвигом на его начало. }
if not WriteAssForRange(FProject, RegionFrom, RegionTo, SubsPath) then
SubsPath := '';
end;
end;
if Rebuilt > 0.5 then
if ThemedMessageDlg('nlve',
Format('Часть ролика придётся пересобрать: %s с из %s.' + LineEnding +
LineEnding +
'Пересобираются участки с наложением, переходами, прозрачностью, ' +
'вжигаемым текстом и куски файлов, не совпадающих с параметрами ' +
'проекта. Остальное копируется без потерь и почти мгновенно.' +
LineEnding + LineEnding +
'Продолжить?',
[SecondsToStr(Rebuilt), SecondsToStr(TotalDuration(Segments))]),
mtConfirmation, [mbYes, mbNo], 0) <> mrYes then
Exit;
{ Пересобираемые куски целятся в битрейт самого щедрого исходника, а не
в то, что кодировщик выберет сам: его выбор скромнее в разы, и
пересобранный кусок оказывается заметно хуже соседних скопированных. }
Master := FProject.Master;
Master.VideoBitrate := FProject.MaxVideoBitrate;
{ Журнал этой сборки: рядом с готовым файлом, под тем же именем.
Прежний одноимённый убираем — иначе две сборки слиплись бы в один
файл, и разобрать, где чья, было бы нельзя. }
FExportLogPath := ChangeFileExt(FExportChoice.Path, '.log');
if FileExists(FExportLogPath) then
SysUtils.DeleteFile(FExportLogPath);
LogExport(Format('nlve, сборка от %s',
[FormatDateTime('yyyy-mm-dd hh:nn:ss', Now)]));
LogExport('Проект: ' + FProject.FileName);
LogExport('Готовый файл: ' + FExportChoice.Path);
if FProject.HasRegion and FExportChoice.RegionOnly then
LogExport(Format('Собирается отмеченный участок: %s — %s',
[SecondsToStr(RegionFrom), SecondsToStr(RegionTo)]))
else
LogExport('Собирается весь монтаж.');
LogExport(Format('Кадр проекта: %dx%d, %d/%d к/с, %s, %s',
[Master.Width, Master.Height, Master.FpsNum, Master.FpsDen,
Master.VideoCodec, Master.PixFmt]));
LogExport(Format('Целевой битрейт: %d кбит/с (наибольший среди файлов проекта)',
[Master.VideoBitrate div 1000]));
if FExportChoice.BurnText then
LogExport('Текст вжигается в картинку.')
else if SubsPath <> '' then
LogExport('Текст идёт отдельной дорожкой субтитров.')
else if HasText(FProject) then
LogExport('ВНИМАНИЕ: текст в проекте есть, но в сборку не попадает.');
LogExport(Format('%d %s, длительность %s с',
[Length(Segments), Plural(Length(Segments), 'кусок', 'куска', 'кусков'),
SecondsToStr(TotalDuration(Segments))]));
{ Что именно собирается — по кускам. Без этого списка по журналу нельзя
понять, попал ли в сборку тот клип, которого потом не нашли в ролике. }
for T := 0 to High(Segments) do
begin
Extra := '';
if Length(Segments[T].Layers) > 0 then
Extra := Extra + ', слоёв ' + IntToStr(Length(Segments[T].Layers));
if Segments[T].SubFile <> '' then
Extra := Extra + ', с текстом';
if Segments[T].Transcode then
Extra := Extra + ', пересобирается';
LogExport(Format(' кусок %d: %s, из %s, длина %s%s',
[T + 1, ExtractFileName(Segments[T].FilePath),
SecondsToStr(Segments[T].SrcStart), SecondsToStr(Segments[T].Duration),
Extra]));
end;
{ Временные куски после удачной сборки убираются: они весят столько же,
сколько готовый ролик, а нужны только чтобы продолжить прерванную
сборку. После неудачи их никто не трогает. }
FExport := TExportThread.Create(Segments, AudioSegs,
FExportChoice.Path, FSettings.KeepParts, Master, AProfile);
FExport.SubtitlePath := SubsPath;
FExport.LogPath := FExportLogPath;
FExport.Resume := AskResume(FExportChoice.Path);
FExport.OnProgress := @ExportProgress;
FExport.OnLog := @ExportLog;
FExport.OnTerminate := @ExportDone;
FExportUi := TProgressForm.Create(Self);
try
{ Не «Сборка ролика»: так называется окно настроек, и два окна с
одинаковым заголовком путают. }
FExportUi.Caption := 'Ход сборки';
FExportUi.OnCancelRequest := @ExportCancel;
FExport.Start;
FExportUi.ShowModal;
finally
FreeAndNil(FExportUi);
end;
if Assigned(FExport) then
begin
FExport.WaitFor;
if FExport.ReusedParts > 0 then
LogExport(Format('Взято готовыми из прошлой сборки: %d %s.',
[FExport.ReusedParts,
Plural(FExport.ReusedParts, 'кусок', 'куска', 'кусков')]));
ShowExportReport(FExport.Report);
FreeAndNil(FExport);
end;
end;
procedure TMainForm.ExportProgress(Sender: TObject);
begin
if Assigned(FExportUi) then
begin
FExportUi.SetStage(FExport.StageText);
FExportUi.SetFraction(FExport.Fraction);
end;
end;
procedure TMainForm.ExportLog(Sender: TObject);
begin
if Assigned(FExportUi) then
FExportUi.AddLog(FExport.PendingLog);
end;
procedure TMainForm.ExportCancel(Sender: TObject);
begin
if Assigned(FExport) then
FExport.RequestCancel;
end;
procedure TMainForm.ExportDone(Sender: TObject);
var
Rep: TExportReport;
begin
if not Assigned(FExportUi) then
Exit;
Rep := FExport.Report;
FExportUi.Finish(Rep.Succeeded, Rep.Message);
if Rep.Succeeded then
FExportUi.AddLog('Файл: ' + Rep.OutputPath);
end;
{ Журнал сборки лежит рядом с готовым файлом и называется так же.
Зачем отдельно от общего журнала программы: тот перезаписывается при
следующем запуске, а разбираться, почему в ролике не то, приходится
потом — иногда через неделю. Рядом с файлом журнал никуда не денется и
не перепутается с чужим. }
procedure TMainForm.LogExport(const AText: string);
var
F: TextFile;
begin
Log(AText);
if FExportLogPath = '' then
Exit;
try
AssignFile(F, FExportLogPath);
if FileExists(FExportLogPath) then
Append(F)
else
Rewrite(F);
try
WriteLn(F, AText);
finally
CloseFile(F);
end;
except
on E: Exception do
FExportLogPath := '';
end;
end;
{ Прерванная сборка: куски от неё лежат в кэше рядом с готовым файлом.
Спрашиваем, а не продолжаем молча: человек мог править монтаж между
попытками, и тогда «готовые» куски — от другой расстановки. Сама сборка
это проверит по отпечатку плана и при несовпадении соберёт заново, но
знать об этом человек должен заранее. }
{ Выбранный контейнер и что он за собой тянет.
У MP4 два недостатка, о которых человек узнаёт обычно поздно. Первый:
оглавление пишется в конец файла, и оборванная сборка не открывается
вовсе — продолжить её нельзя, только собрать заново. На четырёхчасовом
фильме это стоит часов работы. Второй: перекодированный стык точного
реза в нём воспроизводится не везде правильно.
Поэтому предлагается MKV — он лишён обоих, — а переупаковать его в MP4
можно потом, копированием потоков, без перекодирования. }
function TMainForm.AskContainer(AHasPrecise: Boolean): Boolean;
var
Text_, Ext: string;
begin
Result := True;
Ext := LowerCase(ExtractFileExt(FExportChoice.Path));
if Ext = '.mkv' then
Exit;
Ext := Copy(Ext, 2, 10);
Text_ := 'Прерванную сборку в ' + Ext + ' продолжить не удастся: ' +
'оглавление пишется в конец файла, и оборванный файл не открывается ' +
'вовсе. На длинном фильме это значит собирать всё заново.' + LineEnding;
if AHasPrecise then
Text_ := Text_ + 'К тому же в монтаже есть точные резы, а ' +
'перекодированный стык в этом контейнере воспроизводится ' +
'правильно не везде.' + LineEnding;
Text_ := Text_ + 'MKV этого лишён, а переупаковать его потом в ' + Ext +
' можно без перекодирования — «Перепаковка файлов...» в меню «Файл».';
case ThemedChoiceDlg('nlve', Text_,
['Собрать в MKV', 'Всё равно ' + Ext, 'Не собирать']) of
0:
begin
FExportChoice.Path := ChangeFileExt(FExportChoice.Path, '.mkv');
Log('Собираю в MKV: ' + FExportChoice.Path);
end;
1: ;
else
Result := False;
end;
end;
function TMainForm.AskResume(const AOutputPath: string): Boolean;
var
DonePath: string;
Done: TStringList;
N: Integer;
begin
Result := False;
{ Сборка потоком не оставляет кусков: продолжать там можно с самого
недописанного файла. Что он недописан, видно по отметке рядом — сам
по себе оборванный MKV открывается и играет. }
N := UnfinishedFrames(AOutputPath);
if (N > 0) and FileExists(AOutputPath) and
(LowerCase(ExtractFileExt(AOutputPath)) = '.mkv') then
begin
Result := ThemedMessageDlg('nlve',
Format('Прошлая сборка этого файла оборвалась, в нём %d %s.',
[N, Plural(N, 'кадр', 'кадра', 'кадров')]) +
LineEnding + LineEnding +
'Да — продолжить с того места, где оборвалось.' + LineEnding +
'Нет — собрать заново с начала, файл будет заменён.' + LineEnding +
LineEnding +
'Если монтаж с тех пор менялся, продолжать нечего: собранная часть ' +
'сделана по прежнему монтажу.',
mtConfirmation, [mbYes, mbNo], 0) = mrYes;
if Result then
Exit;
end;
DonePath := CacheDirFor(AOutputPath) + 'готово.txt';
if not FileExists(DonePath) then
Exit;
Done := TStringList.Create;
try
try
Done.LoadFromFile(DonePath);
except
Exit;
end;
{ Первая строка — отпечаток плана, куски идут дальше. }
N := Done.Count - 1;
if N <= 0 then
Exit;
Result := ThemedMessageDlg('nlve',
Format('От прошлой сборки осталось готовых кусков: %d.' + LineEnding +
LineEnding +
'Продолжить с них? Если монтаж с тех пор менялся, куски не ' +
'подойдут — программа это заметит и соберёт заново.',
[N]),
mtConfirmation, [mbYes, mbNo], 0) = mrYes;
finally
Done.Free;
end;
end;
procedure TMainForm.ShowExportReport(const AReport: TExportReport);
var
I, Encoded, Mismatched, WorstPart: Integer;
Shift, FrameSeconds: Double;
WantSum, Diff, WorstDiff: Int64;
begin
FrameSeconds := 0;
if (FProject.Master.FpsNum > 0) and (FProject.Master.FpsDen > 0) then
FrameSeconds := FProject.Master.FpsDen / FProject.Master.FpsNum;
if AReport.Cancelled then
begin
LogExport('Сборка отменена.');
Exit;
end;
if not AReport.Succeeded then
begin
LogExport('Сборка не удалась: ' + AReport.Message);
Exit;
end;
LogExport('--- Отчёт о сборке ---');
Encoded := 0;
for I := 0 to High(AReport.Segments) do
begin
Shift := AReport.Segments[I].ActualStart - AReport.Segments[I].RequestedStart;
if AReport.Segments[I].Encoded then
begin
Inc(Encoded);
LogExport(Format(' часть %d (кусок %d, ПЕРЕКОДИРОВАНА): старт %s, длина %s, пакетов %d',
[I + 1, AReport.Segments[I].Index + 1,
SecondsToStr(AReport.Segments[I].ActualStart),
SecondsToStr(AReport.Segments[I].Duration),
AReport.Segments[I].Packets]));
end
else
LogExport(Format(' часть %d (кусок %d): старт %s (просили %s, сдвиг %s), длина %s, пакетов %d',
[I + 1, AReport.Segments[I].Index + 1,
SecondsToStr(AReport.Segments[I].ActualStart),
SecondsToStr(AReport.Segments[I].RequestedStart),
SecondsToStr(Shift),
SecondsToStr(AReport.Segments[I].Duration),
AReport.Segments[I].Packets]));
end;
if Encoded > 0 then
LogExport(Format(' перекодировано частей: %d, остальное скопировано без потерь',
[Encoded]));
LogExport(Format(' сумма пакетов кусков: %d', [AReport.ExpectedPackets]));
LogExport(Format(' пакетов в готовом файле: %d', [AReport.ActualPackets]));
if AReport.ExpectedPackets = AReport.ActualPackets then
LogExport(' СВЕРКА ПРОЙДЕНА: склейка не потеряла и не продублировала ни одного кадра.')
else
LogExport(Format(' РАСХОЖДЕНИЕ на %d кадров.',
[AReport.ActualPackets - AReport.ExpectedPackets]));
{ Сверка с монтажом: одно дело — склейка ничего не потеряла, другое —
получилось ли столько кадров, сколько просил монтаж. Звук набирается
по монтажу с точностью до отсчёта, поэтому лишний или недостающий
кадр картинки — это и есть расхождение звука с картинкой, и о нём
надо говорить числом, а не ждать, пока заметит глаз. }
WantSum := 0;
Mismatched := 0;
WorstPart := -1;
WorstDiff := 0;
for I := 0 to High(AReport.Segments) do
begin
if AReport.Segments[I].WantFrames <= 0 then
Continue;
Inc(WantSum, AReport.Segments[I].WantFrames);
if AReport.Segments[I].Packets < 0 then
Continue;
Diff := AReport.Segments[I].Packets - AReport.Segments[I].WantFrames;
if Diff <> 0 then
begin
Inc(Mismatched);
if Abs(Diff) > Abs(WorstDiff) then
begin
WorstDiff := Diff;
WorstPart := I;
end;
LogExport(Format(' часть %d: кадров %d, а монтаж просил %d (разница %d)',
[I + 1, AReport.Segments[I].Packets,
AReport.Segments[I].WantFrames, Diff]));
end;
end;
if WantSum > 0 then
begin
LogExport(Format(' картинка: %d кадров, монтаж просил %d',
[AReport.ActualPackets, WantSum]));
Diff := AReport.ActualPackets - WantSum;
if Diff = 0 then
LogExport(' ЗВУК И КАРТИНКА СХОДЯТСЯ: кадров ровно столько, сколько в монтаже.')
else if WorstPart >= 0 then
LogExport(Format(' ЗВУК И КАРТИНКА РАСХОДЯТСЯ на %d %s (%s с), хуже всего часть %d.',
[Abs(Diff), Plural(Abs(Diff), 'кадр', 'кадра', 'кадров'),
SecondsToStr(Abs(Diff) * FrameSeconds), WorstPart + 1]))
else
LogExport(Format(' ЗВУК И КАРТИНКА РАСХОДЯТСЯ на %d %s (%s с).',
[Abs(Diff), Plural(Abs(Diff), 'кадр', 'кадра', 'кадров'),
SecondsToStr(Abs(Diff) * FrameSeconds)]));
if Mismatched > 0 then
LogExport(Format(' частей с неверной длиной: %d', [Mismatched]));
end;
LogExport(' файл: ' + AReport.OutputPath);
SetStatus('Готово: ' + AReport.OutputPath);
end;
end.