Клип со своим кадрированием играл в окне во весь кадр: цепочка фильтров у mpv одна на весь список кусков, а включить её на одном куске и выключить на следующем нечем — из фильтров ffmpeg по времени переключается только наложение. Обошлось без цепочки вовсе. У вывода mpv своя обрезка, и делает её видеокарта: курсор зашёл в клип с кадрированием — выставили, вышел — сняли. Ни перестройки цепочки, ни копирования кадров из памяти видеокарты в обычную. Рамку источника считает теперь один код на две стороны: по ней окно «Кадрирование и поворот» рисует рамку, и её же плеер отдаёт как обрезку. Проверка в tests/lfmcheck сверяет рамку с фильтром сборки: увеличение 2.77 даёт 970x546 посередине, и это ровно то, что вырезает crop=2688:1512:2379:1338 после scale=7446:4188. Проверено глазами на невидимом экране, на настоящем материале с дрона: картинка в окне совпала с готовым роликом, на границе с обычным клипом обрезка снимается и возвращается. Сверено со сборкой из прошлого коммита — там на том же месте кадр без кадрирования. Не покрыто и записано в BACKLOG: отражение (свойства для него нет), растяжение и участки с наложением — там кадр уже сведён цепочкой.
437 lines
18 KiB
ObjectPascal
437 lines
18 KiB
ObjectPascal
{
|
||
uTransform — как вписать кадр исходника в кадр проекта.
|
||
|
||
Зачем это нужно. Проект имеет одно разрешение на всё — иначе куски не
|
||
склеить копированием. Файл с другим разрешением или другим соотношением
|
||
сторон надо во что-то превратить, и вариантов ровно три: вписать целиком
|
||
с полями, заполнить кадр с обрезкой краёв, растянуть с искажением
|
||
пропорций. Раньше молча делалось третье, и вертикальное видео
|
||
превращалось в размазанное по ширине — это была ошибка.
|
||
|
||
Все размеры считаются числами здесь, а не выражениями внутри фильтра
|
||
ffmpeg. Выражения там пишутся через экранированные запятые и читаются
|
||
тяжело, а проверить посчитанные числа можно глазами прямо в журнале.
|
||
|
||
Преобразование хранится у файла, а не у клипа: оно описывает, как этот
|
||
материал вообще ложится в проект, и одинаково для всех его кусков.
|
||
}
|
||
unit uTransform;
|
||
|
||
{$mode objfpc}{$H+}
|
||
|
||
interface
|
||
|
||
uses
|
||
Classes, SysUtils, uAppTypes;
|
||
|
||
type
|
||
{ Как соотносятся кадр исходника и кадр проекта. }
|
||
TFitMode = (
|
||
fmFit, // вписать целиком, по краям поля
|
||
fmFill, // заполнить кадр, лишнее обрезать
|
||
fmStretch // растянуть, пропорции исказятся
|
||
);
|
||
|
||
TMediaTransform = record
|
||
Fit: TFitMode;
|
||
{ Поворот по часовой стрелке: 0, 90, 180 или 270. }
|
||
Rotation: Integer;
|
||
FlipH: Boolean;
|
||
FlipV: Boolean;
|
||
{ Дополнительное увеличение поверх выбранного режима. Единица —
|
||
без увеличения. }
|
||
Zoom: Double;
|
||
{ Смещение кадра в долях от размера кадра проекта. Ноль — по центру. }
|
||
OffsetX: Double;
|
||
OffsetY: Double;
|
||
end;
|
||
|
||
function DefaultTransform: TMediaTransform;
|
||
|
||
{ Нужно ли вообще что-то делать: совпадающий по размеру кадр без поворота
|
||
и смещений трогать незачем. }
|
||
function TransformIsIdentity(const AT: TMediaTransform;
|
||
ASrcWidth, ASrcHeight, ADstWidth, ADstHeight: Integer): Boolean;
|
||
|
||
{ Цепочка фильтров ffmpeg. Пустая строка означает «ничего делать не надо». }
|
||
function TransformFilter(const AT: TMediaTransform;
|
||
ASrcWidth, ASrcHeight, ADstWidth, ADstHeight: Integer): string;
|
||
|
||
{ Цепочка для предпросмотра: вписать любой кадр в кадр проекта.
|
||
|
||
От TransformFilter отличается двумя вещами. Во-первых, размеры исходника
|
||
здесь неизвестны — цепочка одна на весь предпросмотр, а куски в нём из
|
||
разных файлов, поэтому вместо посчитанных чисел идут выражения самого
|
||
ffmpeg. Во-вторых, она знает только вписывание целиком: заполнение с
|
||
обрезкой, поворот и смещение задаются для каждого файла отдельно, а
|
||
фильтр общий, и выбрать между ними он не может.
|
||
|
||
Отсюда честное ограничение: предпросмотр показывает кадр проекта верно,
|
||
но собственную настройку кадрирования файла не учитывает. Её видно в
|
||
окне кадрирования и в готовом ролике. }
|
||
{ То же самое, но для наложения в плеере: цепочка без обкладывания
|
||
полями и отдельно точка, куда встанет получившаяся картинка.
|
||
|
||
Поля и наложение — две записи одного и того же. Сборка обкладывает
|
||
картинку полями до кадра проекта, потому что кусок должен быть
|
||
самостоятельным файлом. Плееру поля не нужны и вредны: чёрное поле
|
||
закрыло бы нижнее видео. Ему нужны те же самые координаты, но как место
|
||
наложения. Считаются они одним и тем же кодом — иначе окно показывало
|
||
бы одно, а сборка давала другое. }
|
||
function OverlayFilter(const AT: TMediaTransform;
|
||
ASrcWidth, ASrcHeight, ADstWidth, ADstHeight: Integer;
|
||
out AX, AY, AWidth, AHeight: Integer): string;
|
||
|
||
{ Какая часть исходника видна в кадре проекта — прямоугольник в точках
|
||
самого исходника.
|
||
|
||
Та же настройка, записанная с другой стороны: вместо «как вписать» —
|
||
«что останется». Окно кадрирования рисует по ней рамку, а плеер отдаёт
|
||
её mpv как обрезку на выводе, и оба обязаны считать одинаково — иначе
|
||
окно обещает одно, а предпросмотр показывает другое.
|
||
|
||
False — прямоугольником не описать: при растяжении берётся весь кадр и
|
||
сжимается неравномерно. }
|
||
function TransformSourceRect(const AT: TMediaTransform;
|
||
ASrcWidth, ASrcHeight, ADstWidth, ADstHeight: Integer;
|
||
out ARect: TRect): Boolean;
|
||
|
||
{ Можно ли показать это преобразование обрезкой на выводе плеера.
|
||
|
||
Обрезка умеет только выбрать кусок кадра. Поворот у mpv задаётся
|
||
отдельным свойством и потому разрешён, а отражение — нет: свойства для
|
||
него нет, и такой клип по-прежнему показывается как есть. }
|
||
function TransformFitsOutputCrop(const AT: TMediaTransform): Boolean;
|
||
|
||
{ Фильтр цветового ключа: этот цвет становится прозрачным.
|
||
|
||
Один на всех: и сборка, и предпросмотр должны выбивать одинаково, иначе
|
||
окно обещает одно, а готовый ролик даёт другое. Цвет записывается как
|
||
0xRRGGBB — порядок обратный тому, в каком он хранится у нас. }
|
||
function ColorKeyFilter(AColor: TRgb; ASimilarity, ABlend: Integer): string;
|
||
|
||
{ Фильтр приглушения по альфа-каналу. Пусто, если приглушать нечего. }
|
||
function OpacityFilter(const AOpacity: Double): string;
|
||
|
||
function PreviewFitFilter(ADstWidth, ADstHeight: Integer): string;
|
||
|
||
function FitModeToStr(AMode: TFitMode): string;
|
||
function StrToFitMode(const AValue: string): TFitMode;
|
||
function FitModeCaption(AMode: TFitMode): string;
|
||
|
||
implementation
|
||
|
||
uses
|
||
Math;
|
||
|
||
function DefaultTransform: TMediaTransform;
|
||
begin
|
||
Result.Fit := fmFit;
|
||
Result.Rotation := 0;
|
||
Result.FlipH := False;
|
||
Result.FlipV := False;
|
||
Result.Zoom := 1;
|
||
Result.OffsetX := 0;
|
||
Result.OffsetY := 0;
|
||
end;
|
||
|
||
function FitModeToStr(AMode: TFitMode): string;
|
||
begin
|
||
case AMode of
|
||
fmFill: Result := 'fill';
|
||
fmStretch: Result := 'stretch';
|
||
else
|
||
Result := 'fit';
|
||
end;
|
||
end;
|
||
|
||
function StrToFitMode(const AValue: string): TFitMode;
|
||
begin
|
||
if AValue = 'fill' then
|
||
Result := fmFill
|
||
else if AValue = 'stretch' then
|
||
Result := fmStretch
|
||
else
|
||
Result := fmFit;
|
||
end;
|
||
|
||
function FitModeCaption(AMode: TFitMode): string;
|
||
begin
|
||
case AMode of
|
||
fmFill: Result := 'Заполнить кадр (лишнее обрезается)';
|
||
fmStretch: Result := 'Растянуть (пропорции исказятся)';
|
||
else
|
||
Result := 'Вписать целиком (по краям поля)';
|
||
end;
|
||
end;
|
||
|
||
function TransformIsIdentity(const AT: TMediaTransform;
|
||
ASrcWidth, ASrcHeight, ADstWidth, ADstHeight: Integer): Boolean;
|
||
begin
|
||
Result := (AT.Rotation mod 360 = 0) and (not AT.FlipH) and (not AT.FlipV) and
|
||
(Abs(AT.Zoom - 1) < 0.001) and (Abs(AT.OffsetX) < 0.001) and
|
||
(Abs(AT.OffsetY) < 0.001) and
|
||
(ASrcWidth = ADstWidth) and (ASrcHeight = ADstHeight);
|
||
end;
|
||
|
||
function TransformSourceRect(const AT: TMediaTransform;
|
||
ASrcWidth, ASrcHeight, ADstWidth, ADstHeight: Integer;
|
||
out ARect: TRect): Boolean;
|
||
var
|
||
KFit, KFill, K: Double;
|
||
SW, SH, CW, CH, CX, CY: Double;
|
||
begin
|
||
ARect := Rect(0, 0, ASrcWidth, ASrcHeight);
|
||
Result := False;
|
||
if (ASrcWidth <= 0) or (ASrcHeight <= 0) or
|
||
(ADstWidth <= 0) or (ADstHeight <= 0) then
|
||
Exit;
|
||
{ Растяжение рамкой не описывается: берётся весь кадр и жмётся
|
||
неравномерно. }
|
||
if AT.Fit = fmStretch then
|
||
Exit;
|
||
|
||
KFit := Min(ADstWidth / ASrcWidth, ADstHeight / ASrcHeight);
|
||
KFill := Max(ADstWidth / ASrcWidth, ADstHeight / ASrcHeight);
|
||
if AT.Fit = fmFill then
|
||
K := KFill
|
||
else
|
||
K := KFit;
|
||
if AT.Zoom > 0 then
|
||
K := K * AT.Zoom;
|
||
if K <= 0 then
|
||
Exit;
|
||
|
||
{ Кадр, растянутый до рабочего масштаба, и та его часть, что помещается
|
||
в кадр проекта. Смещение задаётся в долях кадра проекта и прижимается
|
||
к краям: выйти за пределы картинки нельзя. }
|
||
SW := ASrcWidth * K;
|
||
SH := ASrcHeight * K;
|
||
CW := Min(SW, ADstWidth);
|
||
CH := Min(SH, ADstHeight);
|
||
CX := (SW - CW) / 2 + AT.OffsetX * ADstWidth;
|
||
CY := (SH - CH) / 2 + AT.OffsetY * ADstHeight;
|
||
CX := Max(0, Min(SW - CW, CX));
|
||
CY := Max(0, Min(SH - CH, CY));
|
||
|
||
ARect := Rect(Round(CX / K), Round(CY / K),
|
||
Round((CX + CW) / K), Round((CY + CH) / K));
|
||
Result := True;
|
||
end;
|
||
|
||
function TransformFitsOutputCrop(const AT: TMediaTransform): Boolean;
|
||
begin
|
||
Result := (not AT.FlipH) and (not AT.FlipV) and (AT.Fit <> fmStretch);
|
||
end;
|
||
|
||
{ Дробное число через точку: у ffmpeg разделитель всегда точка, а в
|
||
русской локали Format вставил бы запятую и развалил бы всю цепочку. }
|
||
function FilterNum(const AValue: Double): string;
|
||
begin
|
||
Result := SecondsToStr(AValue);
|
||
end;
|
||
|
||
function ColorKeyFilter(AColor: TRgb; ASimilarity, ABlend: Integer): string;
|
||
begin
|
||
Result := Format('colorkey=0x%.2x%.2x%.2x:%s:%s',
|
||
[AColor and $FF, (AColor shr 8) and $FF, (AColor shr 16) and $FF,
|
||
FilterNum(ASimilarity / 100), FilterNum(ABlend / 100)]);
|
||
end;
|
||
|
||
function OpacityFilter(const AOpacity: Double): string;
|
||
begin
|
||
if AOpacity >= 0.999 then
|
||
Result := ''
|
||
else
|
||
Result := 'colorchannelmixer=aa=' + FilterNum(Max(0, AOpacity));
|
||
end;
|
||
|
||
function PreviewFitFilter(ADstWidth, ADstHeight: Integer): string;
|
||
var
|
||
W, H: string;
|
||
begin
|
||
Result := '';
|
||
if (ADstWidth < 2) or (ADstHeight < 2) then
|
||
Exit;
|
||
W := IntToStr(ADstWidth);
|
||
H := IntToStr(ADstHeight);
|
||
|
||
{ force_original_aspect_ratio=decrease вписывает кадр целиком, не искажая
|
||
пропорции; pad добавляет поля до размера проекта; setsar=1 убирает
|
||
неквадратный пиксель, иначе mpv растянет уже готовый кадр обратно. }
|
||
Result :=
|
||
'scale=' + W + ':' + H + ':force_original_aspect_ratio=decrease,' +
|
||
'pad=' + W + ':' + H + ':(ow-iw)/2:(oh-ih)/2,' +
|
||
'setsar=1';
|
||
end;
|
||
|
||
{ Кодировщики требуют чётных сторон: цветность в yuv420p хранится вдвое
|
||
прореженной, и нечётный размер ей некуда деть. }
|
||
function MakeEven(AValue: Integer): Integer;
|
||
begin
|
||
Result := AValue;
|
||
if Odd(Result) then
|
||
Inc(Result);
|
||
if Result < 2 then
|
||
Result := 2;
|
||
end;
|
||
|
||
{ Общая часть: цепочка до обкладывания полями и место, куда встанет
|
||
получившаяся картинка в кадре проекта. }
|
||
function BuildTransform(const AT: TMediaTransform;
|
||
ASrcWidth, ASrcHeight, ADstWidth, ADstHeight: Integer;
|
||
out AX, AY, AWidth, AHeight: Integer): string;
|
||
var
|
||
Chain: string;
|
||
K, KFit, KFill: Double;
|
||
SW, SH: Integer;
|
||
CW, CH, CX, CY: Integer;
|
||
PW, PH, PX, PY: Integer;
|
||
Rot: Integer;
|
||
|
||
procedure Add(const AFilter: string);
|
||
begin
|
||
if Chain <> '' then
|
||
Chain := Chain + ',';
|
||
Chain := Chain + AFilter;
|
||
end;
|
||
|
||
begin
|
||
Result := '';
|
||
if (ASrcWidth <= 0) or (ASrcHeight <= 0) or
|
||
(ADstWidth <= 0) or (ADstHeight <= 0) then
|
||
Exit;
|
||
|
||
Chain := '';
|
||
|
||
{ Порядок действий: сначала выбирается кусок кадра, и только потом он
|
||
поворачивается и отражается.
|
||
|
||
Так это устроено в монтажных программах, и так понятнее: рамка
|
||
показывает, что́ останется, а поворот применяется к оставшемуся. При
|
||
обратном порядке обрезался бы уже перевёрнутый кадр, и в рамку
|
||
попадало бы совсем не то, что видно. }
|
||
|
||
{ --- Масштаб --- }
|
||
if AT.Fit = fmStretch then
|
||
begin
|
||
SW := MakeEven(ADstWidth);
|
||
SH := MakeEven(ADstHeight);
|
||
end
|
||
else
|
||
begin
|
||
KFit := Min(ADstWidth / ASrcWidth, ADstHeight / ASrcHeight);
|
||
KFill := Max(ADstWidth / ASrcWidth, ADstHeight / ASrcHeight);
|
||
if AT.Fit = fmFill then
|
||
K := KFill
|
||
else
|
||
K := KFit;
|
||
if AT.Zoom > 0 then
|
||
K := K * AT.Zoom;
|
||
SW := MakeEven(Round(ASrcWidth * K));
|
||
SH := MakeEven(Round(ASrcHeight * K));
|
||
end;
|
||
Add(Format('scale=%d:%d', [SW, SH]));
|
||
|
||
{ --- Обрезка того, что вылезло за кадр --- }
|
||
CW := Min(SW, MakeEven(ADstWidth));
|
||
CH := Min(SH, MakeEven(ADstHeight));
|
||
if (CW < SW) or (CH < SH) then
|
||
begin
|
||
{ Смещение задаётся в долях кадра проекта и прижимается к границам:
|
||
выйти за пределы картинки нельзя, ffmpeg на это ругается. }
|
||
CX := Round((SW - CW) / 2 + AT.OffsetX * ADstWidth);
|
||
CY := Round((SH - CH) / 2 + AT.OffsetY * ADstHeight);
|
||
CX := Max(0, Min(SW - CW, CX));
|
||
CY := Max(0, Min(SH - CH, CY));
|
||
Add(Format('crop=%d:%d:%d:%d', [CW, CH, CX, CY]));
|
||
end;
|
||
|
||
{ --- Поворот и отражение оставшегося куска --- }
|
||
Rot := ((AT.Rotation mod 360) + 360) mod 360;
|
||
case Rot of
|
||
90:
|
||
begin
|
||
{ transpose=1 — поворот на 90 по часовой стрелке. }
|
||
Add('transpose=1');
|
||
PW := CH; PH := CW;
|
||
end;
|
||
180:
|
||
begin
|
||
{ Два поворота подряд дешевле и точнее, чем поворот на
|
||
произвольный угол: последний пересчитывает каждый пиксель. }
|
||
Add('transpose=1,transpose=1');
|
||
PW := CW; PH := CH;
|
||
end;
|
||
270:
|
||
begin
|
||
Add('transpose=2');
|
||
PW := CH; PH := CW;
|
||
end;
|
||
else
|
||
PW := CW; PH := CH;
|
||
end;
|
||
if AT.FlipH then
|
||
Add('hflip');
|
||
if AT.FlipV then
|
||
Add('vflip');
|
||
|
||
{ --- Приведение к кадру проекта --- }
|
||
|
||
{ После поворота на 90 кусок лежит поперёк и в кадр проекта уже не
|
||
вписывается. Вписываем целиком, с полями. }
|
||
if (PW > ADstWidth) or (PH > ADstHeight) then
|
||
begin
|
||
K := Min(ADstWidth / PW, ADstHeight / PH);
|
||
PW := MakeEven(Round(PW * K));
|
||
PH := MakeEven(Round(PH * K));
|
||
Add(Format('scale=%d:%d', [PW, PH]));
|
||
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));
|
||
|
||
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.
|