Цветовой ключ и живое наложение в предпросмотре
Клипу задаётся цвет, который при сборке становится прозрачным. Окно ключа показывает кадр: цвет берётся щелчком по нему, выбитое показано клеткой, допуск и смягчение края — ползунками. Открывается из окна кадрирования и из меню «Правка», на монтаже клип помечен квадратиком выбиваемого цвета. Предпросмотр перестал сводить такие участки в файлы. Плеер играет две дорожки одна поверх другой: оверлей во весь ролик больше не заставляет пересобирать весь фильм после каждой правки. Замерено: наложение стоит 0.4 с на 10 с материала 2688x1512, вчетверо быстрее реального времени. Починено в сборке: уменьшенный клип обкладывался чёрными полями, и они закрывали собой нижнее видео — оставалась видна только дырка, выбитая ключом. Теперь верхний слой кладётся по координатам, тем же, по которым его показывает предпросмотр. Смещение по горизонтали и вертикали работает и там, где картинка меньше кадра проекта: раньше уменьшенный клип нельзя было сдвинуть в угол. Проверки форм теперь создают каждое окно из файла формы: элемент, забытый в классе, вскрывается на сборке, а не при открытии окна.
This commit is contained in:
parent
1e28dd098b
commit
acb921d5f7
15 changed files with 1449 additions and 66 deletions
28
BACKLOG.md
28
BACKLOG.md
|
|
@ -54,14 +54,38 @@ GPL-3+ или совместимой.
|
|||
|
||||
ffmpeg проблемой не является: он запускается отдельной программой.
|
||||
|
||||
## Настоящая прозрачность в исходном файле
|
||||
|
||||
Цветовой ключ выбивает цвет, но бывает и прозрачность, записанная в самом
|
||||
файле: WebM с VP8/VP9, ProRes 4444, QuickTime Animation. ffmpeg их
|
||||
распаковывает в кадр с альфа-каналом, и ключ таким файлам не нужен —
|
||||
достаточно не выбрасывать альфу по дороге.
|
||||
|
||||
Что для этого потребуется, когда дойдут руки:
|
||||
|
||||
- при добавлении файла запоминать, есть ли у него альфа (`pix_fmt` с
|
||||
буквой «a»: yuva420p, yuva444p10le, argb);
|
||||
- такой клип считать несплошным так же, как клип с ключом, — то есть
|
||||
собирать участок слоями;
|
||||
- в цепочке фильтров следить, чтобы альфа не терялась: `scale` её
|
||||
сохраняет, а `pad` — только с прозрачным цветом полей;
|
||||
- аппаратное декодирование к таким файлам не применять: альфа в VP9
|
||||
лежит отдельным потоком, и видеокарта его не отдаёт.
|
||||
|
||||
Чего не будет в любом случае: раздать готовый ролик с прозрачностью в
|
||||
MP4/H.264 нельзя — контейнер и кодек альфу не хранят. Для этого нужен
|
||||
отдельный вывод в VP9 или ProRes 4444.
|
||||
|
||||
## Мелочи, до которых не дошли руки
|
||||
|
||||
**Значок файла программы под Windows.** Значок окна задаётся из
|
||||
`uIconData`, а значок самого `.exe` в проводнике берётся из ресурса при
|
||||
сборке — там он до сих пор стандартный.
|
||||
|
||||
**Кэш наложений не убирается.** Каждая правка прозрачности создаёт в
|
||||
кэше проекта новый файл; старые остаются лежать.
|
||||
**Кэш наложений не убирается.** Каждая правка наплыва создаёт в кэше
|
||||
проекта новый файл; старые остаются лежать. Клипы с ключом и
|
||||
прозрачностью сюда больше не попадают — их показывает сам плеер, — так
|
||||
что расти кэшу теперь почти не с чего.
|
||||
|
||||
**Окно кадрирования открывается около четырёх секунд.** Оно синхронно
|
||||
достаёт два кадра ffmpeg-ом (по 2.2 с каждый на шестигигабайтном файле) и
|
||||
|
|
|
|||
4
build.sh
4
build.sh
|
|
@ -177,6 +177,10 @@ run_tests() {
|
|||
else
|
||||
status=1
|
||||
fi
|
||||
# Окно зависимостей при создании записывает рядом с собой список
|
||||
# пакетов по умолчанию. Проверке это не мешает, а в дереве исходников
|
||||
# такому файлу не место.
|
||||
rm -f "$HERE/tests/lfmcheck/deps-linux.json"
|
||||
else
|
||||
echo "не собралось" >&2
|
||||
status=1
|
||||
|
|
|
|||
|
|
@ -45,10 +45,25 @@ type
|
|||
TSegmentLayer = record
|
||||
FilePath: string;
|
||||
SrcStart: Double;
|
||||
{ Цепочка кадрирования этого слоя. }
|
||||
{ Цепочка кадрирования этого слоя: с полями до кадра проекта. Годится
|
||||
там, где слой сам по себе занимает весь кадр. }
|
||||
Filter: string;
|
||||
{ Та же цепочка без полей и место, куда слой встаёт в кадре.
|
||||
|
||||
Верхний слой полями обкладывать нельзя: поле чёрное и непрозрачное,
|
||||
и уменьшенный клип закрывал бы им всё нижнее видео вместо того,
|
||||
чтобы лечь в свой угол. }
|
||||
FitFilter: string;
|
||||
X: Integer;
|
||||
Y: Integer;
|
||||
{ Постоянная непрозрачность слоя. }
|
||||
Opacity: Double;
|
||||
{ Цветовой ключ слоя: этот цвет становится прозрачным. Ключ выключен,
|
||||
если KeyEnabled равно False. }
|
||||
KeyEnabled: Boolean;
|
||||
KeyColor: TRgb;
|
||||
KeySimilarity: Integer;
|
||||
KeyBlend: Integer;
|
||||
{ Переход: непрозрачность меняется от начала куска к концу. Если оба
|
||||
значения равны Opacity, перехода нет. }
|
||||
FadeFrom: Double;
|
||||
|
|
@ -57,6 +72,24 @@ type
|
|||
|
||||
TSegmentLayers = array of TSegmentLayer;
|
||||
|
||||
{ Окно наложения для плеера: когда, куда и каким фильтром класть
|
||||
оверлей поверх основы.
|
||||
|
||||
Живёт в базовых типах, а не в уплощении, потому что плеер про
|
||||
уплощение знать не должен: ему отдают готовое описание. }
|
||||
TOverlayWindow = record
|
||||
TlStart: Double;
|
||||
TlEnd: Double;
|
||||
{ Цепочка фильтров для самого оверлея: размер, поворот. Без полей —
|
||||
поля закрыли бы нижнее видео. }
|
||||
Filter: string;
|
||||
X: Integer;
|
||||
Y: Integer;
|
||||
Width: Integer;
|
||||
Height: Integer;
|
||||
end;
|
||||
TOverlayWindows = array of TOverlayWindow;
|
||||
|
||||
TFlatSegment = record
|
||||
FilePath: string;
|
||||
SrcStart: Double;
|
||||
|
|
|
|||
397
src/uColorKeyForm.pas
Normal file
397
src/uColorKeyForm.pas
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
{
|
||||
uColorKeyForm — окно цветового ключа.
|
||||
|
||||
Цветовой ключ выбивает из клипа один цвет: всё, что достаточно на него
|
||||
похоже, становится прозрачным, и сквозь клип видно то, что лежит под
|
||||
ним. Так работает «зелёный экран» в кино, только цвет выбираем мы.
|
||||
|
||||
Главное в этом окне — не поля, а картинка. Цвет берут щелчком прямо по
|
||||
кадру: назвать пурпурный числом человек не сможет, а ткнуть в него
|
||||
мышью — сможет. Выбитое показывается клеткой, как в редакторах
|
||||
изображений: клетка видна там, где будет видно нижний клип.
|
||||
|
||||
Порог считается тем же способом, что и в ffmpeg: расстояние между
|
||||
цветом точки и заданным цветом в пространстве RGB, поделённое на
|
||||
наибольшее возможное. Иначе картинка в окне обещала бы одно, а сборка
|
||||
давала другое.
|
||||
}
|
||||
unit uColorKeyForm;
|
||||
|
||||
{$mode objfpc}{$H+}
|
||||
|
||||
interface
|
||||
|
||||
uses
|
||||
Classes, SysUtils, Controls, Forms, Graphics, StdCtrls, Dialogs, ExtCtrls,
|
||||
ComCtrls, uAppTypes, uProject, uTheme;
|
||||
|
||||
{ Показывает окно. True — человек нажал «Готово», и в AKey лежит
|
||||
изменённое.
|
||||
|
||||
APath — файл клипа, ATime — время внутри файла, откуда брать кадр для
|
||||
показа. }
|
||||
function EditColorKey(var AKey: TColorKey; const APath: string;
|
||||
const ATime: Double; const ATempFile: string): Boolean;
|
||||
|
||||
implementation
|
||||
|
||||
uses
|
||||
Math, FPimage, IntfGraphics, uFfmpegCmd, uProcRunner;
|
||||
|
||||
const
|
||||
{ Клетка под выбитым: две краски и размер квадратика. }
|
||||
ColorCheckA = TColor($4A4A4A);
|
||||
ColorCheckB = TColor($5E5E5E);
|
||||
CheckSize = 8;
|
||||
|
||||
type
|
||||
TColorKeyDialog = class(TComponent)
|
||||
public
|
||||
Form: TForm;
|
||||
pbFrame: TPaintBox;
|
||||
cbOn: TCheckBox;
|
||||
BtnColor: TThemedButton;
|
||||
tbSimilar: TTrackBar;
|
||||
tbBlend: TTrackBar;
|
||||
lblSimilar: TLabel;
|
||||
lblBlend: TLabel;
|
||||
lblHint: TLabel;
|
||||
Key: TColorKey;
|
||||
{ Кадр как он есть и кадр с уже выбитым цветом. Первый нужен, чтобы
|
||||
брать из него цвет по щелчку: во втором выбитое уже заменено
|
||||
клеткой, и попасть по исходному цвету было бы нельзя. }
|
||||
Source: TBitmap;
|
||||
Shown: TBitmap;
|
||||
procedure AnyChange(Sender: TObject);
|
||||
procedure ColorClick(Sender: TObject);
|
||||
procedure FramePaint(Sender: TObject);
|
||||
procedure FrameMouseDown(Sender: TObject; AButton: TMouseButton;
|
||||
AShift: TShiftState; AX, AY: Integer);
|
||||
{ Прямоугольник, в который вписан кадр. }
|
||||
function FrameRect: TRect;
|
||||
procedure Rebuild;
|
||||
procedure UpdateLabels;
|
||||
end;
|
||||
|
||||
{ Насколько точка отличается от заданного цвета: 0 — тот же цвет, 1 —
|
||||
наибольшее возможное расхождение. Повторяет расчёт фильтра colorkey. }
|
||||
function ColorDistance(AR, AG, AB: Integer; AKey: TRgb): Double;
|
||||
var
|
||||
DR, DG, DB: Integer;
|
||||
begin
|
||||
DR := AR - (AKey and $FF);
|
||||
DG := AG - ((AKey shr 8) and $FF);
|
||||
DB := AB - ((AKey shr 16) and $FF);
|
||||
Result := Sqrt((DR * DR + DG * DG + DB * DB) / (255.0 * 255.0 * 3.0));
|
||||
end;
|
||||
|
||||
{ Прозрачность точки после ключа: 0 — выбита совсем, 1 — осталась. }
|
||||
function KeyAlpha(const ADist, ASimilar, ABlend: Double): Double;
|
||||
begin
|
||||
if ABlend > 0.0001 then
|
||||
Result := Max(0, Min(1, (ADist - ASimilar) / ABlend))
|
||||
else if ADist > ASimilar then
|
||||
Result := 1
|
||||
else
|
||||
Result := 0;
|
||||
end;
|
||||
|
||||
{ Кадр с выбитым цветом: где выбито — клетка, где нет — исходная точка,
|
||||
а на смягчённом крае они смешаны.
|
||||
|
||||
Точки берутся через промежуточное изображение, а не через Canvas.Pixels:
|
||||
каждое обращение к Pixels под gtk2 идёт через оконную систему, и на
|
||||
кадре в четверть миллиона точек окно бы просто встало. }
|
||||
procedure ApplyKey(ASrc, ADst: TBitmap; const AKey: TColorKey);
|
||||
var
|
||||
Img: TLazIntfImage;
|
||||
X, Y, R, G, B: Integer;
|
||||
A, Similar, Blend: Double;
|
||||
C: TFPColor;
|
||||
Check: TColor;
|
||||
begin
|
||||
Similar := AKey.Similarity / 100;
|
||||
Blend := AKey.Blend / 100;
|
||||
Img := ASrc.CreateIntfImage;
|
||||
try
|
||||
if AKey.Enabled then
|
||||
for Y := 0 to Img.Height - 1 do
|
||||
for X := 0 to Img.Width - 1 do
|
||||
begin
|
||||
C := Img.Colors[X, Y];
|
||||
R := C.Red shr 8;
|
||||
G := C.Green shr 8;
|
||||
B := C.Blue shr 8;
|
||||
A := KeyAlpha(ColorDistance(R, G, B, AKey.Color), Similar, Blend);
|
||||
if A >= 0.999 then
|
||||
Continue;
|
||||
|
||||
if ((X div CheckSize) + (Y div CheckSize)) and 1 = 0 then
|
||||
Check := ColorCheckA
|
||||
else
|
||||
Check := ColorCheckB;
|
||||
C.Red := Round(R * A + (Check and $FF) * (1 - A)) * 257;
|
||||
C.Green := Round(G * A + ((Check shr 8) and $FF) * (1 - A)) * 257;
|
||||
C.Blue := Round(B * A + ((Check shr 16) and $FF) * (1 - A)) * 257;
|
||||
Img.Colors[X, Y] := C;
|
||||
end;
|
||||
ADst.LoadFromIntfImage(Img);
|
||||
finally
|
||||
Img.Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
function TColorKeyDialog.FrameRect: TRect;
|
||||
var
|
||||
W, H: Integer;
|
||||
Scale: Double;
|
||||
begin
|
||||
Result := Rect(0, 0, 0, 0);
|
||||
if (Source = nil) or (Source.Width = 0) or (Source.Height = 0) then
|
||||
Exit;
|
||||
Scale := Min(pbFrame.Width / Source.Width, pbFrame.Height / Source.Height);
|
||||
W := Max(1, Round(Source.Width * Scale));
|
||||
H := Max(1, Round(Source.Height * Scale));
|
||||
Result := Bounds((pbFrame.Width - W) div 2, (pbFrame.Height - H) div 2, W, H);
|
||||
end;
|
||||
|
||||
procedure TColorKeyDialog.FramePaint(Sender: TObject);
|
||||
var
|
||||
R: TRect;
|
||||
begin
|
||||
pbFrame.Canvas.Brush.Color := ThemeSurface;
|
||||
pbFrame.Canvas.FillRect(0, 0, pbFrame.Width, pbFrame.Height);
|
||||
if Shown = nil then
|
||||
begin
|
||||
pbFrame.Canvas.Font.Color := ThemeText;
|
||||
pbFrame.Canvas.TextOut(8, 8, 'Кадр получить не удалось');
|
||||
Exit;
|
||||
end;
|
||||
R := FrameRect;
|
||||
pbFrame.Canvas.StretchDraw(R, Shown);
|
||||
end;
|
||||
|
||||
procedure TColorKeyDialog.FrameMouseDown(Sender: TObject;
|
||||
AButton: TMouseButton; AShift: TShiftState; AX, AY: Integer);
|
||||
var
|
||||
R: TRect;
|
||||
SX, SY: Integer;
|
||||
begin
|
||||
if Source = nil then
|
||||
Exit;
|
||||
R := FrameRect;
|
||||
if (AX < R.Left) or (AX >= R.Right) or (AY < R.Top) or (AY >= R.Bottom) then
|
||||
Exit;
|
||||
|
||||
SX := Round((AX - R.Left) / (R.Right - R.Left) * Source.Width);
|
||||
SY := Round((AY - R.Top) / (R.Bottom - R.Top) * Source.Height);
|
||||
SX := Max(0, Min(Source.Width - 1, SX));
|
||||
SY := Max(0, Min(Source.Height - 1, SY));
|
||||
|
||||
Key.Color := TRgb(Source.Canvas.Pixels[SX, SY]);
|
||||
BtnColor.Swatch := TColor(Key.Color);
|
||||
{ Ткнули в цвет — значит, его и хотят выбить. Требовать после этого
|
||||
отдельно поставить галку было бы издевательством. }
|
||||
if not cbOn.Checked then
|
||||
cbOn.Checked := True;
|
||||
Rebuild;
|
||||
end;
|
||||
|
||||
procedure TColorKeyDialog.ColorClick(Sender: TObject);
|
||||
var
|
||||
Dlg: TColorDialog;
|
||||
begin
|
||||
Dlg := TColorDialog.Create(nil);
|
||||
try
|
||||
Dlg.Color := TColor(Key.Color);
|
||||
if not Dlg.Execute then
|
||||
Exit;
|
||||
Key.Color := TRgb(Dlg.Color);
|
||||
BtnColor.Swatch := Dlg.Color;
|
||||
Rebuild;
|
||||
finally
|
||||
Dlg.Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
procedure TColorKeyDialog.UpdateLabels;
|
||||
begin
|
||||
lblSimilar.Caption := Format('Допуск: %d%%', [tbSimilar.Position]);
|
||||
lblBlend.Caption := Format('Смягчение края: %d%%', [tbBlend.Position]);
|
||||
end;
|
||||
|
||||
procedure TColorKeyDialog.AnyChange(Sender: TObject);
|
||||
begin
|
||||
Key.Enabled := cbOn.Checked;
|
||||
Key.Similarity := tbSimilar.Position;
|
||||
Key.Blend := tbBlend.Position;
|
||||
UpdateLabels;
|
||||
Rebuild;
|
||||
end;
|
||||
|
||||
procedure TColorKeyDialog.Rebuild;
|
||||
begin
|
||||
if Source = nil then
|
||||
Exit;
|
||||
if Shown = nil then
|
||||
Shown := TBitmap.Create;
|
||||
ApplyKey(Source, Shown, Key);
|
||||
pbFrame.Invalidate;
|
||||
end;
|
||||
|
||||
{ Кадр из середины куска. Первый кадр часто чёрный, и выбирать по нему
|
||||
цвет не по чему. }
|
||||
function GrabFrame(const APath: string; const ATime: Double;
|
||||
const ATempFile: string): TBitmap;
|
||||
var
|
||||
Res: TProcResult;
|
||||
Png: TPortableNetworkGraphic;
|
||||
begin
|
||||
Result := nil;
|
||||
if APath = '' then
|
||||
Exit;
|
||||
Res := RunProcess(FfmpegExe,
|
||||
ArgsPreviewFrame(APath, ATempFile, ATime, ''), nil, nil, nil);
|
||||
if (not Res.Started) or (Res.ExitCode <> 0) or (not FileExists(ATempFile)) then
|
||||
Exit;
|
||||
|
||||
Png := TPortableNetworkGraphic.Create;
|
||||
try
|
||||
try
|
||||
Png.LoadFromFile(ATempFile);
|
||||
Result := TBitmap.Create;
|
||||
{ Формат точек не трогаем. Присвоение ему пересоздаёт хранилище
|
||||
картинки, и только что положенный кадр пропадает — окно
|
||||
показывало ровный чёрный прямоугольник вместо кадра. }
|
||||
Result.Assign(Png);
|
||||
except
|
||||
FreeAndNil(Result);
|
||||
end;
|
||||
finally
|
||||
Png.Free;
|
||||
end;
|
||||
SysUtils.DeleteFile(ATempFile);
|
||||
end;
|
||||
|
||||
function EditColorKey(var AKey: TColorKey; const APath: string;
|
||||
const ATime: Double; const ATempFile: string): Boolean;
|
||||
var
|
||||
Dlg: TColorKeyDialog;
|
||||
BtnOk, BtnCancel: TThemedButton;
|
||||
Lbl: TLabel;
|
||||
begin
|
||||
Result := False;
|
||||
Dlg := TColorKeyDialog.Create(nil);
|
||||
try
|
||||
Dlg.Key := AKey;
|
||||
Dlg.Source := GrabFrame(APath, ATime, ATempFile);
|
||||
|
||||
Dlg.Form := TForm.CreateNew(nil);
|
||||
Dlg.Form.Caption := 'Цветовой ключ';
|
||||
Dlg.Form.Position := poScreenCenter;
|
||||
Dlg.Form.ClientWidth := 760;
|
||||
Dlg.Form.ClientHeight := 560;
|
||||
|
||||
Dlg.pbFrame := TPaintBox.Create(Dlg.Form);
|
||||
Dlg.pbFrame.Parent := Dlg.Form;
|
||||
Dlg.pbFrame.SetBounds(12, 12, 736, 360);
|
||||
Dlg.pbFrame.Anchors := [akLeft, akTop, akRight, akBottom];
|
||||
Dlg.pbFrame.OnPaint := @Dlg.FramePaint;
|
||||
Dlg.pbFrame.OnMouseDown := @Dlg.FrameMouseDown;
|
||||
|
||||
Lbl := TLabel.Create(Dlg.Form);
|
||||
Lbl.Parent := Dlg.Form;
|
||||
Lbl.SetBounds(12, 380, 736, 20);
|
||||
Lbl.Anchors := [akLeft, akRight, akBottom];
|
||||
Lbl.Caption := 'Щёлкните по кадру, чтобы взять цвет. Клетка — то, ' +
|
||||
'что станет прозрачным.';
|
||||
|
||||
Dlg.cbOn := TCheckBox.Create(Dlg.Form);
|
||||
Dlg.cbOn.Parent := Dlg.Form;
|
||||
Dlg.cbOn.SetBounds(12, 408, 220, 22);
|
||||
Dlg.cbOn.Anchors := [akLeft, akBottom];
|
||||
Dlg.cbOn.Caption := 'Выбивать цвет';
|
||||
Dlg.cbOn.Checked := AKey.Enabled;
|
||||
Dlg.cbOn.OnClick := @Dlg.AnyChange;
|
||||
|
||||
Dlg.BtnColor := TThemedButton.Create(Dlg.Form);
|
||||
Dlg.BtnColor.Parent := Dlg.Form;
|
||||
Dlg.BtnColor.SetBounds(240, 404, 120, 28);
|
||||
Dlg.BtnColor.Anchors := [akLeft, akBottom];
|
||||
Dlg.BtnColor.Caption := 'Цвет';
|
||||
Dlg.BtnColor.Swatch := TColor(AKey.Color);
|
||||
Dlg.BtnColor.OnClick := @Dlg.ColorClick;
|
||||
|
||||
Dlg.lblSimilar := TLabel.Create(Dlg.Form);
|
||||
Dlg.lblSimilar.Parent := Dlg.Form;
|
||||
Dlg.lblSimilar.SetBounds(12, 442, 200, 20);
|
||||
Dlg.lblSimilar.Anchors := [akLeft, akBottom];
|
||||
|
||||
Dlg.tbSimilar := TTrackBar.Create(Dlg.Form);
|
||||
Dlg.tbSimilar.Parent := Dlg.Form;
|
||||
Dlg.tbSimilar.SetBounds(210, 438, 538, 30);
|
||||
Dlg.tbSimilar.Anchors := [akLeft, akRight, akBottom];
|
||||
Dlg.tbSimilar.Min := 1;
|
||||
Dlg.tbSimilar.Max := 60;
|
||||
Dlg.tbSimilar.TickStyle := tsNone;
|
||||
Dlg.tbSimilar.Position := Max(1, Min(60, AKey.Similarity));
|
||||
Dlg.tbSimilar.OnChange := @Dlg.AnyChange;
|
||||
|
||||
Dlg.lblBlend := TLabel.Create(Dlg.Form);
|
||||
Dlg.lblBlend.Parent := Dlg.Form;
|
||||
Dlg.lblBlend.SetBounds(12, 478, 200, 20);
|
||||
Dlg.lblBlend.Anchors := [akLeft, akBottom];
|
||||
|
||||
Dlg.tbBlend := TTrackBar.Create(Dlg.Form);
|
||||
Dlg.tbBlend.Parent := Dlg.Form;
|
||||
Dlg.tbBlend.SetBounds(210, 474, 538, 30);
|
||||
Dlg.tbBlend.Anchors := [akLeft, akRight, akBottom];
|
||||
Dlg.tbBlend.Min := 0;
|
||||
Dlg.tbBlend.Max := 40;
|
||||
Dlg.tbBlend.TickStyle := tsNone;
|
||||
Dlg.tbBlend.Position := Max(0, Min(40, AKey.Blend));
|
||||
Dlg.tbBlend.OnChange := @Dlg.AnyChange;
|
||||
|
||||
Dlg.lblHint := TLabel.Create(Dlg.Form);
|
||||
Dlg.lblHint.Parent := Dlg.Form;
|
||||
Dlg.lblHint.SetBounds(12, 512, 480, 36);
|
||||
Dlg.lblHint.Anchors := [akLeft, akRight, akBottom];
|
||||
Dlg.lblHint.AutoSize := False;
|
||||
Dlg.lblHint.WordWrap := True;
|
||||
Dlg.lblHint.Caption := 'Участки с ключом придётся пересобрать: ' +
|
||||
'копированием прозрачность не делается.';
|
||||
|
||||
BtnOk := TThemedButton.Create(Dlg.Form);
|
||||
BtnOk.Parent := Dlg.Form;
|
||||
BtnOk.SetBounds(540, 518, 100, 30);
|
||||
BtnOk.Anchors := [akRight, akBottom];
|
||||
BtnOk.Caption := 'Готово';
|
||||
BtnOk.ModalResult := mrOk;
|
||||
BtnOk.Default := True;
|
||||
|
||||
BtnCancel := TThemedButton.Create(Dlg.Form);
|
||||
BtnCancel.Parent := Dlg.Form;
|
||||
BtnCancel.SetBounds(648, 518, 100, 30);
|
||||
BtnCancel.Anchors := [akRight, akBottom];
|
||||
BtnCancel.Caption := 'Отмена';
|
||||
BtnCancel.ModalResult := mrCancel;
|
||||
BtnCancel.Cancel := True;
|
||||
|
||||
ApplyDarkTheme(Dlg.Form);
|
||||
Dlg.UpdateLabels;
|
||||
Dlg.Rebuild;
|
||||
|
||||
if Dlg.Form.ShowModal <> mrOk then
|
||||
Exit;
|
||||
|
||||
AKey := Dlg.Key;
|
||||
Result := True;
|
||||
finally
|
||||
Dlg.Source.Free;
|
||||
Dlg.Shown.Free;
|
||||
Dlg.Form.Free;
|
||||
Dlg.Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
end.
|
||||
|
|
@ -282,7 +282,7 @@ function ArgsRenderLayers(const ALayers: array of TSegmentLayer;
|
|||
const APixFmt, ACodec: string; AIsTs: Boolean): TArgs;
|
||||
var
|
||||
I: Integer;
|
||||
Graph, Fit: string;
|
||||
Graph, Fit, Src: string;
|
||||
Crossfade: Boolean;
|
||||
begin
|
||||
Result := ['-hide_banner', '-nostdin', '-loglevel', 'warning', '-y'];
|
||||
|
|
@ -302,30 +302,66 @@ begin
|
|||
Graph := '';
|
||||
for I := 0 to High(ALayers) do
|
||||
begin
|
||||
Fit := ALayers[I].Filter;
|
||||
{ Нижний слой — холст, он занимает весь кадр, и поля ему нужны.
|
||||
Верхнему поля вредны: чёрное поле непрозрачно и закрыло бы холст,
|
||||
поэтому верхний кладётся на своё место как есть. Переход — случай
|
||||
особый: xfade смешивает два кадра целиком, и оба должны быть в
|
||||
размер проекта. }
|
||||
if (I > 0) and (not Crossfade) and (ALayers[I].FitFilter <> '') then
|
||||
Fit := ALayers[I].FitFilter
|
||||
else
|
||||
Fit := ALayers[I].Filter;
|
||||
if Fit = '' then
|
||||
Fit := Format('scale=%d:%d,setsar=1', [AWidth, AHeight]);
|
||||
{ Цветовой ключ ставится после вписывания и до наложения: он должен
|
||||
работать по кадру проекта, иначе смягчение края разъедется вместе с
|
||||
масштабом. Цвет записывается как 0xRRGGBB — порядок обратный тому,
|
||||
в каком он хранится у нас. }
|
||||
if ALayers[I].KeyEnabled then
|
||||
Fit := Fit + Format(',colorkey=0x%.2x%.2x%.2x:%s:%s',
|
||||
[ALayers[I].KeyColor and $FF,
|
||||
(ALayers[I].KeyColor shr 8) and $FF,
|
||||
(ALayers[I].KeyColor shr 16) and $FF,
|
||||
SecondsToStr(ALayers[I].KeySimilarity / 100),
|
||||
SecondsToStr(ALayers[I].KeyBlend / 100)]);
|
||||
Graph := Graph + Format('[%d:v]%s,format=yuva420p[v%d];', [I, Fit, I]);
|
||||
end;
|
||||
|
||||
if Length(ALayers) = 1 then
|
||||
begin
|
||||
Src := 'v0';
|
||||
{ Ключ без нижнего слоя: выбитое надо чем-то заполнить, иначе при
|
||||
переводе в кадр без прозрачности вернётся исходный цвет — тот
|
||||
самый, который выбивали. Кладём на чёрное поле. }
|
||||
if ALayers[0].KeyEnabled then
|
||||
begin
|
||||
Graph := Graph + Format('color=c=black:s=%dx%d:r=%d/%d:d=%s[bg];' +
|
||||
'[bg][v0]overlay=0:0[k0];',
|
||||
[AWidth, AHeight, AFpsNum, AFpsDen, SecondsToStr(ADuration)]);
|
||||
Src := 'k0';
|
||||
end;
|
||||
if Abs(ALayers[0].FadeTo - ALayers[0].FadeFrom) < 0.001 then
|
||||
Graph := Graph + Format('[%s]null[out]', [Src])
|
||||
{ Один слой с меняющейся долей — это затухание в чёрное или из него. }
|
||||
if ALayers[0].FadeTo >= ALayers[0].FadeFrom then
|
||||
Graph := Graph + Format('[v0]fade=t=in:st=0:d=%s:alpha=0[out]',
|
||||
[SecondsToStr(ADuration)])
|
||||
else if ALayers[0].FadeTo >= ALayers[0].FadeFrom then
|
||||
Graph := Graph + Format('[%s]fade=t=in:st=0:d=%s:alpha=0[out]',
|
||||
[Src, SecondsToStr(ADuration)])
|
||||
else
|
||||
Graph := Graph + Format('[v0]fade=t=out:st=0:d=%s:alpha=0[out]',
|
||||
[SecondsToStr(ADuration)]);
|
||||
Graph := Graph + Format('[%s]fade=t=out:st=0:d=%s:alpha=0[out]',
|
||||
[Src, SecondsToStr(ADuration)]);
|
||||
end
|
||||
else if Crossfade then
|
||||
Graph := Graph + Format('[v0][v1]xfade=transition=fade:duration=%s:offset=0[out]',
|
||||
[SecondsToStr(ADuration)])
|
||||
else
|
||||
begin
|
||||
{ Верхний слой приглушается по альфа-каналу и кладётся на нижний. }
|
||||
Graph := Graph + Format('[v1]colorchannelmixer=aa=%s[t];[v0][t]overlay=0:0[out]',
|
||||
[SecondsToStr(ALayers[High(ALayers)].Opacity)]);
|
||||
{ Верхний слой приглушается по альфа-каналу и кладётся на нижний —
|
||||
на своё место в кадре, теми же координатами, по которым его
|
||||
показывает предпросмотр. }
|
||||
Graph := Graph + Format('[v1]colorchannelmixer=aa=%s[t];' +
|
||||
'[v0][t]overlay=%d:%d[out]',
|
||||
[SecondsToStr(ALayers[High(ALayers)].Opacity),
|
||||
ALayers[High(ALayers)].X, ALayers[High(ALayers)].Y]);
|
||||
end;
|
||||
|
||||
{ Кодировщик выбирается предпочтительный, а не тот, что ffmpeg возьмёт
|
||||
|
|
|
|||
235
src/uFlatten.pas
235
src/uFlatten.pas
|
|
@ -38,6 +38,25 @@ uses
|
|||
Classes, SysUtils, uAppTypes, uProject, uMediaProbe, uTransform;
|
||||
|
||||
type
|
||||
{ Клип, который в предпросмотре кладётся поверх основы.
|
||||
|
||||
Место в кадре — то же самое, по чему сборка обкладывает картинку
|
||||
полями: считается одним кодом (см. OverlayFilter). }
|
||||
TPreviewOverlay = record
|
||||
TlStart: Double;
|
||||
TlEnd: Double;
|
||||
FilePath: string;
|
||||
SrcStart: Double;
|
||||
{ Цепочка фильтров для плеера: без полей, только приведение к нужному
|
||||
размеру и повороту. }
|
||||
Filter: string;
|
||||
X: Integer;
|
||||
Y: Integer;
|
||||
Width: Integer;
|
||||
Height: Integer;
|
||||
end;
|
||||
TPreviewOverlays = array of TPreviewOverlay;
|
||||
|
||||
{ Что нужно создать, чтобы кусок стал воспроизводимым файлом. }
|
||||
{ gkSilence — вставка тишины без картинки, только для звуковых дорожек.
|
||||
|
||||
|
|
@ -87,7 +106,8 @@ type
|
|||
Недостающее заполняется чёрным и тишиной. }
|
||||
function FlattenStream(AProject: TProject; AKind: TTrackKind;
|
||||
const AMinLength: Double;
|
||||
out ASegments: TFlatSegmentArray; out AMissing: TGenRequests): Boolean;
|
||||
out ASegments: TFlatSegmentArray; out AMissing: TGenRequests;
|
||||
AHidden: TFPList = nil): Boolean;
|
||||
|
||||
{ Уплощение одной звуковой дорожки — для предпросмотра.
|
||||
|
||||
|
|
@ -107,6 +127,25 @@ function FlattenProject(AProject: TProject;
|
|||
out AVideo, AAudio: TFlatSegmentArray;
|
||||
out AMissing: TGenRequests): Boolean;
|
||||
|
||||
{ То же для предпросмотра, но клипы, которые кладутся поверх, вынесены
|
||||
отдельно.
|
||||
|
||||
Зачем. Клип с ключом или прозрачностью не сплошной, и в готовом ролике
|
||||
участок под ним приходится сводить. Оверлей во весь ролик — компас или
|
||||
карта поверх всего материала — заставил бы сводить весь фильм на каждую
|
||||
правку, ради картинки, которую всё равно надо перепроверять сборкой.
|
||||
|
||||
Плееру сводить не нужно: он умеет играть две дорожки одна поверх
|
||||
другой. Поэтому такие клипы здесь не участвуют в отборе победителя —
|
||||
под ними видно то, что лежит ниже, — а выдаются отдельным списком со
|
||||
временем и местом в кадре.
|
||||
|
||||
Для сборки ролика это не применяется: там всё сводится по-настоящему. }
|
||||
function FlattenPreview(AProject: TProject;
|
||||
out AVideo, AAudio: TFlatSegmentArray;
|
||||
out AOverlays: TPreviewOverlays;
|
||||
out AMissing: TGenRequests): Boolean;
|
||||
|
||||
{ Путь к сгенерированному клипу. Имя выводится из содержимого запроса,
|
||||
поэтому одинаковые запросы дают один и тот же файл, и он делается
|
||||
один раз. }
|
||||
|
|
@ -224,6 +263,12 @@ begin
|
|||
'*' + SecondsToStr(ALayers[I].Opacity) +
|
||||
'>' + SecondsToStr(ALayers[I].FadeFrom) +
|
||||
'<' + SecondsToStr(ALayers[I].FadeTo);
|
||||
{ Ключ входит в имя готового файла: иначе после правки цвета или
|
||||
допуска подставился бы прежний, сведённый по старым числам. }
|
||||
if ALayers[I].KeyEnabled then
|
||||
Result := Result + Format('~%.6x/%d/%d',
|
||||
[ALayers[I].KeyColor and $FFFFFF,
|
||||
ALayers[I].KeySimilarity, ALayers[I].KeyBlend]);
|
||||
end;
|
||||
|
||||
procedure ResolveLayers(AProject: TProject; var ASegments: TFlatSegmentArray;
|
||||
|
|
@ -347,9 +392,13 @@ begin
|
|||
end;
|
||||
end;
|
||||
|
||||
{ AHidden — клипы, которых для этого прохода как бы нет. Через них видно
|
||||
то, что лежит ниже. Нужны предпросмотру: клип, который плеер положит
|
||||
поверх сам, не должен ничего закрывать в основе. }
|
||||
function FlattenStream(AProject: TProject; AKind: TTrackKind;
|
||||
const AMinLength: Double;
|
||||
out ASegments: TFlatSegmentArray; out AMissing: TGenRequests): Boolean;
|
||||
out ASegments: TFlatSegmentArray; out AMissing: TGenRequests;
|
||||
AHidden: TFPList = nil): Boolean;
|
||||
var
|
||||
Bounds: array of Double;
|
||||
BoundCount: Integer;
|
||||
|
|
@ -372,6 +421,7 @@ var
|
|||
const AFrom, ATo: Double): TSegmentLayer;
|
||||
var
|
||||
It: TMediaItem;
|
||||
W, H: Integer;
|
||||
begin
|
||||
Result := Default(TSegmentLayer);
|
||||
Result.FadeFrom := AFrom;
|
||||
|
|
@ -382,9 +432,17 @@ var
|
|||
Exit;
|
||||
Result.FilePath := It.EffectivePath;
|
||||
Result.SrcStart := AClip.SrcIn + (AAt - AClip.TlStart);
|
||||
Result.KeyEnabled := AClip.ColorKey.Enabled;
|
||||
Result.KeyColor := AClip.ColorKey.Color;
|
||||
Result.KeySimilarity := AClip.ColorKey.Similarity;
|
||||
Result.KeyBlend := AClip.ColorKey.Blend;
|
||||
Result.Filter := TransformFilter(AClip.Transform,
|
||||
It.Info.Width, It.Info.Height,
|
||||
AProject.Master.Width, AProject.Master.Height);
|
||||
Result.FitFilter := OverlayFilter(AClip.Transform,
|
||||
It.Info.Width, It.Info.Height,
|
||||
AProject.Master.Width, AProject.Master.Height,
|
||||
Result.X, Result.Y, W, H);
|
||||
if Result.Filter = '' then
|
||||
Result.Filter := PreviewFitFilter(AProject.Master.Width,
|
||||
AProject.Master.Height);
|
||||
|
|
@ -454,6 +512,26 @@ var
|
|||
ASeg.Layers := nil;
|
||||
end;
|
||||
|
||||
function IsHidden(AClip: TClip): Boolean;
|
||||
begin
|
||||
Result := (AHidden <> nil) and (AHidden.IndexOf(AClip) >= 0);
|
||||
end;
|
||||
|
||||
{ Клип дорожки в этот момент, не считая спрятанных. }
|
||||
function VisibleClipAt(ATrack: TTrack; const ATime: Double): TClip;
|
||||
var
|
||||
C: Integer;
|
||||
Cl: TClip;
|
||||
begin
|
||||
Result := nil;
|
||||
for C := 0 to ATrack.Count - 1 do
|
||||
begin
|
||||
Cl := ATrack[C];
|
||||
if Cl.Covers(ATime) and (not IsHidden(Cl)) then
|
||||
Exit(Cl);
|
||||
end;
|
||||
end;
|
||||
|
||||
{ Клип, побеждающий в указанный момент: с самой верхней дорожки. }
|
||||
function WinnerAt(const ATime: Double): TClip;
|
||||
var
|
||||
|
|
@ -469,7 +547,7 @@ var
|
|||
то и все несолирующие. }
|
||||
if not AProject.TrackAudible(AProject.Tracks[Ti]) then
|
||||
Continue;
|
||||
Cl := AProject.Tracks[Ti].ClipAt(ATime);
|
||||
Cl := VisibleClipAt(AProject.Tracks[Ti], ATime);
|
||||
if Cl <> nil then
|
||||
Exit(Cl);
|
||||
end;
|
||||
|
|
@ -505,7 +583,7 @@ var
|
|||
for C := 0 to Track.Count - 1 do
|
||||
begin
|
||||
Cl := Track[C];
|
||||
if (Cl <> AClip) and Cl.Covers(ATime) then
|
||||
if (Cl <> AClip) and Cl.Covers(ATime) and (not IsHidden(Cl)) then
|
||||
Exit(Cl);
|
||||
end;
|
||||
end;
|
||||
|
|
@ -526,7 +604,7 @@ var
|
|||
Continue;
|
||||
if not AProject.TrackAudible(AProject.Tracks[Ti]) then
|
||||
Continue;
|
||||
Cl := AProject.Tracks[Ti].ClipAt(ATime);
|
||||
Cl := VisibleClipAt(AProject.Tracks[Ti], ATime);
|
||||
if Cl <> nil then
|
||||
Exit(Cl);
|
||||
end;
|
||||
|
|
@ -684,13 +762,24 @@ begin
|
|||
|
||||
Partner := OverlapPartner(Winner, Mid);
|
||||
Under := nil;
|
||||
if (Partner = nil) and (Winner.Opacity < 0.999) then
|
||||
{ Ключ работает так же, как полупрозрачность: клип перестаёт быть
|
||||
сплошным, и участок приходится собирать вместе с тем, что под
|
||||
ним. }
|
||||
if (Partner = nil) and
|
||||
((Winner.Opacity < 0.999) or Winner.ColorKey.Enabled) then
|
||||
Under := BelowAt(Winner, Mid);
|
||||
|
||||
{ Ручное затухание: кусок попал в зону появления или ухода. Слой
|
||||
один, но с меняющейся долей — дальше это обрабатывается тем же
|
||||
путём, что и переход между двумя клипами. }
|
||||
if (Partner = nil) and (Under = nil) then
|
||||
if (Partner = nil) and (Under = nil) and Winner.ColorKey.Enabled then
|
||||
begin
|
||||
{ Ключ есть, а класть не на что — всё равно пересобираем. }
|
||||
SetLength(Seg.Layers, 1);
|
||||
Seg.Layers[0] := LayerOf(Winner, SegStart, 1, 1);
|
||||
Seg.Transcode := True;
|
||||
end
|
||||
else if (Partner = nil) and (Under = nil) then
|
||||
begin
|
||||
if (Winner.FadeIn > 0) and (SegEnd <= Winner.TlStart + Winner.FadeIn + 0.0005) then
|
||||
begin
|
||||
|
|
@ -741,12 +830,20 @@ begin
|
|||
Seg.Layers[1] := LayerOf(Partner, SegStart, 0, 1);
|
||||
end;
|
||||
end
|
||||
else
|
||||
else if Under <> nil then
|
||||
begin
|
||||
{ Полупрозрачный клип поверх нижнего. }
|
||||
{ Полупрозрачный клип или клип с ключом поверх нижнего. }
|
||||
Seg.Layers[0] := LayerOf(Under, SegStart, 1, 1);
|
||||
Seg.Layers[1] := LayerOf(Winner, SegStart,
|
||||
Winner.Opacity, Winner.Opacity);
|
||||
end
|
||||
else
|
||||
begin
|
||||
{ Под клипом ничего нет, но ключ всё равно надо применить:
|
||||
вырезанное станет чёрным. }
|
||||
SetLength(Seg.Layers, 1);
|
||||
Seg.Layers[0] := LayerOf(Winner, SegStart,
|
||||
Winner.Opacity, Winner.Opacity);
|
||||
end;
|
||||
{ Собрать такой участок копированием нельзя. }
|
||||
Seg.Transcode := True;
|
||||
|
|
@ -926,4 +1023,124 @@ begin
|
|||
end;
|
||||
end;
|
||||
|
||||
{ Клип кладётся поверх, а не побеждает целиком, если он не сплошной:
|
||||
с выбитым цветом или полупрозрачный. }
|
||||
function IsOverlayClip(AClip: TClip): Boolean;
|
||||
begin
|
||||
Result := (AClip <> nil) and (AClip.Kind in [ckVideo, ckImage]) and
|
||||
(AClip.ColorKey.Enabled or (AClip.Opacity < 0.999));
|
||||
end;
|
||||
|
||||
function FlattenPreview(AProject: TProject;
|
||||
out AVideo, AAudio: TFlatSegmentArray;
|
||||
out AOverlays: TPreviewOverlays;
|
||||
out AMissing: TGenRequests): Boolean;
|
||||
var
|
||||
Hidden: TFPList;
|
||||
MissA: TGenRequests;
|
||||
T, C, I, N: Integer;
|
||||
Track: TTrack;
|
||||
Clip: TClip;
|
||||
Item: TMediaItem;
|
||||
Total: Double;
|
||||
Ov: TPreviewOverlay;
|
||||
|
||||
{ Занято ли это время другим оверлеем. }
|
||||
function OverlapsTaken(const AFrom, ATo: Double): Boolean;
|
||||
var
|
||||
K: Integer;
|
||||
begin
|
||||
Result := False;
|
||||
for K := 0 to High(AOverlays) do
|
||||
if (AFrom < AOverlays[K].TlEnd - 0.001) and
|
||||
(ATo > AOverlays[K].TlStart + 0.001) then
|
||||
Exit(True);
|
||||
end;
|
||||
|
||||
begin
|
||||
AVideo := nil;
|
||||
AAudio := nil;
|
||||
AOverlays := nil;
|
||||
AMissing := nil;
|
||||
MissA := nil;
|
||||
Result := False;
|
||||
if AProject = nil then
|
||||
Exit;
|
||||
|
||||
Total := Max(AudibleEnd(AProject, tkVideo), AudibleEnd(AProject, tkAudio));
|
||||
if Total <= 0 then
|
||||
Exit;
|
||||
|
||||
Hidden := TFPList.Create;
|
||||
try
|
||||
N := 0;
|
||||
for T := 0 to AProject.TrackCount - 1 do
|
||||
begin
|
||||
Track := AProject.Tracks[T];
|
||||
if Track.Kind <> tkVideo then
|
||||
Continue;
|
||||
if not AProject.TrackAudible(Track) then
|
||||
Continue;
|
||||
for C := 0 to Track.Count - 1 do
|
||||
begin
|
||||
Clip := Track[C];
|
||||
if not IsOverlayClip(Clip) then
|
||||
Continue;
|
||||
Item := AProject.FindMedia(Clip.MediaId);
|
||||
if Item = nil then
|
||||
Continue;
|
||||
|
||||
{ Оверлей играется одной дорожкой поверх основы, а одна дорожка
|
||||
не может показывать два куска разом. Если такой клип наложился
|
||||
на уже взятый, он остаётся в основе и сводится по-старому:
|
||||
потерять его было бы хуже. }
|
||||
if OverlapsTaken(Clip.TlStart, Clip.TlEnd) then
|
||||
Continue;
|
||||
|
||||
Hidden.Add(Clip);
|
||||
|
||||
Ov := Default(TPreviewOverlay);
|
||||
Ov.TlStart := Clip.TlStart;
|
||||
Ov.TlEnd := Clip.TlEnd;
|
||||
Ov.FilePath := Item.EffectivePath;
|
||||
Ov.SrcStart := Clip.SrcIn;
|
||||
Ov.Filter := OverlayFilter(Clip.Transform,
|
||||
Item.Info.Width, Item.Info.Height,
|
||||
AProject.Master.Width, AProject.Master.Height,
|
||||
Ov.X, Ov.Y, Ov.Width, Ov.Height);
|
||||
SetLength(AOverlays, N + 1);
|
||||
AOverlays[N] := Ov;
|
||||
Inc(N);
|
||||
end;
|
||||
end;
|
||||
|
||||
{ По времени: список кусков оверлея строится подряд, дырами между
|
||||
ними. }
|
||||
for I := 1 to High(AOverlays) do
|
||||
begin
|
||||
Ov := AOverlays[I];
|
||||
C := I - 1;
|
||||
while (C >= 0) and (AOverlays[C].TlStart > Ov.TlStart) do
|
||||
begin
|
||||
AOverlays[C + 1] := AOverlays[C];
|
||||
Dec(C);
|
||||
end;
|
||||
AOverlays[C + 1] := Ov;
|
||||
end;
|
||||
|
||||
Result := FlattenStream(AProject, tkVideo, Total, AVideo, AMissing, Hidden);
|
||||
if not Result then
|
||||
Exit;
|
||||
FlattenStream(AProject, tkAudio, Total, AAudio, MissA);
|
||||
finally
|
||||
Hidden.Free;
|
||||
end;
|
||||
|
||||
for I := 0 to High(MissA) do
|
||||
begin
|
||||
SetLength(AMissing, Length(AMissing) + 1);
|
||||
AMissing[High(AMissing)] := MissA[I];
|
||||
end;
|
||||
end;
|
||||
|
||||
end.
|
||||
|
|
|
|||
|
|
@ -29,7 +29,8 @@ uses
|
|||
uRemuxForm, uMismatchForm,
|
||||
uFfmpegJob, uSettings, uThumbs, uTransform, uPanCropForm,
|
||||
uProjectInfoForm, ImgList, uTheme, uLogView, uDepsForm, uWaveform,
|
||||
uIconData, uSubtitles, uTextMeasure, uTextForm, uExportForm;
|
||||
uIconData, uSubtitles, uTextMeasure, uTextForm, uExportForm,
|
||||
uColorKeyForm;
|
||||
|
||||
const
|
||||
{ Номера системных сообщений о перерисовке рамки окна. Берём числами, а
|
||||
|
|
@ -93,6 +94,9 @@ type
|
|||
FUpdatingFromPlayer: Boolean;
|
||||
FLastLoadedEdl: string;
|
||||
FLastLoadedAudioEdl: string;
|
||||
{ Наложение для плеера: список кусков оверлея и окна показа. }
|
||||
FPreviewOverlayUrl: string;
|
||||
FPreviewOverlayWindows: TOverlayWindows;
|
||||
{ Списки кусков звуковых дорожек для плеера. }
|
||||
FPreviewAudioUrls: array of string;
|
||||
{ Звуковые дорожки предпросмотра — их номера в проекте, а не сами
|
||||
|
|
@ -260,6 +264,8 @@ type
|
|||
procedure ApplyPreviewFrame;
|
||||
procedure RebuildPreview;
|
||||
procedure LoadPreviewEdl(const ASegments, AAudio: TFlatSegmentArray);
|
||||
procedure BuildOverlayPreview(const AOverlays: TPreviewOverlays;
|
||||
const ATotal: Double);
|
||||
procedure GenerateDone(Sender: TObject);
|
||||
procedure GenerateProgress(Sender: TObject);
|
||||
procedure GenerateLog(Sender: TObject);
|
||||
|
|
@ -289,6 +295,7 @@ type
|
|||
procedure MenuSplit(Sender: TObject);
|
||||
procedure MenuSplitPrecise(Sender: TObject);
|
||||
procedure MenuDelete(Sender: TObject);
|
||||
procedure MenuColorKey(Sender: TObject);
|
||||
procedure MenuSelectAll(Sender: TObject);
|
||||
procedure MenuRipple(Sender: TObject);
|
||||
procedure MenuToggleRipple(Sender: TObject);
|
||||
|
|
@ -643,6 +650,7 @@ begin
|
|||
@MenuSplitPrecise, ShortCut(VK_S, [ssShift]));
|
||||
AddItem(MEdit, 'Удалить выделенное', @MenuDelete, ShortCut(VK_DELETE, []));
|
||||
AddItem(MEdit, 'Выделить всё', @MenuSelectAll, ShortCut(VK_A, [ssCtrl]));
|
||||
AddItem(MEdit, 'Цветовой ключ клипа...', @MenuColorKey);
|
||||
AddSep(MEdit);
|
||||
AddItem(MEdit, 'Схлопнуть дыру под курсором', @MenuRipple,
|
||||
ShortCut(VK_R, [ssCtrl, ssShift]));
|
||||
|
|
@ -1850,6 +1858,7 @@ end;
|
|||
procedure TMainForm.RebuildPreview;
|
||||
var
|
||||
Segments, AudioSegs, TrackSegs: TFlatSegmentArray;
|
||||
Overlays: TPreviewOverlays;
|
||||
Missing, TrackMissing: TGenRequests;
|
||||
I, J: Integer;
|
||||
TotalLen: Double;
|
||||
|
|
@ -1865,14 +1874,20 @@ begin
|
|||
Exit;
|
||||
end;
|
||||
|
||||
if not FlattenProject(FProject, Segments, AudioSegs, Missing) then
|
||||
{ Уплощение для предпросмотра: клипы, которые кладутся поверх, идут
|
||||
отдельно. Плеер положит их сам, ничего не собирая. }
|
||||
if not FlattenPreview(FProject, Segments, AudioSegs, Overlays, Missing) then
|
||||
begin
|
||||
FPlayer.Stop;
|
||||
FLastLoadedEdl := '';
|
||||
FPreviewOverlayUrl := '';
|
||||
FPreviewOverlayWindows := nil;
|
||||
UpdateTimeLabel;
|
||||
Exit;
|
||||
end;
|
||||
|
||||
BuildOverlayPreview(Overlays, TotalDuration(Segments));
|
||||
|
||||
{ Текст поверх картинки рисует сам плеер: ему отдаётся файл разметки,
|
||||
а не пересобранный кусок. Поэтому правка текста видна сразу. }
|
||||
UpdateSubtitles;
|
||||
|
|
@ -1930,6 +1945,71 @@ begin
|
|||
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;
|
||||
|
||||
procedure TMainForm.LoadPreviewEdl(const ASegments, AAudio: TFlatSegmentArray);
|
||||
var
|
||||
Edl, AudioEdl: string;
|
||||
|
|
@ -1949,6 +2029,15 @@ begin
|
|||
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)]);
|
||||
|
||||
{ Список кусков запоминаем всегда, даже если строка не изменилась:
|
||||
громкость в неё не входит, а брать её потом неоткуда. }
|
||||
|
|
@ -1970,6 +2059,7 @@ begin
|
|||
правке монтажа слышен и виден кусок не оттуда. }
|
||||
{ Внешний звук назначается до загрузки: он часть того, что открывается. }
|
||||
FPlayer.SetAudioSources(FPreviewAudioUrls);
|
||||
FPlayer.SetVideoOverlay(FPreviewOverlayUrl, FPreviewOverlayWindows);
|
||||
FPlayer.LoadUrl(Edl, Format('start=%s,pause=%s',
|
||||
[SecondsToStr(Pos), BoolToStr(WasPaused, 'yes', 'no')]));
|
||||
FLastLoadedEdl := Edl;
|
||||
|
|
@ -2315,6 +2405,8 @@ procedure TMainForm.TimelineClipTransform(Sender: TObject; AClip: TClip);
|
|||
var
|
||||
Item: TMediaItem;
|
||||
T: TMediaTransform;
|
||||
Key: TColorKey;
|
||||
Applied: Boolean;
|
||||
begin
|
||||
if AClip = nil then
|
||||
Exit;
|
||||
|
|
@ -2344,13 +2436,19 @@ begin
|
|||
end;
|
||||
|
||||
T := AClip.Transform;
|
||||
if not EditTransform(Self, Item.EffectivePath, Item.Info,
|
||||
{ Ключ отдаётся окну по ссылке: правку он подтверждает сам, и «Отмена»
|
||||
кадрирования её не отменяет. }
|
||||
Key := AClip.ColorKey;
|
||||
Applied := EditTransform(Self, Item.EffectivePath, Item.Info,
|
||||
FProject.Master.Width, FProject.Master.Height, T,
|
||||
FProject.CacheDir + 'pancrop.png') then
|
||||
FProject.CacheDir + 'pancrop.png', @Key);
|
||||
if (not Applied) and SameColorKey(Key, AClip.ColorKey) then
|
||||
Exit;
|
||||
|
||||
FProject.BeginChange;
|
||||
AClip.Transform := T;
|
||||
if Applied then
|
||||
AClip.Transform := T;
|
||||
AClip.ColorKey := Key;
|
||||
FTimeline.Invalidate;
|
||||
FLastLoadedEdl := '';
|
||||
SchedulePreview;
|
||||
|
|
@ -2914,6 +3012,63 @@ 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('Цветовой ключ включён: участок будет пересобран.')
|
||||
else
|
||||
SetStatus('Цветовой ключ выключен.');
|
||||
end;
|
||||
|
||||
procedure TMainForm.MenuSelectAll(Sender: TObject);
|
||||
begin
|
||||
FTimeline.SelectAll;
|
||||
|
|
|
|||
|
|
@ -59,6 +59,13 @@ type
|
|||
FHwdec: string;
|
||||
FAudioSource: string;
|
||||
FAudioSources: array of string;
|
||||
{ Наложение: список кусков оверлея и окна, когда и куда его класть. }
|
||||
FOverlayUrl: string;
|
||||
FOverlayWindows: TOverlayWindows;
|
||||
{ Номера видеодорожек: своя (основа) и подключённая для наложения.
|
||||
Пусто — наложения нет. }
|
||||
FBaseVid: string;
|
||||
FOverlayVid: string;
|
||||
FTrackVolumes: array of Double;
|
||||
{ Номера подключённых звуковых дорожек, в порядке подключения. }
|
||||
FAudioIds: array of string;
|
||||
|
|
@ -97,11 +104,16 @@ type
|
|||
усмотрению. Получалось, что звук слышен там, где на монтаже его нет
|
||||
вовсе — он приходил из видеофайла, а не с звуковой дорожки. }
|
||||
procedure SelectExternalAudio;
|
||||
{ Подключает оверлей второй видеодорожкой и запоминает её номер. }
|
||||
procedure AttachOverlay;
|
||||
{ Часть цепочки, которая кладёт оверлей на основу. Пусто — нечего
|
||||
класть. }
|
||||
function OverlayGraph: string;
|
||||
{ Цепочка смешивания звуковых дорожек с их громкостями. }
|
||||
{ Собственная запись в журнал программы. Идёт тем же путём, что и
|
||||
сообщения самого mpv: у окна для них уже есть обработчик. }
|
||||
procedure Note(const AText: string);
|
||||
procedure BuildAudioGraph;
|
||||
procedure BuildGraph;
|
||||
function SetOption(const AName, AValue: string): Integer;
|
||||
public
|
||||
constructor Create(AVideoParent: TWinControl);
|
||||
|
|
@ -167,6 +179,12 @@ type
|
|||
{ Несколько звуковых дорожек монтажа разом. Плеер играет только одну,
|
||||
поэтому смесь собирается его же средствами — см. AttachAudio. }
|
||||
procedure SetAudioSources(const AUrls: array of string);
|
||||
{ Что класть поверх картинки. Пустой список кусков — ничего.
|
||||
|
||||
Наложение делает сам плеер, ничего заранее не собирается: две
|
||||
дорожки играются одна поверх другой. }
|
||||
procedure SetVideoOverlay(const AUrl: string;
|
||||
const AWindows: TOverlayWindows);
|
||||
|
||||
{ Громкость каждой звуковой дорожки монтажа. Подставляется в цепочку
|
||||
смешивания: одной общей громкостью тут не обойтись. }
|
||||
|
|
@ -634,6 +652,7 @@ begin
|
|||
Снимать её после загрузки поздно. }
|
||||
FLastGraph := '';
|
||||
FAudioIds := nil;
|
||||
FOverlayVid := '';
|
||||
mpv_set_property_string(FHandle, 'lavfi-complex', '');
|
||||
|
||||
Rest := AOptions;
|
||||
|
|
@ -753,6 +772,17 @@ begin
|
|||
FAudioSource := '';
|
||||
end;
|
||||
|
||||
procedure TMpvPlayer.SetVideoOverlay(const AUrl: string;
|
||||
const AWindows: TOverlayWindows);
|
||||
var
|
||||
I: Integer;
|
||||
begin
|
||||
FOverlayUrl := AUrl;
|
||||
SetLength(FOverlayWindows, Length(AWindows));
|
||||
for I := 0 to High(AWindows) do
|
||||
FOverlayWindows[I] := AWindows[I];
|
||||
end;
|
||||
|
||||
procedure TMpvPlayer.SetTrackVolumes(const AVolumes: array of Double);
|
||||
var
|
||||
I: Integer;
|
||||
|
|
@ -760,7 +790,7 @@ begin
|
|||
SetLength(FTrackVolumes, Length(AVolumes));
|
||||
for I := 0 to High(AVolumes) do
|
||||
FTrackVolumes[I] := AVolumes[I];
|
||||
BuildAudioGraph;
|
||||
BuildGraph;
|
||||
end;
|
||||
|
||||
procedure TMpvPlayer.SetHwdec(const AMode: string);
|
||||
|
|
@ -843,29 +873,135 @@ begin
|
|||
end;
|
||||
end;
|
||||
|
||||
procedure TMpvPlayer.BuildAudioGraph;
|
||||
{ Наложение оверлея на основу.
|
||||
|
||||
Оверлей — обычная видеодорожка, подключённая рядом с основной. Класть её
|
||||
поверх умеет сам плеер, поэтому ничего не собирается заранее: правка
|
||||
видна сразу, а видеокарта не тратится на пересборку всего ролика.
|
||||
|
||||
Окна с одинаковым размером и местом объединяются в одну ветку: масштаб
|
||||
во времени не меняется, а вот показывать и прятать можно по времени —
|
||||
это и делает enable. }
|
||||
function TMpvPlayer.OverlayGraph: string;
|
||||
var
|
||||
I, J, N, Count: Integer;
|
||||
Filters: array of string;
|
||||
Xs, Ys: array of Integer;
|
||||
Enables: array of string;
|
||||
Src, Prev, Next, Fit: string;
|
||||
|
||||
{ Номер ветки для окна: у одинаковых по виду и месту она общая. }
|
||||
function GroupOf(const AWin: TOverlayWindow): Integer;
|
||||
var
|
||||
K: Integer;
|
||||
begin
|
||||
for K := 0 to Count - 1 do
|
||||
if (Filters[K] = AWin.Filter) and (Xs[K] = AWin.X) and
|
||||
(Ys[K] = AWin.Y) then
|
||||
Exit(K);
|
||||
SetLength(Filters, Count + 1);
|
||||
SetLength(Xs, Count + 1);
|
||||
SetLength(Ys, Count + 1);
|
||||
SetLength(Enables, Count + 1);
|
||||
Filters[Count] := AWin.Filter;
|
||||
Xs[Count] := AWin.X;
|
||||
Ys[Count] := AWin.Y;
|
||||
Enables[Count] := '';
|
||||
Result := Count;
|
||||
Inc(Count);
|
||||
end;
|
||||
|
||||
begin
|
||||
Result := '';
|
||||
if (FOverlayVid = '') or (FBaseVid = '') or
|
||||
(Length(FOverlayWindows) = 0) then
|
||||
Exit;
|
||||
|
||||
Count := 0;
|
||||
for I := 0 to High(FOverlayWindows) do
|
||||
begin
|
||||
J := GroupOf(FOverlayWindows[I]);
|
||||
if Enables[J] <> '' then
|
||||
Enables[J] := Enables[J] + '+';
|
||||
Enables[J] := Enables[J] + Format('between(t,%s,%s)',
|
||||
[SecondsToStr(FOverlayWindows[I].TlStart),
|
||||
SecondsToStr(FOverlayWindows[I].TlEnd)]);
|
||||
end;
|
||||
|
||||
{ Одна ветка берёт оверлей напрямую, несколько — через split. }
|
||||
if Count = 1 then
|
||||
Result := Format('[vid%s]', [FOverlayVid])
|
||||
else
|
||||
begin
|
||||
Result := Format('[vid%s]split=%d', [FOverlayVid, Count]);
|
||||
for I := 0 to Count - 1 do
|
||||
Result := Result + Format('[q%d]', [I]);
|
||||
Result := Result + ';';
|
||||
end;
|
||||
|
||||
for I := 0 to Count - 1 do
|
||||
begin
|
||||
if Count = 1 then
|
||||
Src := ''
|
||||
else
|
||||
Src := Format('[q%d]', [I]);
|
||||
Fit := Filters[I];
|
||||
if Fit = '' then
|
||||
Fit := 'null';
|
||||
Result := Result + Format('%s%s[ov%d];', [Src, Fit, I]);
|
||||
end;
|
||||
|
||||
Prev := Format('[vid%s]', [FBaseVid]);
|
||||
for I := 0 to Count - 1 do
|
||||
begin
|
||||
if I = Count - 1 then
|
||||
Next := '[vo]'
|
||||
else
|
||||
Next := Format('[m%d]', [I]);
|
||||
{ eof_action=pass и repeatlast=0: оверлей короче основы — обычное
|
||||
дело, и после его конца картинка должна идти дальше как ни в чём
|
||||
не бывало. }
|
||||
Result := Result + Format('%s[ov%d]overlay=%d:%d:enable=''%s''' +
|
||||
':eof_action=pass:repeatlast=0%s;',
|
||||
[Prev, I, Xs[I], Ys[I], Enables[I], Next]);
|
||||
Prev := Next;
|
||||
end;
|
||||
end;
|
||||
|
||||
procedure TMpvPlayer.BuildGraph;
|
||||
var
|
||||
I: Integer;
|
||||
Graph, Vol: string;
|
||||
Graph, Vol, Video: string;
|
||||
NeedAudioPart: Boolean;
|
||||
begin
|
||||
if FHandle = nil then
|
||||
Exit;
|
||||
|
||||
Video := OverlayGraph;
|
||||
|
||||
{ Цепочка отбирает дорожки: всё, что в ней не упомянуто, mpv снимает с
|
||||
выбора. Поэтому при наложении звук приходится вести через неё же,
|
||||
даже когда дорожка одна, — иначе он просто пропадёт. }
|
||||
NeedAudioPart := (Length(FAudioIds) > 1) or
|
||||
((Video <> '') and (Length(FAudioIds) > 0));
|
||||
|
||||
if Length(FAudioIds) = 0 then
|
||||
begin
|
||||
{ Дорожек нет — снимаем и цепочку, и приглушение. Иначе они пережили
|
||||
бы смену монтажа и заглушили следующий. }
|
||||
{ Дорожек нет — снимаем приглушение, иначе оно пережило бы смену
|
||||
монтажа и заглушило следующий. }
|
||||
SetVolume(100);
|
||||
if FLastGraph <> '' then
|
||||
Graph := Video;
|
||||
if Graph <> FLastGraph then
|
||||
begin
|
||||
FLastGraph := '';
|
||||
mpv_set_property_string(FHandle, 'lavfi-complex', '');
|
||||
FLastGraph := Graph;
|
||||
mpv_set_property_string(FHandle, 'lavfi-complex', PAnsiChar(Graph));
|
||||
end;
|
||||
Exit;
|
||||
end;
|
||||
|
||||
{ Одна дорожка играется как есть: цепочка фильтров тут только мешала бы,
|
||||
да и громкость для неё задаётся обычным способом. }
|
||||
if Length(FAudioIds) = 1 then
|
||||
{ Одна дорожка без наложения играется как есть: цепочка тут только
|
||||
мешала бы, да и громкость задаётся обычным способом. }
|
||||
if not NeedAudioPart then
|
||||
begin
|
||||
Graph := '';
|
||||
if (Length(FTrackVolumes) > 0) and (Abs(FTrackVolumes[0] - 1) > 0.001) then
|
||||
|
|
@ -873,6 +1009,15 @@ begin
|
|||
else
|
||||
SetVolume(100);
|
||||
end
|
||||
else if Length(FAudioIds) = 1 then
|
||||
begin
|
||||
SetVolume(100);
|
||||
if Length(FTrackVolumes) > 0 then
|
||||
Vol := SecondsToStr(FTrackVolumes[0])
|
||||
else
|
||||
Vol := '1';
|
||||
Graph := Format('[aid%s]volume=%s[ao];', [FAudioIds[0], Vol]);
|
||||
end
|
||||
else
|
||||
begin
|
||||
{ Общая громкость возвращается на место обязательно.
|
||||
|
|
@ -895,16 +1040,56 @@ begin
|
|||
end;
|
||||
for I := 0 to High(FAudioIds) do
|
||||
Graph := Graph + Format('[t%d]', [I]);
|
||||
Graph := Graph + Format('amix=inputs=%d:normalize=0[ao]',
|
||||
Graph := Graph + Format('amix=inputs=%d:normalize=0[ao];',
|
||||
[Length(FAudioIds)]);
|
||||
end;
|
||||
|
||||
Graph := Video + Graph;
|
||||
if Graph = FLastGraph then
|
||||
Exit;
|
||||
FLastGraph := Graph;
|
||||
mpv_set_property_string(FHandle, 'lavfi-complex', PAnsiChar(Graph));
|
||||
end;
|
||||
|
||||
{ Оверлей подключается второй видеодорожкой.
|
||||
|
||||
Как и со звуком, через свойство это не делается: нужна команда
|
||||
video-add. Дорожка добавляется без выбора — выбирать её нельзя, иначе
|
||||
она заменила бы картинку. Её дело — быть входом цепочки. }
|
||||
procedure TMpvPlayer.AttachOverlay;
|
||||
var
|
||||
Count, I: Integer;
|
||||
Kind, Ext, Id: string;
|
||||
begin
|
||||
if FHandle = nil then
|
||||
Exit;
|
||||
|
||||
FBaseVid := '';
|
||||
FOverlayVid := '';
|
||||
|
||||
if (FOverlayUrl <> '') and (Length(FOverlayWindows) > 0) then
|
||||
Command(['video-add', FOverlayUrl, 'auto']);
|
||||
|
||||
Count := StrToIntDef(GetPropString('track-list/count'), 0);
|
||||
for I := 0 to Count - 1 do
|
||||
begin
|
||||
Kind := GetPropString(Format('track-list/%d/type', [I]));
|
||||
if Kind <> 'video' then
|
||||
Continue;
|
||||
Id := GetPropString(Format('track-list/%d/id', [I]));
|
||||
if Id = '' then
|
||||
Continue;
|
||||
Ext := GetPropString(Format('track-list/%d/external', [I]));
|
||||
if (Ext = 'yes') or (Ext = 'true') then
|
||||
begin
|
||||
if FOverlayVid = '' then
|
||||
FOverlayVid := Id;
|
||||
end
|
||||
else if FBaseVid = '' then
|
||||
FBaseVid := Id;
|
||||
end;
|
||||
end;
|
||||
|
||||
{ Субтитры подключаются двумя путями, и оба нужны.
|
||||
|
||||
Здесь — когда файл поменялся, а картинка та же: снимаем прежние и
|
||||
|
|
@ -979,7 +1164,7 @@ begin
|
|||
FAudioTrackNote := 'Звук: дорожек монтажа ' + IntToStr(Found) + '.';
|
||||
{ Цепочка строится отдельно: она зависит ещё и от громкостей, а те
|
||||
меняются по ходу воспроизведения. }
|
||||
BuildAudioGraph;
|
||||
BuildGraph;
|
||||
Exit;
|
||||
end;
|
||||
|
||||
|
|
@ -1027,7 +1212,13 @@ begin
|
|||
HandleLogMessage(Pmpv_event_log_message(Ev^.data));
|
||||
MPV_EVENT_FILE_LOADED:
|
||||
begin
|
||||
{ Оверлей подключается до звука: цепочка строится один раз, и в
|
||||
ней должно быть сразу всё. }
|
||||
AttachOverlay;
|
||||
SelectExternalAudio;
|
||||
{ Звуковых дорожек может не оказаться вовсе, а наложение при
|
||||
этом есть — тогда цепочку никто больше не соберёт. }
|
||||
BuildGraph;
|
||||
{ Загрузка нового списка кусков сбрасывает все внешние дорожки,
|
||||
в том числе субтитры. Подключаем заново. }
|
||||
if FSubtitlePath <> '' then
|
||||
|
|
|
|||
|
|
@ -179,6 +179,15 @@ object PanCropForm: TPanCropForm
|
|||
OnClick = btnCancelClick
|
||||
TabOrder = 9
|
||||
end
|
||||
object btnKey: TThemedButton
|
||||
Left = 12
|
||||
Height = 30
|
||||
Top = 534
|
||||
Width = 356
|
||||
Caption = 'Цветовой ключ...'
|
||||
OnClick = btnKeyClick
|
||||
TabOrder = 10
|
||||
end
|
||||
end
|
||||
object pbSource: TPaintBox
|
||||
Left = 380
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ interface
|
|||
|
||||
uses
|
||||
Classes, SysUtils, Forms, Controls, Graphics, ExtCtrls, StdCtrls, Spin,
|
||||
ComCtrls, uTransform, uMediaProbe, uTheme;
|
||||
ComCtrls, uTransform, uMediaProbe, uProject, uTheme;
|
||||
|
||||
type
|
||||
|
||||
|
|
@ -29,6 +29,7 @@ type
|
|||
|
||||
TPanCropForm = class(TForm)
|
||||
btnCancel: TThemedButton;
|
||||
btnKey: TThemedButton;
|
||||
btnOk: TThemedButton;
|
||||
btnReset: TThemedButton;
|
||||
cbFit: TComboBox;
|
||||
|
|
@ -49,6 +50,7 @@ type
|
|||
tbOffsetY: TTrackBar;
|
||||
tbZoom: TTrackBar;
|
||||
procedure btnCancelClick(Sender: TObject);
|
||||
procedure btnKeyClick(Sender: TObject);
|
||||
procedure btnOkClick(Sender: TObject);
|
||||
procedure btnResetClick(Sender: TObject);
|
||||
procedure ControlChanged(Sender: TObject);
|
||||
|
|
@ -64,6 +66,11 @@ type
|
|||
private
|
||||
FTransform: TMediaTransform;
|
||||
FSourcePath: string;
|
||||
{ Цветовой ключ правится тут же, но принадлежит не окну, а клипу:
|
||||
окно лишь показывает его хозяину. Пусто — правит нечего (так бывает
|
||||
у заготовки для файла, у которой клипа ещё нет), и кнопки не
|
||||
будет. }
|
||||
FKey: PColorKey;
|
||||
FSourceInfo: TMediaInfo;
|
||||
FDstWidth: Integer;
|
||||
FDstHeight: Integer;
|
||||
|
|
@ -103,21 +110,28 @@ type
|
|||
{ Готовит окно. ADstWidth и ADstHeight — кадр проекта. }
|
||||
procedure Setup(const APath: string; const AInfo: TMediaInfo;
|
||||
ADstWidth, ADstHeight: Integer; const ATransform: TMediaTransform;
|
||||
const APreviewFile: string);
|
||||
const APreviewFile: string; AKey: PColorKey = nil);
|
||||
property Transform: TMediaTransform read FTransform;
|
||||
end;
|
||||
|
||||
{ Показывает окно и возвращает True, если настройки приняты. }
|
||||
{ AKey — цветовой ключ клипа. Нужен, чтобы правку ключа можно было
|
||||
открыть отсюда: человек ищет её там же, где кадрирование, — обе
|
||||
настройки про картинку одного клипа. Правка ключа сама себя
|
||||
подтверждает и «Отменой» этого окна не отменяется.
|
||||
|
||||
nil — ключа у этой настройки нет, кнопка не показывается. }
|
||||
function EditTransform(AOwner: TComponent; const APath: string;
|
||||
const AInfo: TMediaInfo; ADstWidth, ADstHeight: Integer;
|
||||
var ATransform: TMediaTransform; const APreviewFile: string): Boolean;
|
||||
var ATransform: TMediaTransform; const APreviewFile: string;
|
||||
AKey: PColorKey = nil): Boolean;
|
||||
|
||||
implementation
|
||||
|
||||
{$R *.lfm}
|
||||
|
||||
uses
|
||||
Math, Types, LCLIntf, uAppTypes, uFfmpegCmd, uProcRunner;
|
||||
Math, Types, LCLIntf, uAppTypes, uFfmpegCmd, uProcRunner, uColorKeyForm;
|
||||
|
||||
const
|
||||
{ Ширина показываемого кадра. Больше незачем: тут важны пропорции и то,
|
||||
|
|
@ -197,8 +211,10 @@ end;
|
|||
|
||||
procedure TPanCropForm.Setup(const APath: string; const AInfo: TMediaInfo;
|
||||
ADstWidth, ADstHeight: Integer; const ATransform: TMediaTransform;
|
||||
const APreviewFile: string);
|
||||
const APreviewFile: string; AKey: PColorKey);
|
||||
begin
|
||||
FKey := AKey;
|
||||
btnKey.Visible := AKey <> nil;
|
||||
FSourcePath := APath;
|
||||
FSourceInfo := AInfo;
|
||||
FDstWidth := ADstWidth;
|
||||
|
|
@ -295,6 +311,23 @@ begin
|
|||
ScheduleRefresh;
|
||||
end;
|
||||
|
||||
{ Правка цветового ключа того же клипа.
|
||||
|
||||
Кадр для выбора цвета окно ключа достаёт само — тот же, что и здесь:
|
||||
первый кадр часто чёрный, и тыкать в него не во что. }
|
||||
procedure TPanCropForm.btnKeyClick(Sender: TObject);
|
||||
var
|
||||
AtTime: Double;
|
||||
begin
|
||||
if FKey = nil then
|
||||
Exit;
|
||||
if FSourceInfo.Kind = mkImage then
|
||||
AtTime := 0
|
||||
else
|
||||
AtTime := Min(5, Max(0, FSourceInfo.Duration * 0.1));
|
||||
EditColorKey(FKey^, FSourcePath, AtTime, FPreviewFile + '.key.png');
|
||||
end;
|
||||
|
||||
procedure TPanCropForm.ScheduleRefresh;
|
||||
begin
|
||||
{ Вызов может прийти во время сборки окна, когда таймера ещё нет. }
|
||||
|
|
@ -446,8 +479,15 @@ begin
|
|||
SH := FRotH * K;
|
||||
CW := Min(SW, FDstWidth);
|
||||
CH := Min(SH, FDstHeight);
|
||||
FTransform.OffsetX := (FSel.Left * K - (SW - CW) / 2) / FDstWidth;
|
||||
FTransform.OffsetY := (FSel.Top * K - (SH - CH) / 2) / FDstHeight;
|
||||
{ Смещение пересчитывается по рамке только там, где рамка его и
|
||||
задаёт, — то есть где картинка шире или выше кадра. Где она меньше,
|
||||
рамка охватывает её целиком и о смещении ничего не знает; посчитанный
|
||||
по ней ноль вернул бы картинку на середину и отменил бы то, что
|
||||
выставлено ползунком. }
|
||||
if SW > FDstWidth then
|
||||
FTransform.OffsetX := (FSel.Left * K - (SW - CW) / 2) / FDstWidth;
|
||||
if SH > FDstHeight then
|
||||
FTransform.OffsetY := (FSel.Top * K - (SH - CH) / 2) / FDstHeight;
|
||||
end;
|
||||
|
||||
procedure TPanCropForm.ClampTransform;
|
||||
|
|
@ -679,13 +719,15 @@ end;
|
|||
|
||||
function EditTransform(AOwner: TComponent; const APath: string;
|
||||
const AInfo: TMediaInfo; ADstWidth, ADstHeight: Integer;
|
||||
var ATransform: TMediaTransform; const APreviewFile: string): Boolean;
|
||||
var ATransform: TMediaTransform; const APreviewFile: string;
|
||||
AKey: PColorKey): Boolean;
|
||||
var
|
||||
Form: TPanCropForm;
|
||||
begin
|
||||
Form := TPanCropForm.Create(AOwner);
|
||||
try
|
||||
Form.Setup(APath, AInfo, ADstWidth, ADstHeight, ATransform, APreviewFile);
|
||||
Form.Setup(APath, AInfo, ADstWidth, ADstHeight, ATransform, APreviewFile,
|
||||
AKey);
|
||||
Result := Form.ShowModal = mrOK;
|
||||
if Result then
|
||||
ATransform := Form.Transform;
|
||||
|
|
|
|||
|
|
@ -48,6 +48,25 @@ type
|
|||
tpTopLeft = 7, tpTopCenter = 8, tpTopRight = 9
|
||||
);
|
||||
|
||||
{ Цветовой ключ: цвет, который у клипа становится прозрачным.
|
||||
|
||||
Нужен для накладок — плиток, значков, рамок, снятых на однотонном
|
||||
фоне. Пурпурный берут потому, что в природе он почти не встречается:
|
||||
ошибиться и вырезать кусок картинки труднее, чем с зелёным.
|
||||
|
||||
Клип с ключом собрать копированием нельзя: участок пересобирается
|
||||
вместе с тем, что под ним, как и полупрозрачный. }
|
||||
TColorKey = record
|
||||
Enabled: Boolean;
|
||||
Color: TRgb;
|
||||
{ Насколько далеко от указанного цвета считать «тем же цветом», в
|
||||
процентах. Ноль — только точное совпадение. }
|
||||
Similarity: Integer;
|
||||
{ Смягчение края, в процентах. Ноль — резкая граница. }
|
||||
Blend: Integer;
|
||||
end;
|
||||
PColorKey = ^TColorKey;
|
||||
|
||||
{ Оформление текстового клипа.
|
||||
|
||||
Цвета хранятся как TColor, а в ASS переводятся: там свой порядок
|
||||
|
|
@ -121,6 +140,7 @@ type
|
|||
FFadeOut: Double;
|
||||
FText: string;
|
||||
FTextStyle: TTextStyle;
|
||||
FColorKey: TColorKey;
|
||||
public
|
||||
constructor Create;
|
||||
function TlEnd: Double;
|
||||
|
|
@ -163,6 +183,8 @@ type
|
|||
удаляются вместе. По умолчанию так связаны картинка и её звук;
|
||||
Ctrl+G связывает и развязывает что угодно. }
|
||||
property GroupId: string read FGroupId write FGroupId;
|
||||
{ Цветовой ключ клипа. Выключен — клип обычный. }
|
||||
property ColorKey: TColorKey read FColorKey write FColorKey;
|
||||
{ Текст и его оформление. Имеют смысл только у клипа вида ckText;
|
||||
у остальных лежат пустыми и в файл проекта не пишутся. }
|
||||
property Text: string read FText write FText;
|
||||
|
|
@ -336,6 +358,14 @@ const
|
|||
{ Сколько длится картинка, пока её не растянули. }
|
||||
DefaultImageDuration = 5.0;
|
||||
|
||||
{ Цветовой ключ по умолчанию. }
|
||||
function DefaultColorKey: TColorKey;
|
||||
|
||||
{ Одинаковы ли два ключа. Записи в Паскале сравнивать оператором нельзя,
|
||||
а знать, изменилось ли что-нибудь, нужно: иначе в истории отмены
|
||||
появится пустой шаг. }
|
||||
function SameColorKey(const A, B: TColorKey): Boolean;
|
||||
|
||||
{ Оформление текста по умолчанию: белые буквы с тёмной обводкой внизу по
|
||||
центру — то, что читается на любой картинке. }
|
||||
function DefaultTextStyle: TTextStyle;
|
||||
|
|
@ -380,6 +410,7 @@ begin
|
|||
FOpacity := 1;
|
||||
FTransform := DefaultTransform;
|
||||
FTextStyle := DefaultTextStyle;
|
||||
FColorKey := DefaultColorKey;
|
||||
end;
|
||||
|
||||
function TClip.TlEnd: Double;
|
||||
|
|
@ -1114,6 +1145,23 @@ begin
|
|||
Result := edSnap;
|
||||
end;
|
||||
|
||||
function SameColorKey(const A, B: TColorKey): Boolean;
|
||||
begin
|
||||
Result := (A.Enabled = B.Enabled) and (A.Color = B.Color) and
|
||||
(A.Similarity = B.Similarity) and (A.Blend = B.Blend);
|
||||
end;
|
||||
|
||||
{ Цветовой ключ по умолчанию: выключен, пурпурный, с небольшим запасом по
|
||||
сходству — сжатие видео размывает чистый цвет, и точное совпадение
|
||||
оставило бы кайму. }
|
||||
function DefaultColorKey: TColorKey;
|
||||
begin
|
||||
Result.Enabled := False;
|
||||
Result.Color := TRgb($FF00FF);
|
||||
Result.Similarity := 15;
|
||||
Result.Blend := 5;
|
||||
end;
|
||||
|
||||
{ Оформление текста по умолчанию: белые буквы с тёмной обводкой внизу по
|
||||
центру — то, что читается на любой картинке. }
|
||||
function DefaultTextStyle: TTextStyle;
|
||||
|
|
@ -1131,6 +1179,17 @@ begin
|
|||
Result.Italic := False;
|
||||
end;
|
||||
|
||||
function JsonToColorKey(AObj: TJSONObject): TColorKey;
|
||||
begin
|
||||
Result := DefaultColorKey;
|
||||
if AObj = nil then
|
||||
Exit;
|
||||
Result.Enabled := AObj.Get('enabled', Result.Enabled);
|
||||
Result.Color := TRgb(AObj.Get('color', Integer(Result.Color)));
|
||||
Result.Similarity := AObj.Get('similarity', Result.Similarity);
|
||||
Result.Blend := AObj.Get('blend', Result.Blend);
|
||||
end;
|
||||
|
||||
function JsonToTextStyle(AObj: TJSONObject): TTextStyle;
|
||||
begin
|
||||
Result := DefaultTextStyle;
|
||||
|
|
@ -1346,6 +1405,12 @@ begin
|
|||
]),
|
||||
{ Оформление пишется только у текстовых клипов: у остальных оно
|
||||
пустое, и таскать его в файле незачем. }
|
||||
'color_key', TJSONObject.Create([
|
||||
'enabled', Clip.ColorKey.Enabled,
|
||||
'color', Integer(Clip.ColorKey.Color),
|
||||
'similarity', Clip.ColorKey.Similarity,
|
||||
'blend', Clip.ColorKey.Blend
|
||||
]),
|
||||
'text', Clip.Text,
|
||||
'text_style', TJSONObject.Create([
|
||||
'font', Clip.TextStyle.FontName,
|
||||
|
|
@ -1474,6 +1539,8 @@ begin
|
|||
if Media <> nil then
|
||||
Clip.Transform := Media.Transform;
|
||||
end;
|
||||
Clip.ColorKey := JsonToColorKey(
|
||||
ClipObj.Get('color_key', TJSONObject(nil)));
|
||||
Clip.Text := ClipObj.Get('text', '');
|
||||
Clip.TextStyle := JsonToTextStyle(
|
||||
ClipObj.Get('text_style', TJSONObject(nil)));
|
||||
|
|
|
|||
|
|
@ -805,6 +805,18 @@ begin
|
|||
SecondsToStr(AClip.Duration) + ' с');
|
||||
end;
|
||||
|
||||
{ Метка цветового ключа: сам выбиваемый цвет квадратиком в нижнем левом
|
||||
углу. Иначе клип с ключом ничем не отличается от обычного, а разница
|
||||
у них большая — участок под ним будет пересобран. }
|
||||
if AClip.ColorKey.Enabled and (W > 20) then
|
||||
begin
|
||||
ACanvas.Brush.Style := bsSolid;
|
||||
ACanvas.Brush.Color := TColor(AClip.ColorKey.Color);
|
||||
ACanvas.Pen.Color := ColorClipText;
|
||||
ACanvas.Rectangle(Body.Left + 6, Body.Bottom - 15,
|
||||
Body.Left + 18, Body.Bottom - 3);
|
||||
end;
|
||||
|
||||
ACanvas.Brush.Style := bsSolid;
|
||||
end;
|
||||
|
||||
|
|
|
|||
|
|
@ -69,6 +69,19 @@ function TransformFilter(const AT: TMediaTransform;
|
|||
Отсюда честное ограничение: предпросмотр показывает кадр проекта верно,
|
||||
но собственную настройку кадрирования файла не учитывает. Её видно в
|
||||
окне кадрирования и в готовом ролике. }
|
||||
{ То же самое, но для наложения в плеере: цепочка без обкладывания
|
||||
полями и отдельно точка, куда встанет получившаяся картинка.
|
||||
|
||||
Поля и наложение — две записи одного и того же. Сборка обкладывает
|
||||
картинку полями до кадра проекта, потому что кусок должен быть
|
||||
самостоятельным файлом. Плееру поля не нужны и вредны: чёрное поле
|
||||
закрыло бы нижнее видео. Ему нужны те же самые координаты, но как место
|
||||
наложения. Считаются они одним и тем же кодом — иначе окно показывало
|
||||
бы одно, а сборка давала другое. }
|
||||
function OverlayFilter(const AT: TMediaTransform;
|
||||
ASrcWidth, ASrcHeight, ADstWidth, ADstHeight: Integer;
|
||||
out AX, AY, AWidth, AHeight: Integer): string;
|
||||
|
||||
function PreviewFitFilter(ADstWidth, ADstHeight: Integer): string;
|
||||
|
||||
function FitModeToStr(AMode: TFitMode): string;
|
||||
|
|
@ -160,8 +173,11 @@ begin
|
|||
Result := 2;
|
||||
end;
|
||||
|
||||
function TransformFilter(const AT: TMediaTransform;
|
||||
ASrcWidth, ASrcHeight, ADstWidth, ADstHeight: Integer): string;
|
||||
{ Общая часть: цепочка до обкладывания полями и место, куда встанет
|
||||
получившаяся картинка в кадре проекта. }
|
||||
function BuildTransform(const AT: TMediaTransform;
|
||||
ASrcWidth, ASrcHeight, ADstWidth, ADstHeight: Integer;
|
||||
out AX, AY, AWidth, AHeight: Integer): string;
|
||||
var
|
||||
Chain: string;
|
||||
K, KFit, KFill: Double;
|
||||
|
|
@ -269,17 +285,50 @@ begin
|
|||
Add(Format('scale=%d:%d', [PW, PH]));
|
||||
end;
|
||||
|
||||
if (PW < ADstWidth) or (PH < ADstHeight) then
|
||||
begin
|
||||
PX := Max(0, (ADstWidth - PW) div 2);
|
||||
PY := Max(0, (ADstHeight - PH) div 2);
|
||||
Add(Format('pad=%d:%d:%d:%d', [ADstWidth, ADstHeight, PX, PY]));
|
||||
end;
|
||||
{ Смещение двигает картинку и там, где её не обрезали. Когда картинка
|
||||
больше кадра, двигать можно лишь окно, через которое на неё смотрят,
|
||||
— это делает crop выше. Когда она меньше, двигать надо её саму по
|
||||
полю, иначе уменьшенный кадр навсегда прибит к середине и в угол его
|
||||
не отправить. Обе стороны считают смещение в долях кадра проекта, так
|
||||
что число значит одно и то же. }
|
||||
PX := (ADstWidth - PW) div 2 + Round(AT.OffsetX * ADstWidth);
|
||||
PY := (ADstHeight - PH) div 2 + Round(AT.OffsetY * ADstHeight);
|
||||
PX := Max(0, Min(Max(0, ADstWidth - PW), PX));
|
||||
PY := Max(0, Min(Max(0, ADstHeight - PH), PY));
|
||||
|
||||
{ Пиксель считаем квадратным: иначе плеер растянет кадр по своему
|
||||
разумению, и результат разойдётся с тем, что видно в предпросмотре. }
|
||||
Add('setsar=1');
|
||||
AX := PX;
|
||||
AY := PY;
|
||||
AWidth := PW;
|
||||
AHeight := PH;
|
||||
Result := Chain;
|
||||
end;
|
||||
|
||||
function TransformFilter(const AT: TMediaTransform;
|
||||
ASrcWidth, ASrcHeight, ADstWidth, ADstHeight: Integer): string;
|
||||
var
|
||||
X, Y, W, H: Integer;
|
||||
begin
|
||||
Result := BuildTransform(AT, ASrcWidth, ASrcHeight, ADstWidth, ADstHeight,
|
||||
X, Y, W, H);
|
||||
if Result = '' then
|
||||
Exit;
|
||||
|
||||
if (W < ADstWidth) or (H < ADstHeight) then
|
||||
Result := Result + Format(',pad=%d:%d:%d:%d', [ADstWidth, ADstHeight, X, Y]);
|
||||
|
||||
{ Пиксель считаем квадратным: иначе плеер растянет кадр по своему
|
||||
разумению, и результат разойдётся с тем, что видно в предпросмотре. }
|
||||
Result := Result + ',setsar=1';
|
||||
end;
|
||||
|
||||
function OverlayFilter(const AT: TMediaTransform;
|
||||
ASrcWidth, ASrcHeight, ADstWidth, ADstHeight: Integer;
|
||||
out AX, AY, AWidth, AHeight: Integer): string;
|
||||
begin
|
||||
Result := BuildTransform(AT, ASrcWidth, ASrcHeight, ADstWidth, ADstHeight,
|
||||
AX, AY, AWidth, AHeight);
|
||||
if Result <> '' then
|
||||
Result := Result + ',setsar=1';
|
||||
end;
|
||||
|
||||
end.
|
||||
|
|
|
|||
|
|
@ -12,9 +12,11 @@ program lfmcheck;
|
|||
{$mode objfpc}{$H+}
|
||||
|
||||
uses
|
||||
{ Interfaces нужен только для сборки: без него не связывается вся LCL.
|
||||
Окно при этом не создаётся — Application.Initialize мы не зовём. }
|
||||
Interfaces, SysUtils, TypInfo, uTheme;
|
||||
{ Interfaces нужен, чтобы связалась вся LCL и заработала загрузка
|
||||
файлов форм. }
|
||||
Interfaces, Forms, Classes, SysUtils, TypInfo, uTheme,
|
||||
uPanCropForm, uDepsForm, uMismatchForm, uProgressForm, uProjectInfoForm,
|
||||
uRemuxForm;
|
||||
|
||||
var
|
||||
Failed: Integer = 0;
|
||||
|
|
@ -41,6 +43,35 @@ begin
|
|||
end;
|
||||
end;
|
||||
|
||||
{ Создаёт окно и тут же убирает.
|
||||
|
||||
Смысл в самом создании: файл формы разбирается именно тогда. Элемент,
|
||||
который есть в файле формы и которого нет в классе, или обработчик с
|
||||
опечаткой в имени — всё это здесь и вскрывается, а иначе вскрылось бы у
|
||||
человека при открытии окна.
|
||||
|
||||
Главное окно не проверяется: оно при создании читает настройки, строит
|
||||
меню и заводит плеер — это уже не проверка формы, а запуск программы. }
|
||||
procedure CheckForm(AClass: TFormClass);
|
||||
var
|
||||
F: TForm;
|
||||
begin
|
||||
try
|
||||
F := AClass.Create(nil);
|
||||
try
|
||||
WriteLn(' ok ', AClass.ClassName, ' — форма загружена');
|
||||
finally
|
||||
F.Free;
|
||||
end;
|
||||
except
|
||||
on E: Exception do
|
||||
begin
|
||||
WriteLn(' ПЛОХО ', AClass.ClassName, ': ', E.Message);
|
||||
Inc(Failed);
|
||||
end;
|
||||
end;
|
||||
end;
|
||||
|
||||
begin
|
||||
WriteLn('Свойства из файлов форм');
|
||||
WriteLn;
|
||||
|
|
@ -56,6 +87,17 @@ begin
|
|||
CheckEnum(TypeInfo(TThemedIcon), 'tiLog');
|
||||
CheckEnum(TypeInfo(TThemedIcon), 'tiNone');
|
||||
|
||||
WriteLn;
|
||||
WriteLn('Загрузка файлов форм');
|
||||
WriteLn;
|
||||
Application.Initialize;
|
||||
CheckForm(TPanCropForm);
|
||||
CheckForm(TDepsForm);
|
||||
CheckForm(TMismatchForm);
|
||||
CheckForm(TProgressForm);
|
||||
CheckForm(TProjectInfoForm);
|
||||
CheckForm(TRemuxForm);
|
||||
|
||||
WriteLn;
|
||||
if Failed = 0 then
|
||||
WriteLn('ВЕРДИКТ: все проверки пройдены')
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ uses
|
|||
{$ENDIF}
|
||||
Classes, SysUtils, DateUtils, Math, StrUtils,
|
||||
uAppTypes, uEdl, uKeyframeIndex, uFfmpegCmd, uExport, uRemuxQueue,
|
||||
uMediaProbe, uProject, uFlatten, uGenerate, uSubtitles;
|
||||
uMediaProbe, uProject, uFlatten, uGenerate, uSubtitles, uTransform;
|
||||
|
||||
procedure PrintUsage;
|
||||
begin
|
||||
|
|
@ -561,8 +561,12 @@ procedure CmdFlatTest;
|
|||
var
|
||||
Proj: TProject;
|
||||
Media: TMediaItem;
|
||||
Segs: TFlatSegmentArray;
|
||||
Segs, AudioSegs: TFlatSegmentArray;
|
||||
Overlays: TPreviewOverlays;
|
||||
Missing: TGenRequests;
|
||||
Clip: TClip;
|
||||
Key: TColorKey;
|
||||
Tr: TMediaTransform;
|
||||
TempFile, Err: string;
|
||||
begin
|
||||
WriteLn('Проверка уплощения монтажа');
|
||||
|
|
@ -730,6 +734,53 @@ begin
|
|||
DeleteFile(TempFile);
|
||||
WriteLn;
|
||||
|
||||
{ --- 8. Оверлей в предпросмотре не закрывает нижний клип --- }
|
||||
WriteLn(' 8. Клип с ключом поверх: основа и оверлей врозь');
|
||||
Proj := MakeProject(2, Media);
|
||||
try
|
||||
Proj.Master := Media.Info;
|
||||
{ Нижняя дорожка — обычный кусок, верхняя — тот же файл с выбитым
|
||||
цветом во всю длину. Так выглядит компас или карта поверх ролика. }
|
||||
Proj.AddClip(Proj.Tracks[1], Media, 0, 10, 0);
|
||||
Clip := Proj.AddClip(Proj.Tracks[0], Media, 100, 10, 0);
|
||||
Key := DefaultColorKey;
|
||||
Key.Enabled := True;
|
||||
Clip.ColorKey := Key;
|
||||
Tr := Clip.Transform;
|
||||
Tr.Zoom := 0.5;
|
||||
Clip.Transform := Tr;
|
||||
|
||||
{ Обычное уплощение: побеждает верхний, нижнего не видно. }
|
||||
FlattenStream(Proj, tkVideo, 0, Segs, Missing);
|
||||
Check(Length(Segs) = 1, 'при сборке кусок один');
|
||||
if Length(Segs) = 1 then
|
||||
Check(Abs(Segs[0].SrcStart - 100) < 0.001, 'и это верхний клип');
|
||||
|
||||
{ Уплощение для предпросмотра: верхний ушёл в оверлей. }
|
||||
Check(FlattenPreview(Proj, Segs, AudioSegs, Overlays, Missing),
|
||||
'уплощение для предпросмотра прошло');
|
||||
DumpSegments(Segs);
|
||||
Check(Length(Segs) = 1, 'основа из одного куска');
|
||||
if Length(Segs) = 1 then
|
||||
Check(Abs(Segs[0].SrcStart - 0) < 0.001, 'в основе виден нижний клип');
|
||||
Check(Length(Overlays) = 1, 'оверлей один');
|
||||
if Length(Overlays) = 1 then
|
||||
begin
|
||||
Check(Abs(Overlays[0].TlStart) < 0.001, 'оверлей с начала');
|
||||
Check(Abs(Overlays[0].TlEnd - 10) < 0.001, 'и до конца');
|
||||
Check(Abs(Overlays[0].SrcStart - 100) < 0.001, 'берётся с 100 с');
|
||||
Check((Overlays[0].Width = 960) and (Overlays[0].Height = 540),
|
||||
'уменьшен вдвое: ' + IntToStr(Overlays[0].Width) + 'x' +
|
||||
IntToStr(Overlays[0].Height));
|
||||
Check((Overlays[0].X = 480) and (Overlays[0].Y = 270),
|
||||
'и стоит в середине кадра: ' + IntToStr(Overlays[0].X) + ':' +
|
||||
IntToStr(Overlays[0].Y));
|
||||
end;
|
||||
finally
|
||||
Proj.Free;
|
||||
end;
|
||||
WriteLn;
|
||||
|
||||
WriteLn;
|
||||
if FailCount = 0 then
|
||||
begin
|
||||
|
|
@ -797,6 +848,58 @@ begin
|
|||
end;
|
||||
end;
|
||||
|
||||
{ Проект для проверки цветового ключа: нижний клип во всю длину и
|
||||
верхний с выбитым пурпурным поверх него. Собранное потом смотрим по
|
||||
точкам кадра: где был пурпурный, должно быть видно нижний клип. }
|
||||
procedure CmdKeyTest;
|
||||
var
|
||||
Proj: TProject;
|
||||
Info: TMediaInfo;
|
||||
Bottom, Top: TMediaItem;
|
||||
Clip: TClip;
|
||||
Key: TColorKey;
|
||||
Err: string;
|
||||
begin
|
||||
Proj := TProject.Create;
|
||||
try
|
||||
Proj.Clear;
|
||||
Proj.AddTrack('V1');
|
||||
Proj.AddTrack('V2');
|
||||
Proj.EnsureAudioTracks;
|
||||
|
||||
if not ProbeMedia(ExpandFileName(ParamStr(3)), Info, Err) then
|
||||
begin
|
||||
WriteLn('Не удалось прочитать нижнее видео: ', Err);
|
||||
Halt(1);
|
||||
end;
|
||||
Bottom := Proj.AddMedia(ExpandFileName(ParamStr(3)), Info);
|
||||
WriteLn('низ: ', DescribeMedia(Info));
|
||||
Proj.AddClipPair(Proj.Tracks[1], Bottom, 0, 3, 0);
|
||||
|
||||
if not ProbeMedia(ExpandFileName(ParamStr(4)), Info, Err) then
|
||||
begin
|
||||
WriteLn('Не удалось прочитать верхнее видео: ', Err);
|
||||
Halt(1);
|
||||
end;
|
||||
Top := Proj.AddMedia(ExpandFileName(ParamStr(4)), Info);
|
||||
WriteLn('верх: ', DescribeMedia(Info));
|
||||
Clip := Proj.AddClip(Proj.Tracks[0], Top, 0, 3, 0);
|
||||
|
||||
Key := DefaultColorKey;
|
||||
Key.Enabled := True;
|
||||
Clip.ColorKey := Key;
|
||||
|
||||
if not Proj.SaveToFile(ExpandFileName(ParamStr(2)), Err) then
|
||||
begin
|
||||
WriteLn('Не удалось записать проект: ', Err);
|
||||
Halt(1);
|
||||
end;
|
||||
WriteLn('проект записан: ', Proj.FileName);
|
||||
finally
|
||||
Proj.Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
procedure CmdBuildCut;
|
||||
var
|
||||
Proj: TProject;
|
||||
|
|
@ -1203,6 +1306,8 @@ begin
|
|||
CmdFlatTest
|
||||
else if (Cmd = 'build') and (ParamCount >= 3) then
|
||||
CmdBuild
|
||||
else if (Cmd = 'keytest') and (ParamCount >= 4) then
|
||||
CmdKeyTest
|
||||
else if (Cmd = 'buildcut') and (ParamCount >= 3) then
|
||||
CmdBuildCut
|
||||
else if (Cmd = 'subs') and (ParamCount >= 2) then
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue