Отмеченный участок. Клавиши I и O ставят начало и конец по курсору, полоса под линейкой показывает отмеченное, сборка собирает только его. Без этого проверить правку на длинном монтаже нельзя: каждая проверка стоит столько же, сколько идёт весь ролик. Отметки живут в проекте. Разметка текста при этом берётся с начала участка, иначе надписи легли бы со сдвигом. Фотография пропадала из ролика. Кусок с наложением подаёт слои в ffmpeg как есть, а одиночная картинка даёт ровно один кадр: кусок в три секунды получался длиной 0.016667 с. Теперь у слоя есть признак «это картинка», и она повторяется ключом -loop с частотой кадров проекта. Текст пропадал там же. Вжигание помечает кусок разметкой, но ветка наложения про неё не знала. Пока оверлей лежит на всём ролике, этой веткой идут все куски — и текста не было нигде. Теперь разметка вжигается последним звеном цепочки, поверх сведённой картинки. Качество пересобранных кусков. Битрейт не задавался вовсе, и кодировщик брал своё значение по умолчанию: 5.9 Мбит/с там, где исходник 56. Теперь пересборка целится в наибольший битрейт среди файлов проекта, с потолком в полтора раза выше и буфером вдвое. Тем же битрейтом делаются вставки из фотографий. Проверки: обрезка списка кусков по участку (nlvetool flattest, 9), сборка фотографии под наложением и вжигание текста поверх наложения (nlvetool render ... burn).
1223 lines
53 KiB
ObjectPascal
1223 lines
53 KiB
ObjectPascal
{
|
||
uFfmpegCmd — сборка аргументов для ffmpeg и ffprobe и разбор их вывода.
|
||
|
||
Весь знающий про ffmpeg код собран здесь, чтобы команды можно было
|
||
прочитать в одном месте и сверить с тем, что реально запускается.
|
||
|
||
Про выбор параметров.
|
||
|
||
-ss и -to ставятся ДО -i. Это принципиально: в таком порядке ffmpeg
|
||
перематывает по индексу файла, а не читает его насквозь, и при
|
||
копировании потока даёт точную длину. Обратный порядок на пробе давал
|
||
574 кадра вместо 600.
|
||
|
||
Склейка через concat-демультиплексор, но БЕЗ директив inpoint и outpoint:
|
||
на пробе они резали неточно и не пересчитывали метки времени. Каждый
|
||
кусок вырезается отдельным запуском, и только потом всё склеивается.
|
||
|
||
Пути в списке склейки пишутся с явным префиксом file:. Без него ffmpeg
|
||
подставляет к ним протокол родительского входа и не находит файлы.
|
||
|
||
Явный -map 0:v:0 и -map 0:a:0? обязателен: камеры вроде DJI кладут в файл
|
||
потоки телеметрии и MJPEG-обложку, и без отбора в результат попадёт мусор.
|
||
Вопросительный знак у аудио делает его необязательным — иначе файл без
|
||
звука вообще не обработается.
|
||
}
|
||
unit uFfmpegCmd;
|
||
|
||
{$mode objfpc}{$H+}
|
||
|
||
interface
|
||
|
||
uses
|
||
Classes, SysUtils, uAppTypes, uProcRunner;
|
||
|
||
type
|
||
TArgs = array of string;
|
||
|
||
{ Пути к инструментам. Ищутся один раз и запоминаются. }
|
||
function FfmpegExe: string;
|
||
function FfprobeExe: string;
|
||
|
||
{ Забыть найденные пути. Нужно после того, как файлы доложили рядом с
|
||
программой: иначе она до перезапуска считает, что их нет. }
|
||
procedure ResetToolSearch;
|
||
|
||
{ Проверяет, что оба инструмента на месте. }
|
||
function ToolsAvailable(out AError: string): Boolean;
|
||
|
||
{ Вырезать кусок исходника без перекодирования.
|
||
|
||
AStart обязан попадать на опорный кадр, иначе начало куска будет
|
||
испорчено: декодер не сможет восстановить кадры, которые ссылаются на
|
||
не попавший в вырезку опорный. Конец на опорный кадр попадать не обязан.
|
||
|
||
AFormat — 'matroska' или 'mpegts', см. пояснение к NeedsTsIntermediate.
|
||
|
||
AVolume — множитель громкости. При единице звук копируется как есть;
|
||
при любом другом значении его приходится пересобрать, потому что
|
||
громкость нельзя изменить, не тронув сам звук. Видео при этом
|
||
по-прежнему копируется — перекодируется только звуковая дорожка. }
|
||
function ArgsExtract(const ASrc, ADst: string; AStart, AEnd: Double;
|
||
const AFormat: string; AVolume: Double = 1;
|
||
const AAudioCodec: string = ''): TArgs;
|
||
|
||
{ Перепаковать файл целиком в заданный формат, копируя потоки. }
|
||
function ArgsCopyTo(const ASrc, ADst, AFormat: string): TArgs;
|
||
|
||
{ Перекодировать короткий отрезок исходника.
|
||
|
||
Нужно для точного реза: копировать можно только начиная с опорного
|
||
кадра, поэтому огрызок от запрошенной точки до следующего опорного
|
||
кадра приходится пересобрать. Разрешение и формат пикселей берутся
|
||
как есть у исходника — их менять не надо, надо лишь заново закодировать
|
||
те же кадры. }
|
||
function ArgsEncodeFragment(const ASrc, ADst: string; AStart, AEnd: Double;
|
||
AFpsNum, AFpsDen: Integer;
|
||
const AVideoCodec, APixFmt, AAudioCodec: string;
|
||
ASampleRate, AChannels: Integer; const AFormat: string): TArgs;
|
||
|
||
{ Пересобрать кусок в параметры проекта.
|
||
|
||
Нужно для файлов, которые не совпали с проектом и не были приведены к
|
||
нему заранее. Перекодируется только этот кусок; соседние, снятые тем же
|
||
оборудованием, что и весь проект, по-прежнему копируются. }
|
||
function ArgsTranscodeSegment(const ASrc, ADst: string; AStart, AEnd: Double;
|
||
AWidth, AHeight, AFpsNum, AFpsDen: Integer;
|
||
const AVideoCodec, APixFmt, AAudioCodec: string;
|
||
ASampleRate, AChannels: Integer; AVolume: Double;
|
||
const AFormat: string; const AFilter: string = '';
|
||
const ASubFile: string = ''; ABitrate: Int64 = 0): TArgs;
|
||
|
||
{ Путь внутри строки фильтра. Двоеточие там разделяет настройки, обратная
|
||
косая и кавычка имеют своё значение — всё это надо обезвредить, иначе
|
||
путь с пробелом или двоеточием развалит разбор. }
|
||
function EscapeFilterPath(const APath: string): string;
|
||
|
||
{ Кадр одиночной картинки для окна кадрирования. }
|
||
function ArgsPreviewFrame(const ASrc, ADst: string; const ATime: Double;
|
||
const AFilter: string): TArgs;
|
||
|
||
{ Годится ли MPEG-TS для этих кодеков.
|
||
|
||
Зачем вообще MPEG-TS. Когда в одном ролике встречаются куски от разных
|
||
кодировщиков — скопированные из камеры и созданные нами, — их наборы
|
||
параметров кодека различаются. MP4 и Matroska хранят такой набор один
|
||
на дорожку, поэтому декодер применяет параметры первого куска ко всем
|
||
остальным и на стыках сыплет ошибками. Проверено: прямая склейка дала
|
||
384 ошибки декодера HEVC, склейка через MPEG-TS — ни одной.
|
||
|
||
MPEG-TS повторяет параметры прямо в потоке перед каждым опорным кадром,
|
||
поэтому смена параметров посреди ролика для него обычное дело. Но
|
||
вмещает он не всякий кодек. }
|
||
function TsSupportsCodecs(const AVideoCodec, AAudioCodec: string): Boolean;
|
||
|
||
{ Склеить куски по списку. Формат вывода ffmpeg определит по расширению. }
|
||
function ArgsConcat(const AListFile, ADst: string): TArgs;
|
||
|
||
{ Склеить куски и сразу перекодировать картинку в заданный размер и поток.
|
||
|
||
Нужно для сборки «под площадку»: когда ролик надо отдать в 1080p с
|
||
определённым битрейтом, а исходники сняты в 2688x1512. Отдельный путь, а
|
||
не пересборка каждого куска по отдельности: здесь важно, чтобы весь
|
||
ролик получился одним кодировщиком с одними настройками — иначе на
|
||
стыках видно смену качества.
|
||
|
||
Кодировщик выбирается аппаратный: перекодирование целого ролика на
|
||
процессоре идёт медленнее реального времени, и смысл теряется. }
|
||
function ArgsConcatEncode(const AListFile, ADst: string;
|
||
AWidth, AHeight, AFpsNum, AFpsDen, ABitrateKbps: Integer;
|
||
const AVideoCodec, APixFmt: string; AHwDecode: Boolean = True): TArgs;
|
||
|
||
{ Перепаковать файл в другой контейнер без перекодирования. }
|
||
{ Звук куска в сыром виде: 16 бит со знаком, частота и число каналов
|
||
проекта. Сырой поток нужен потому, что куски потом просто дописываются
|
||
друг за другом байтами — так стык получается с точностью до отсчёта, а
|
||
не до звукового кадра. }
|
||
function ArgsExtractAudioRaw(const ASource, ATarget: string;
|
||
const AStart, ADuration: Double; ARate, AChannels: Integer;
|
||
const AVolume: Double): TArgs;
|
||
|
||
{ Сжатие собранного сырого звука. Один проход на весь ролик. }
|
||
function ArgsEncodeAudio(const ASource, ATarget: string;
|
||
ARate, AChannels: Integer; const ACodec: string): TArgs;
|
||
|
||
{ Пересборка участка с наложением: два слоя сводятся в один кадр.
|
||
|
||
Постоянная непрозрачность делается наложением с приглушённым альфа-
|
||
каналом, переход — фильтром xfade. Их нельзя перепутать: xfade умеет
|
||
плавно менять долю во времени, а простое наложение — нет.
|
||
|
||
ASubFile — разметка текста, которую надо вжечь в этот кусок. Пусто — не
|
||
вжигать. Без этого текст пропадал везде, где есть наложение: кусок шёл
|
||
этой веткой, а она про субтитры не знала. }
|
||
function ArgsRenderLayers(const ALayers: array of TSegmentLayer;
|
||
const ATarget: string; const ADuration: Double;
|
||
AWidth, AHeight, AFpsNum, AFpsDen: Integer;
|
||
const APixFmt, ACodec: string; AIsTs: Boolean;
|
||
ABitrate: Int64 = 0; const ASubFile: string = ''): TArgs;
|
||
|
||
{ Сшивание готовой картинки с готовым звуком. Оба потока копируются.
|
||
|
||
ASubFile — разметка субтитров, которую надо вложить отдельной дорожкой.
|
||
Пусто — не вкладывать. }
|
||
function ArgsMux(const AVideo, AAudio, ATarget: string;
|
||
const ASubFile: string = ''): TArgs;
|
||
|
||
function ArgsRemux(const ASrc, ADst: string): TArgs;
|
||
|
||
{ Сведения о файле в JSON. }
|
||
function ArgsProbeJson(const ASrc: string): TArgs;
|
||
|
||
{ Только длительность, одним числом. Нужна для пересчёта позиции ffmpeg
|
||
в долю выполненного. }
|
||
function ArgsProbeDuration(const ASrc: string): TArgs;
|
||
|
||
{ Число пакетов видеопотока — быстрый способ посчитать кадры без
|
||
декодирования. Читает файл целиком, но не распаковывает. }
|
||
function ArgsCountVideoPackets(const ASrc: string): TArgs;
|
||
|
||
{ Метки времени и признаки всех видеопакетов. Запасной путь построения
|
||
индекса опорных кадров для контейнеров, кроме MP4. }
|
||
function ArgsPacketFlags(const ASrc: string): TArgs;
|
||
|
||
{ Название кодировщика для кодека.
|
||
|
||
Предпочитается аппаратный NVENC: на этой машине он кодирует HEVC в
|
||
несколько раз быстрее реального времени, тогда как процессорный x265
|
||
на 2688x1512 работает медленнее реального времени. Если NVENC в сборке
|
||
ffmpeg нет, возвращается процессорный. }
|
||
function PreferredEncoder(const ACodec: string): string;
|
||
|
||
{ Перекодировать файл в параметры проекта.
|
||
|
||
Нужно, когда добавляемый файл с ними не совпадает: один раз потратить
|
||
время здесь выгоднее, чем перекодировать весь результат при каждой сборке. }
|
||
function ArgsNormalize(const ASrc, ADst: string;
|
||
AWidth, AHeight, AFpsNum, AFpsDen: Integer;
|
||
const AVideoCodec, APixFmt, AAudioCodec: string;
|
||
ASampleRate, AChannels: Integer; const AFilter: string = ''): TArgs;
|
||
|
||
{ Сделать видеоклип заданной длины из картинки. }
|
||
function ArgsImageClip(const ASrc, ADst: string; ADuration: Double;
|
||
AWidth, AHeight, AFpsNum, AFpsDen: Integer;
|
||
const AVideoCodec, APixFmt, AAudioCodec: string;
|
||
ASampleRate, AChannels: Integer; ABitrate: Int64 = 0): TArgs;
|
||
|
||
{ Сделать чёрный клип с тишиной заданной длины.
|
||
|
||
Нужен для дыр на монтаже: так дыра становится обычным куском, и её не
|
||
приходится обрабатывать особым образом ни в предпросмотре, ни в сборке. }
|
||
function ArgsBlackClip(const ADst: string; ADuration: Double;
|
||
AWidth, AHeight, AFpsNum, AFpsDen: Integer;
|
||
const AVideoCodec, APixFmt, AAudioCodec: string;
|
||
ASampleRate, AChannels: Integer): TArgs;
|
||
|
||
{ Вставка тишины для звуковой дорожки: только звук, без картинки.
|
||
|
||
Отдельно от чёрного клипа, потому что перемотка внутри видео
|
||
откатывается к опорному кадру, а в звуке опорным является каждый. }
|
||
function ArgsSilenceClip(const ADst: string; ADuration: Double;
|
||
const AAudioCodec: string; ASampleRate, AChannels: Integer): TArgs;
|
||
|
||
{ Кадр-миниатюра для списка файлов проекта.
|
||
|
||
Кадр берётся не с нуля: у многих съёмок первые секунды это крышка
|
||
объектива, разгон экспозиции или просто чернота. Картинка вписывается
|
||
в прямоугольник целиком, с чёрными полями — обрезать её нельзя, иначе
|
||
по миниатюре не узнать кадр. }
|
||
function ArgsThumbnail(const ASrc, ADst: string; const ATime: Double;
|
||
AWidth, AHeight: Integer): TArgs;
|
||
|
||
{ Строка списка для concat-демультиплексора, с экранированием. }
|
||
function ConcatListLine(const APath: string): string;
|
||
|
||
{ Разбирает строку из -progress. Возвращает True, если строка узнана.
|
||
ASeconds обновляется на позиции, AFinished — на завершении. }
|
||
function ParseProgressLine(const ALine: string; var ASeconds: Double;
|
||
var AFinished: Boolean): Boolean;
|
||
|
||
implementation
|
||
|
||
uses
|
||
StrUtils, uTransform;
|
||
|
||
{ Ключи качества. Объявлено в самом начале: ими пользуется отрисовка
|
||
слоёв, а она по тексту раньше самой функции. }
|
||
function BitrateArgs(ABitrate: Int64): TArgs; forward;
|
||
|
||
{ Общие параметры вывода в формате проекта. Объявлено заранее, потому что
|
||
используется выше по тексту, чем определено. }
|
||
function ArgsExtractAudioRaw(const ASource, ATarget: string;
|
||
const AStart, ADuration: Double; ARate, AChannels: Integer;
|
||
const AVolume: Double): TArgs;
|
||
var
|
||
Filter: string;
|
||
begin
|
||
Result := ['-hide_banner', '-nostdin', '-loglevel', 'error', '-y',
|
||
'-ss', SecondsToStr(AStart), '-t', SecondsToStr(ADuration),
|
||
'-i', ASource, '-vn', '-map', '0:a:0?'];
|
||
|
||
{ Громкость применяется здесь же: звук всё равно перекодируется, второго
|
||
прохода ради неё не нужно. }
|
||
if Abs(AVolume - 1) > 0.001 then
|
||
begin
|
||
Filter := 'volume=' + SecondsToStr(AVolume);
|
||
Result := Concat(Result, ['-af', Filter]);
|
||
end;
|
||
|
||
Result := Concat(Result,
|
||
['-ac', IntToStr(AChannels), '-ar', IntToStr(ARate),
|
||
'-f', 's16le', ATarget]);
|
||
end;
|
||
|
||
function ArgsEncodeAudio(const ASource, ATarget: string;
|
||
ARate, AChannels: Integer; const ACodec: string): TArgs;
|
||
var
|
||
Codec: string;
|
||
begin
|
||
Codec := ACodec;
|
||
if Codec = '' then
|
||
Codec := 'aac';
|
||
Result := ['-hide_banner', '-nostdin', '-loglevel', 'error', '-y',
|
||
'-f', 's16le', '-ar', IntToStr(ARate), '-ac', IntToStr(AChannels),
|
||
'-i', ASource, '-c:a', Codec, '-b:a', '192k',
|
||
'-progress', 'pipe:1', '-nostats', ATarget];
|
||
end;
|
||
|
||
function ArgsRenderLayers(const ALayers: array of TSegmentLayer;
|
||
const ATarget: string; const ADuration: Double;
|
||
AWidth, AHeight, AFpsNum, AFpsDen: Integer;
|
||
const APixFmt, ACodec: string; AIsTs: Boolean;
|
||
ABitrate: Int64; const ASubFile: string): TArgs;
|
||
var
|
||
I: Integer;
|
||
Graph, Fit, Src, OutLabel: string;
|
||
Crossfade: Boolean;
|
||
begin
|
||
Result := ['-hide_banner', '-nostdin', '-loglevel', 'warning', '-y'];
|
||
|
||
{ Каждый слой — отдельный вход, вырезанный ровно по длине участка.
|
||
|
||
Неподвижная картинка — случай особый: её надо повторять ключом -loop,
|
||
иначе ffmpeg отдаёт один-единственный кадр, и весь кусок с наложением
|
||
схлопывается в одну шестидесятую секунды. Перематывать её тоже
|
||
незачем: у картинки нет времени. }
|
||
for I := 0 to High(ALayers) do
|
||
if ALayers[I].IsImage then
|
||
Result := Concat(Result,
|
||
['-loop', '1',
|
||
'-framerate', Format('%d/%d', [AFpsNum, AFpsDen]),
|
||
'-t', SecondsToStr(ADuration),
|
||
'-i', ALayers[I].FilePath])
|
||
else
|
||
Result := Concat(Result,
|
||
['-ss', SecondsToStr(ALayers[I].SrcStart),
|
||
'-t', SecondsToStr(ADuration),
|
||
'-i', ALayers[I].FilePath]);
|
||
|
||
{ Переход отличается от наложения тем, что доля меняется во времени. }
|
||
Crossfade := (Length(ALayers) = 2) and
|
||
((Abs(ALayers[1].FadeFrom - ALayers[1].FadeTo) > 0.001) or
|
||
(Abs(ALayers[0].FadeFrom - ALayers[0].FadeTo) > 0.001));
|
||
|
||
Graph := '';
|
||
for I := 0 to High(ALayers) do
|
||
begin
|
||
{ Нижний слой — холст, он занимает весь кадр, и поля ему нужны.
|
||
Верхнему поля вредны: чёрное поле непрозрачно и закрыло бы холст,
|
||
поэтому верхний кладётся на своё место как есть. Переход — случай
|
||
особый: 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 + ',' + ColorKeyFilter(ALayers[I].KeyColor,
|
||
ALayers[I].KeySimilarity, ALayers[I].KeyBlend);
|
||
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])
|
||
{ Один слой с меняющейся долей — это затухание в чёрное или из него. }
|
||
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('[%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=%d:%d[out]',
|
||
[SecondsToStr(ALayers[High(ALayers)].Opacity),
|
||
ALayers[High(ALayers)].X, ALayers[High(ALayers)].Y]);
|
||
end;
|
||
|
||
{ Текст вжигается последним: он ложится поверх уже сведённой картинки,
|
||
как и в куске без наложения. Разметка приходит со сдвигом под этот
|
||
кусок, поэтому время в ней считается от его начала. }
|
||
if ASubFile <> '' then
|
||
Graph := Graph + Format(';[out]subtitles=filename=''%s''[outs]',
|
||
[EscapeFilterPath(ASubFile)]);
|
||
|
||
{ Кодировщик выбирается предпочтительный, а не тот, что ffmpeg возьмёт
|
||
по имени кодека. Имя «hevc» уводит его на процессорный x265, и участок
|
||
наложения считался медленнее реального времени: замерено 9.6 с
|
||
материала за 11.2 с работы. }
|
||
if ASubFile <> '' then
|
||
OutLabel := '[outs]'
|
||
else
|
||
OutLabel := '[out]';
|
||
Result := Concat(Result,
|
||
['-filter_complex', Graph, '-map', OutLabel,
|
||
'-t', SecondsToStr(ADuration),
|
||
'-c:v', PreferredEncoder(ACodec), '-r', Format('%d/%d', [AFpsNum, AFpsDen]),
|
||
'-pix_fmt', APixFmt]);
|
||
Result := Concat(Result, BitrateArgs(ABitrate));
|
||
Result := Concat(Result,
|
||
['-bf', '0', '-an', '-avoid_negative_ts', 'make_zero',
|
||
'-progress', 'pipe:1', '-nostats']);
|
||
if AIsTs then
|
||
Result := Concat(Result, ['-f', 'mpegts']);
|
||
Result := Concat(Result, [ATarget]);
|
||
end;
|
||
|
||
function ArgsMux(const AVideo, AAudio, ATarget: string;
|
||
const ASubFile: string): TArgs;
|
||
begin
|
||
Result := ['-hide_banner', '-nostdin', '-loglevel', 'warning', '-y',
|
||
'-i', AVideo, '-i', AAudio];
|
||
|
||
{ Субтитры отдельной дорожкой: ничего не перекодируется, проигрыватель
|
||
может их включить или выключить. Кодек ass сохраняет всё оформление —
|
||
в MP4 такого нет, там субтитры кладутся голым текстом, поэтому туда
|
||
их и не предлагают. }
|
||
if ASubFile <> '' then
|
||
Result := Concat(Result, ['-i', ASubFile]);
|
||
|
||
Result := Concat(Result, ['-map', '0:v:0', '-map', '1:a:0']);
|
||
if ASubFile <> '' then
|
||
Result := Concat(Result, ['-map', '2:s:0', '-c:s', 'ass',
|
||
'-metadata:s:s:0', 'title=Надписи',
|
||
'-metadata:s:s:0', 'language=rus']);
|
||
|
||
Result := Concat(Result, ['-c:v', 'copy', '-c:a', 'copy',
|
||
'-avoid_negative_ts', 'make_zero',
|
||
'-progress', 'pipe:1', '-nostats', ATarget]);
|
||
end;
|
||
|
||
function MasterOutputArgs(AWidth, AHeight, AFpsNum, AFpsDen: Integer;
|
||
const AVideoCodec, APixFmt, AAudioCodec: string;
|
||
ASampleRate, AChannels: Integer; ABitrate: Int64 = 0): TArgs; forward;
|
||
|
||
{ Запрет двунаправленных кадров. Объявлено заранее по той же причине. }
|
||
function NoBFramesArgs: TArgs; forward;
|
||
|
||
var
|
||
FFfmpeg: string = '';
|
||
FFprobe: string = '';
|
||
FToolsSearched: Boolean = False;
|
||
|
||
{ Сначала рядом с программой, потом в PATH. Под Windows ставить ffmpeg
|
||
некуда, и файлы кладут рядом; положенное туда должно побеждать. }
|
||
function LocateTool(const AName: string): string;
|
||
var
|
||
Own: string;
|
||
begin
|
||
Own := ProgramDir + AName
|
||
{$IFDEF WINDOWS} + '.exe'{$ENDIF};
|
||
if FileExists(Own) then
|
||
Exit(Own);
|
||
Result := FindTool(AName);
|
||
end;
|
||
|
||
procedure SearchTools;
|
||
begin
|
||
if FToolsSearched then
|
||
Exit;
|
||
FToolsSearched := True;
|
||
FFfmpeg := LocateTool('ffmpeg');
|
||
FFprobe := LocateTool('ffprobe');
|
||
end;
|
||
|
||
procedure ResetToolSearch;
|
||
begin
|
||
FToolsSearched := False;
|
||
FFfmpeg := '';
|
||
FFprobe := '';
|
||
end;
|
||
|
||
function FfmpegExe: string;
|
||
begin
|
||
SearchTools;
|
||
Result := FFfmpeg;
|
||
end;
|
||
|
||
function FfprobeExe: string;
|
||
begin
|
||
SearchTools;
|
||
Result := FFprobe;
|
||
end;
|
||
|
||
function ToolsAvailable(out AError: string): Boolean;
|
||
begin
|
||
AError := '';
|
||
SearchTools;
|
||
if FFfmpeg = '' then
|
||
AError := 'Не найден ffmpeg в PATH.';
|
||
if FFprobe = '' then
|
||
begin
|
||
if AError <> '' then
|
||
AError := AError + LineEnding;
|
||
AError := AError + 'Не найден ffprobe в PATH.';
|
||
end;
|
||
Result := AError = '';
|
||
end;
|
||
|
||
{ Общее начало любого запуска ffmpeg.
|
||
|
||
-nostdin нужен потому, что иначе ffmpeg считает стандартный ввод своим
|
||
и может съесть то, что ему не предназначалось.
|
||
-loglevel warning оставляет предупреждения и ошибки, но убирает
|
||
многословный разбор потоков, который забивает журнал. }
|
||
function CommonFfmpegPrefix: TArgs;
|
||
begin
|
||
Result := ['-hide_banner', '-nostdin', '-loglevel', 'warning', '-y'];
|
||
end;
|
||
|
||
{ Хвост, включающий машиночитаемый прогресс.
|
||
|
||
-progress pipe:1 шлёт в стандартный вывод блоки вида ключ=значение,
|
||
-nostats убирает человекочитаемую строку состояния, которая иначе
|
||
дублировала бы ту же информацию в потоке ошибок. }
|
||
function ProgressArgs: TArgs;
|
||
begin
|
||
Result := ['-progress', 'pipe:1', '-nostats'];
|
||
end;
|
||
|
||
function Concat(const A, B: TArgs): TArgs;
|
||
var
|
||
I: Integer;
|
||
begin
|
||
Result := nil;
|
||
SetLength(Result, Length(A) + Length(B));
|
||
for I := 0 to High(A) do
|
||
Result[I] := A[I];
|
||
for I := 0 to High(B) do
|
||
Result[Length(A) + I] := B[I];
|
||
end;
|
||
|
||
{ Нужен ли контейнеру перенос заголовка в начало файла.
|
||
|
||
Для MP4 это делает файл пригодным для воспроизведения по мере загрузки.
|
||
Стоит один дополнительный проход по готовому файлу, но он быстрый. }
|
||
function WantsFaststart(const APath: string): Boolean;
|
||
var
|
||
Ext: string;
|
||
begin
|
||
Ext := LowerCase(ExtractFileExt(APath));
|
||
Result := (Ext = '.mp4') or (Ext = '.mov') or (Ext = '.m4v');
|
||
end;
|
||
|
||
function ArgsExtract(const ASrc, ADst: string; AStart, AEnd: Double;
|
||
const AFormat: string; AVolume: Double; const AAudioCodec: string): TArgs;
|
||
begin
|
||
Result := Concat(CommonFfmpegPrefix, [
|
||
'-ss', SecondsToStr(AStart),
|
||
'-to', SecondsToStr(AEnd),
|
||
'-i', ASrc,
|
||
'-map', '0:v:0',
|
||
'-map', '0:a:0?'
|
||
]);
|
||
|
||
if Abs(AVolume - 1) < 0.001 then
|
||
Result := Concat(Result, ['-c', 'copy'])
|
||
else
|
||
begin
|
||
{ Видео копируется, звук пересобирается с новой громкостью. Так
|
||
обходится дороже, чем чистое копирование, но несопоставимо дешевле
|
||
перекодирования картинки. }
|
||
Result := Concat(Result, ['-c:v', 'copy',
|
||
'-af', 'volume=' + SecondsToStr(AVolume)]);
|
||
if AAudioCodec <> '' then
|
||
Result := Concat(Result, ['-c:a', AAudioCodec])
|
||
else
|
||
Result := Concat(Result, ['-c:a', 'aac']);
|
||
end;
|
||
|
||
{ Метки времени вырезанного куска должны начинаться с нуля, иначе
|
||
при склейке куски разъедутся по времени. }
|
||
Result := Concat(Result, ['-avoid_negative_ts', 'make_zero']);
|
||
Result := Concat(Result, ProgressArgs);
|
||
Result := Concat(Result, ['-f', AFormat, ADst]);
|
||
end;
|
||
|
||
function ArgsCopyTo(const ASrc, ADst, AFormat: string): TArgs;
|
||
begin
|
||
Result := Concat(CommonFfmpegPrefix, [
|
||
'-i', ASrc,
|
||
'-map', '0:v:0',
|
||
'-map', '0:a:0?',
|
||
'-c', 'copy'
|
||
]);
|
||
Result := Concat(Result, ProgressArgs);
|
||
Result := Concat(Result, ['-f', AFormat, ADst]);
|
||
end;
|
||
|
||
function ArgsEncodeFragment(const ASrc, ADst: string; AStart, AEnd: Double;
|
||
AFpsNum, AFpsDen: Integer;
|
||
const AVideoCodec, APixFmt, AAudioCodec: string;
|
||
ASampleRate, AChannels: Integer; const AFormat: string): TArgs;
|
||
var
|
||
HalfFrame, StopAt: Double;
|
||
begin
|
||
{ Тонкость, стоившая одного лишнего кадра на стыке.
|
||
|
||
При копировании потока -to работает как «строго меньше»: кадр ровно
|
||
на границе не попадает. При перекодировании — как «меньше либо равно»,
|
||
и кадр на границе попадает. А граница у нас — это опорный кадр, с
|
||
которого начинается следующая, скопированная часть. В итоге он
|
||
оказывался в ролике дважды: замерено 43 + 258 = 301 кадр там, где
|
||
эталонное перекодирование всего отрезка даёт 300.
|
||
|
||
Поэтому отступаем от границы на полкадра. Полкадра, а не миллисекунду,
|
||
чтобы отступ был осмысленным при любой частоте. }
|
||
HalfFrame := 0.001;
|
||
if (AFpsNum > 0) and (AFpsDen > 0) then
|
||
HalfFrame := AFpsDen / AFpsNum / 2;
|
||
StopAt := AEnd - HalfFrame;
|
||
if StopAt <= AStart then
|
||
StopAt := AEnd;
|
||
|
||
{ -ss до -i при перекодировании тоже работает точно: ffmpeg доходит до
|
||
предыдущего опорного кадра, декодирует и выбрасывает лишнее. }
|
||
Result := Concat(CommonFfmpegPrefix, [
|
||
'-ss', SecondsToStr(AStart),
|
||
'-to', SecondsToStr(StopAt),
|
||
'-i', ASrc,
|
||
'-map', '0:v:0',
|
||
'-map', '0:a:0?'
|
||
]);
|
||
{ Ни размер, ни частота кадров намеренно не задаются. Отрезок берётся
|
||
из того же файла, что и соседние куски, так что менять ему нечего.
|
||
Хуже того: принудительная частота включает пересчёт кадров, и на
|
||
пробе это добавило один лишний кадр на стыке — 601 вместо 600. }
|
||
Result := Concat(Result, MasterOutputArgs(0, 0, 0, 0,
|
||
AVideoCodec, APixFmt, AAudioCodec, ASampleRate, AChannels));
|
||
Result := Concat(Result, NoBFramesArgs);
|
||
Result := Concat(Result, ['-avoid_negative_ts', 'make_zero']);
|
||
Result := Concat(Result, ProgressArgs);
|
||
Result := Concat(Result, ['-f', AFormat, ADst]);
|
||
end;
|
||
|
||
function EscapeFilterPath(const APath: string): string;
|
||
begin
|
||
Result := StringReplace(APath, '\', '/', [rfReplaceAll]);
|
||
Result := StringReplace(Result, '''', '\''', [rfReplaceAll]);
|
||
Result := StringReplace(Result, ':', '\:', [rfReplaceAll]);
|
||
end;
|
||
|
||
function ArgsTranscodeSegment(const ASrc, ADst: string; AStart, AEnd: Double;
|
||
AWidth, AHeight, AFpsNum, AFpsDen: Integer;
|
||
const AVideoCodec, APixFmt, AAudioCodec: string;
|
||
ASampleRate, AChannels: Integer; AVolume: Double;
|
||
const AFormat: string; const AFilter: string;
|
||
const ASubFile: string; ABitrate: Int64): TArgs;
|
||
var
|
||
Chain: string;
|
||
begin
|
||
Result := Concat(CommonFfmpegPrefix, [
|
||
'-ss', SecondsToStr(AStart),
|
||
'-to', SecondsToStr(AEnd),
|
||
'-i', ASrc,
|
||
'-map', '0:v:0',
|
||
'-map', '0:a:0?'
|
||
]);
|
||
|
||
{ Как именно кадр ложится в кадр проекта, решено заранее и передано
|
||
готовой цепочкой фильтров. }
|
||
if AFilter <> '' then
|
||
Chain := AFilter
|
||
else
|
||
Chain := Format(
|
||
'scale=%d:%d:force_original_aspect_ratio=decrease,' +
|
||
'pad=%d:%d:(ow-iw)/2:(oh-ih)/2,setsar=1',
|
||
[AWidth, AHeight, AWidth, AHeight]);
|
||
|
||
{ Текст рисуется последним — уже по кадру проекта. Раньше он попал бы
|
||
под масштабирование и уехал бы вместе с картинкой. }
|
||
if ASubFile <> '' then
|
||
Chain := Chain + ',subtitles=filename=''' +
|
||
EscapeFilterPath(ASubFile) + '''';
|
||
|
||
Result := Concat(Result, ['-vf', Chain]);
|
||
|
||
if Abs(AVolume - 1) >= 0.001 then
|
||
Result := Concat(Result, ['-af', 'volume=' + SecondsToStr(AVolume)]);
|
||
|
||
Result := Concat(Result, MasterOutputArgs(0, 0, AFpsNum, AFpsDen,
|
||
AVideoCodec, APixFmt, AAudioCodec, ASampleRate, AChannels, ABitrate));
|
||
Result := Concat(Result, NoBFramesArgs);
|
||
Result := Concat(Result, ['-avoid_negative_ts', 'make_zero']);
|
||
Result := Concat(Result, ProgressArgs);
|
||
Result := Concat(Result, ['-f', AFormat, ADst]);
|
||
end;
|
||
|
||
function TsSupportsCodecs(const AVideoCodec, AAudioCodec: string): Boolean;
|
||
begin
|
||
{ Список намеренно короткий: сюда попали только те кодеки, для которых
|
||
перенос в MPEG-TS заведомо работает. Всё остальное лучше собрать
|
||
привычным способом и предупредить, чем молча выдать битый файл. }
|
||
Result := (AVideoCodec = 'h264') or (AVideoCodec = 'hevc') or
|
||
(AVideoCodec = 'mpeg2video') or (AVideoCodec = 'mpeg4');
|
||
if not Result then
|
||
Exit;
|
||
if AAudioCodec = '' then
|
||
Exit(True);
|
||
Result := (AAudioCodec = 'aac') or (AAudioCodec = 'mp3') or
|
||
(AAudioCodec = 'ac3') or (AAudioCodec = 'mp2');
|
||
end;
|
||
|
||
function ArgsConcatEncode(const AListFile, ADst: string;
|
||
AWidth, AHeight, AFpsNum, AFpsDen, ABitrateKbps: Integer;
|
||
const AVideoCodec, APixFmt: string; AHwDecode: Boolean): TArgs;
|
||
var
|
||
Fps, Enc: string;
|
||
begin
|
||
Enc := PreferredEncoder(AVideoCodec);
|
||
|
||
Result := Concat(CommonFfmpegPrefix, []);
|
||
|
||
{ Распаковка тоже на видеокарте. Замерено на паре кусков 2688x1512:
|
||
9.18 с без неё против 1.77 с с ней, впятеро.
|
||
|
||
Кадры при этом всё равно возвращаются в обычную память, а масштаб
|
||
считает процессор. Полностью карточный путь через scale_cuda в том же
|
||
замере оказался медленнее (5.74 с) — видимо, из-за преобразований
|
||
формата, и городить его незачем.
|
||
|
||
Признак необязательный: если распаковка не пойдёт, вызывающий
|
||
повторит без неё. }
|
||
if AHwDecode and (Pos('nvenc', Enc) > 0) then
|
||
Result := Concat(Result, ['-hwaccel', 'cuda']);
|
||
|
||
Result := Concat(Result, [
|
||
'-f', 'concat',
|
||
'-safe', '0',
|
||
'-i', AListFile,
|
||
'-map', '0:v:0',
|
||
'-map', '0:a:0?'
|
||
]);
|
||
|
||
{ Вписываем целиком и добиваем полями: соотношение сторон менять нельзя,
|
||
иначе кадр растянется. }
|
||
if (AWidth > 0) and (AHeight > 0) then
|
||
Result := Concat(Result, ['-vf', Format(
|
||
'scale=%d:%d:force_original_aspect_ratio=decrease,' +
|
||
'pad=%d:%d:(ow-iw)/2:(oh-ih)/2,setsar=1',
|
||
[AWidth, AHeight, AWidth, AHeight])]);
|
||
|
||
Result := Concat(Result, ['-c:v', Enc]);
|
||
if ABitrateKbps > 0 then
|
||
{ Верхняя граница и запас буфера задаются вместе со средним потоком:
|
||
без них кодировщик волен выдать всплеск вдвое выше заказанного. }
|
||
Result := Concat(Result, [
|
||
'-b:v', IntToStr(ABitrateKbps) + 'k',
|
||
'-maxrate', IntToStr(Round(ABitrateKbps * 1.5)) + 'k',
|
||
'-bufsize', IntToStr(ABitrateKbps * 2) + 'k']);
|
||
|
||
Fps := '';
|
||
if (AFpsNum > 0) and (AFpsDen > 0) then
|
||
Fps := IntToStr(AFpsNum) + '/' + IntToStr(AFpsDen);
|
||
if Fps <> '' then
|
||
Result := Concat(Result, ['-r', Fps]);
|
||
if APixFmt <> '' then
|
||
Result := Concat(Result, ['-pix_fmt', APixFmt]);
|
||
|
||
{ Звук уже собран и сжат отдельным проходом — трогать его незачем. }
|
||
Result := Concat(Result, ['-c:a', 'copy']);
|
||
|
||
Result := Concat(Result, ProgressArgs);
|
||
if WantsFaststart(ADst) then
|
||
Result := Concat(Result, ['-movflags', '+faststart']);
|
||
Result := Concat(Result, [ADst]);
|
||
end;
|
||
|
||
function ArgsConcat(const AListFile, ADst: string): TArgs;
|
||
begin
|
||
Result := Concat(CommonFfmpegPrefix, [
|
||
'-f', 'concat',
|
||
{ Без -safe 0 демультиплексор откажется брать абсолютные пути. }
|
||
'-safe', '0',
|
||
'-i', AListFile,
|
||
'-map', '0:v:0',
|
||
'-map', '0:a:0?',
|
||
'-c', 'copy'
|
||
]);
|
||
Result := Concat(Result, ProgressArgs);
|
||
if WantsFaststart(ADst) then
|
||
Result := Concat(Result, ['-movflags', '+faststart']);
|
||
Result := Concat(Result, [ADst]);
|
||
end;
|
||
|
||
function ArgsRemux(const ASrc, ADst: string): TArgs;
|
||
begin
|
||
Result := Concat(CommonFfmpegPrefix, [
|
||
'-i', ASrc,
|
||
{ Здесь берём всё, что есть: задача ремукса — сменить контейнер, а не
|
||
отбирать содержимое. -ignore_unknown отбрасывает потоки, которые
|
||
целевой контейнер не умеет, вместо того чтобы падать с ошибкой. }
|
||
'-map', '0',
|
||
'-ignore_unknown',
|
||
'-c', 'copy'
|
||
]);
|
||
Result := Concat(Result, ProgressArgs);
|
||
if WantsFaststart(ADst) then
|
||
Result := Concat(Result, ['-movflags', '+faststart']);
|
||
Result := Concat(Result, [ADst]);
|
||
end;
|
||
|
||
function ArgsProbeJson(const ASrc: string): TArgs;
|
||
begin
|
||
Result := ['-v', 'error', '-print_format', 'json',
|
||
'-show_format', '-show_streams', ASrc];
|
||
end;
|
||
|
||
function ArgsProbeDuration(const ASrc: string): TArgs;
|
||
begin
|
||
Result := ['-v', 'error', '-show_entries', 'format=duration',
|
||
'-of', 'csv=p=0', ASrc];
|
||
end;
|
||
|
||
function ArgsCountVideoPackets(const ASrc: string): TArgs;
|
||
begin
|
||
Result := ['-v', 'error', '-select_streams', 'v:0', '-count_packets',
|
||
'-show_entries', 'stream=nb_read_packets', '-of', 'csv=p=0', ASrc];
|
||
end;
|
||
|
||
function ArgsPacketFlags(const ASrc: string): TArgs;
|
||
begin
|
||
Result := ['-v', 'error', '-select_streams', 'v:0',
|
||
'-show_entries', 'packet=pts_time,flags', '-of', 'csv=p=0', ASrc];
|
||
end;
|
||
|
||
{ --- Выбор кодировщика --- }
|
||
|
||
type
|
||
TEncoderCollector = class
|
||
private
|
||
FNames: TStringList;
|
||
public
|
||
constructor Create;
|
||
destructor Destroy; override;
|
||
procedure Line(const ALine: string);
|
||
property Names: TStringList read FNames;
|
||
end;
|
||
|
||
constructor TEncoderCollector.Create;
|
||
begin
|
||
inherited Create;
|
||
FNames := TStringList.Create;
|
||
FNames.Sorted := True;
|
||
FNames.Duplicates := dupIgnore;
|
||
end;
|
||
|
||
destructor TEncoderCollector.Destroy;
|
||
begin
|
||
FNames.Free;
|
||
inherited Destroy;
|
||
end;
|
||
|
||
procedure TEncoderCollector.Line(const ALine: string);
|
||
var
|
||
S: string;
|
||
P: Integer;
|
||
begin
|
||
{ Строки списка кодировщиков выглядят так:
|
||
V....D hevc_nvenc NVIDIA NVENC hevc encoder
|
||
Первое поле — флаги, второе — название. }
|
||
S := Trim(ALine);
|
||
P := Pos(' ', S);
|
||
if P <= 0 then
|
||
Exit;
|
||
S := Trim(Copy(S, P + 1, Length(S) - P));
|
||
P := Pos(' ', S);
|
||
if P > 0 then
|
||
S := Copy(S, 1, P - 1);
|
||
if S <> '' then
|
||
FNames.Add(S);
|
||
end;
|
||
|
||
var
|
||
FEncoders: TStringList = nil;
|
||
|
||
{ Список кодировщиков спрашивается у ffmpeg один раз за запуск: он не
|
||
меняется, а вызов процесса не бесплатный. }
|
||
procedure EnsureEncoderList;
|
||
var
|
||
Collector: TEncoderCollector;
|
||
begin
|
||
if FEncoders <> nil then
|
||
Exit;
|
||
FEncoders := TStringList.Create;
|
||
FEncoders.Sorted := True;
|
||
FEncoders.Duplicates := dupIgnore;
|
||
|
||
if FfmpegExe = '' then
|
||
Exit;
|
||
|
||
Collector := TEncoderCollector.Create;
|
||
try
|
||
RunProcess(FfmpegExe, ['-hide_banner', '-encoders'], @Collector.Line, nil, nil);
|
||
FEncoders.AddStrings(Collector.Names);
|
||
finally
|
||
Collector.Free;
|
||
end;
|
||
end;
|
||
|
||
function HasEncoder(const AName: string): Boolean;
|
||
var
|
||
Dummy: Integer;
|
||
begin
|
||
EnsureEncoderList;
|
||
Result := FEncoders.Find(AName, Dummy);
|
||
end;
|
||
|
||
function PreferredEncoder(const ACodec: string): string;
|
||
begin
|
||
if ACodec = 'hevc' then
|
||
begin
|
||
if HasEncoder('hevc_nvenc') then
|
||
Exit('hevc_nvenc');
|
||
Exit('libx265');
|
||
end;
|
||
|
||
if (ACodec = 'h264') or (ACodec = 'avc1') then
|
||
begin
|
||
if HasEncoder('h264_nvenc') then
|
||
Exit('h264_nvenc');
|
||
Exit('libx264');
|
||
end;
|
||
|
||
if ACodec = 'av1' then
|
||
begin
|
||
if HasEncoder('av1_nvenc') then
|
||
Exit('av1_nvenc');
|
||
if HasEncoder('libsvtav1') then
|
||
Exit('libsvtav1');
|
||
Exit('libaom-av1');
|
||
end;
|
||
|
||
{ Незнакомый кодек — пусть ffmpeg сам подберёт кодировщик по названию. }
|
||
Result := ACodec;
|
||
end;
|
||
|
||
{ Запрет двунаправленных кадров.
|
||
|
||
B-кадры хранят разницу и с предыдущим, и со следующим кадром, поэтому
|
||
порядок декодирования отличается от порядка показа. Для длинного видео
|
||
это экономит место, а для наших коротких вставок экономия ничтожна, зато
|
||
переупорядочивание создаёт настоящую проблему: замерено, что метки
|
||
декодирования перекодированного огрызка после переноса в Matroska идут
|
||
назад (5.101 -> 5.068 -> 5.051), давая двадцать совпадающих меток на
|
||
шестьсот кадров.
|
||
|
||
Без B-кадров порядок декодирования совпадает с порядком показа, и вопрос
|
||
снимается целиком. }
|
||
function NoBFramesArgs: TArgs;
|
||
begin
|
||
Result := ['-bf', '0'];
|
||
end;
|
||
|
||
{ Реализация объявленной выше функции. }
|
||
{ Ключи качества для пересобираемых кусков.
|
||
|
||
Без них кодировщик берёт своё значение по умолчанию, а оно скромное: на
|
||
съёмке 2688x1512 с 56 Мбит/с NVENC выдавал 5.9 Мбит/с, и пересобранный
|
||
кусок бросался в глаза среди скопированных. Поэтому целимся в тот же
|
||
битрейт, что у исходного материала.
|
||
|
||
Кроме среднего задаётся потолок и размер буфера: без них кодировщик
|
||
разгоняется на сложных кадрах ровно настолько, насколько ему позволит
|
||
средний, и мелкие детали всё равно пропадают. }
|
||
function BitrateArgs(ABitrate: Int64): TArgs;
|
||
begin
|
||
Result := [];
|
||
{ Ниже ста килобит в секунду — это не битрейт, а неизвестность: ffprobe
|
||
не всегда его сообщает. Тогда оставляем решение кодировщику. }
|
||
if ABitrate < 100000 then
|
||
Exit;
|
||
Result := ['-b:v', IntToStr(ABitrate div 1000) + 'k',
|
||
'-maxrate', IntToStr(ABitrate * 3 div 2000) + 'k',
|
||
'-bufsize', IntToStr(ABitrate div 500) + 'k'];
|
||
end;
|
||
|
||
function MasterOutputArgs(AWidth, AHeight, AFpsNum, AFpsDen: Integer;
|
||
const AVideoCodec, APixFmt, AAudioCodec: string;
|
||
ASampleRate, AChannels: Integer; ABitrate: Int64): TArgs;
|
||
var
|
||
Fps: string;
|
||
begin
|
||
Fps := '';
|
||
if (AFpsNum > 0) and (AFpsDen > 0) then
|
||
Fps := IntToStr(AFpsNum) + '/' + IntToStr(AFpsDen);
|
||
|
||
Result := ['-c:v', PreferredEncoder(AVideoCodec)];
|
||
|
||
if (AWidth > 0) and (AHeight > 0) then
|
||
Result := Concat(Result, ['-s', IntToStr(AWidth) + 'x' + IntToStr(AHeight)]);
|
||
if Fps <> '' then
|
||
Result := Concat(Result, ['-r', Fps]);
|
||
if APixFmt <> '' then
|
||
Result := Concat(Result, ['-pix_fmt', APixFmt]);
|
||
Result := Concat(Result, BitrateArgs(ABitrate));
|
||
|
||
if AAudioCodec <> '' then
|
||
begin
|
||
Result := Concat(Result, ['-c:a', AAudioCodec]);
|
||
if ASampleRate > 0 then
|
||
Result := Concat(Result, ['-ar', IntToStr(ASampleRate)]);
|
||
if AChannels > 0 then
|
||
Result := Concat(Result, ['-ac', IntToStr(AChannels)]);
|
||
end;
|
||
end;
|
||
|
||
function ArgsNormalize(const ASrc, ADst: string;
|
||
AWidth, AHeight, AFpsNum, AFpsDen: Integer;
|
||
const AVideoCodec, APixFmt, AAudioCodec: string;
|
||
ASampleRate, AChannels: Integer; const AFilter: string): TArgs;
|
||
begin
|
||
Result := Concat(CommonFfmpegPrefix, ['-i', ASrc, '-map', '0:v:0']);
|
||
if AAudioCodec <> '' then
|
||
Result := Concat(Result, ['-map', '0:a:0?']);
|
||
{ Размер задаётся цепочкой фильтров, а не ключом -s: тот растягивает
|
||
кадр, не считаясь с соотношением сторон. }
|
||
if AFilter <> '' then
|
||
Result := Concat(Result, ['-vf', AFilter]);
|
||
Result := Concat(Result, MasterOutputArgs(0, 0, AFpsNum, AFpsDen,
|
||
AVideoCodec, APixFmt, AAudioCodec, ASampleRate, AChannels));
|
||
Result := Concat(Result, ProgressArgs);
|
||
Result := Concat(Result, [ADst]);
|
||
end;
|
||
|
||
function ArgsImageClip(const ASrc, ADst: string; ADuration: Double;
|
||
AWidth, AHeight, AFpsNum, AFpsDen: Integer;
|
||
const AVideoCodec, APixFmt, AAudioCodec: string;
|
||
ASampleRate, AChannels: Integer; ABitrate: Int64): TArgs;
|
||
begin
|
||
{ -loop 1 повторяет единственный кадр, -t задаёт длину.
|
||
Картинка вписывается в кадр целиком с чёрными полями по краям:
|
||
обрезать её без спроса неправильно, растягивать — тем более. }
|
||
Result := Concat(CommonFfmpegPrefix, [
|
||
'-loop', '1',
|
||
'-i', ASrc,
|
||
'-t', SecondsToStr(ADuration)
|
||
]);
|
||
|
||
if AAudioCodec <> '' then
|
||
{ Клипу нужна звуковая дорожка, иначе при склейке с обычным видео
|
||
звук поедет: у одних кусков он есть, у других нет. }
|
||
Result := Concat(Result, ['-f', 'lavfi', '-i',
|
||
Format('anullsrc=channel_layout=%s:sample_rate=%d',
|
||
[IfThen(AChannels = 1, 'mono', 'stereo'), ASampleRate])]);
|
||
|
||
Result := Concat(Result, ['-vf',
|
||
Format('scale=%d:%d:force_original_aspect_ratio=decrease,pad=%d:%d:(ow-iw)/2:(oh-ih)/2,setsar=1',
|
||
[AWidth, AHeight, AWidth, AHeight])]);
|
||
|
||
Result := Concat(Result, MasterOutputArgs(0, 0, AFpsNum, AFpsDen,
|
||
AVideoCodec, APixFmt, AAudioCodec, ASampleRate, AChannels, ABitrate));
|
||
Result := Concat(Result, NoBFramesArgs);
|
||
Result := Concat(Result, ['-shortest']);
|
||
Result := Concat(Result, ProgressArgs);
|
||
Result := Concat(Result, [ADst]);
|
||
end;
|
||
|
||
function ArgsBlackClip(const ADst: string; ADuration: Double;
|
||
AWidth, AHeight, AFpsNum, AFpsDen: Integer;
|
||
const AVideoCodec, APixFmt, AAudioCodec: string;
|
||
ASampleRate, AChannels: Integer): TArgs;
|
||
var
|
||
Fps: string;
|
||
begin
|
||
Fps := '25';
|
||
if (AFpsNum > 0) and (AFpsDen > 0) then
|
||
Fps := IntToStr(AFpsNum) + '/' + IntToStr(AFpsDen);
|
||
|
||
Result := Concat(CommonFfmpegPrefix, [
|
||
'-f', 'lavfi',
|
||
'-i', Format('color=c=black:s=%dx%d:r=%s:d=%s',
|
||
[AWidth, AHeight, Fps, SecondsToStr(ADuration)])
|
||
]);
|
||
|
||
if AAudioCodec <> '' then
|
||
Result := Concat(Result, ['-f', 'lavfi', '-i',
|
||
Format('anullsrc=channel_layout=%s:sample_rate=%d',
|
||
[IfThen(AChannels = 1, 'mono', 'stereo'), ASampleRate])]);
|
||
|
||
Result := Concat(Result, MasterOutputArgs(0, 0, AFpsNum, AFpsDen,
|
||
AVideoCodec, APixFmt, AAudioCodec, ASampleRate, AChannels));
|
||
Result := Concat(Result, NoBFramesArgs);
|
||
Result := Concat(Result, ['-t', SecondsToStr(ADuration)]);
|
||
Result := Concat(Result, ProgressArgs);
|
||
Result := Concat(Result, [ADst]);
|
||
end;
|
||
|
||
function ArgsSilenceClip(const ADst: string; ADuration: Double;
|
||
const AAudioCodec: string; ASampleRate, AChannels: Integer): TArgs;
|
||
var
|
||
Codec: string;
|
||
Ch: Integer;
|
||
begin
|
||
Codec := AAudioCodec;
|
||
if Codec = '' then
|
||
Codec := 'aac';
|
||
Ch := AChannels;
|
||
if Ch < 1 then
|
||
Ch := 2;
|
||
|
||
Result := Concat(CommonFfmpegPrefix, [
|
||
'-f', 'lavfi',
|
||
'-i', Format('anullsrc=channel_layout=%s:sample_rate=%d',
|
||
[IfThen(Ch = 1, 'mono', 'stereo'), ASampleRate]),
|
||
'-c:a', Codec,
|
||
'-ac', IntToStr(Ch),
|
||
'-ar', IntToStr(ASampleRate),
|
||
'-t', SecondsToStr(ADuration)
|
||
]);
|
||
Result := Concat(Result, ProgressArgs);
|
||
Result := Concat(Result, [ADst]);
|
||
end;
|
||
|
||
function ArgsThumbnail(const ASrc, ADst: string; const ATime: Double;
|
||
AWidth, AHeight: Integer): TArgs;
|
||
begin
|
||
Result := Concat(CommonFfmpegPrefix, []);
|
||
if ATime > 0 then
|
||
{ -ss до -i, чтобы не читать файл до нужного места насквозь: на
|
||
шестигигабайтном файле это разница между мгновением и минутой. }
|
||
Result := Concat(Result, ['-ss', SecondsToStr(ATime)]);
|
||
Result := Concat(Result, [
|
||
'-i', ASrc,
|
||
'-map', '0:v:0',
|
||
'-frames:v', '1',
|
||
'-vf', Format(
|
||
'scale=%d:%d:force_original_aspect_ratio=decrease,pad=%d:%d:(ow-iw)/2:(oh-ih)/2',
|
||
[AWidth, AHeight, AWidth, AHeight]),
|
||
'-f', 'image2',
|
||
ADst
|
||
]);
|
||
end;
|
||
|
||
function ArgsPreviewFrame(const ASrc, ADst: string; const ATime: Double;
|
||
const AFilter: string): TArgs;
|
||
begin
|
||
Result := Concat(CommonFfmpegPrefix, []);
|
||
if ATime > 0 then
|
||
Result := Concat(Result, ['-ss', SecondsToStr(ATime)]);
|
||
Result := Concat(Result, ['-i', ASrc, '-map', '0:v:0', '-frames:v', '1']);
|
||
if AFilter <> '' then
|
||
Result := Concat(Result, ['-vf', AFilter]);
|
||
Result := Concat(Result, ['-f', 'image2', ADst]);
|
||
end;
|
||
|
||
function ConcatListLine(const APath: string): string;
|
||
var
|
||
Escaped: string;
|
||
begin
|
||
{ Апостроф внутри пути закрывает строку, поэтому его заменяют
|
||
последовательностью «закрыть строку, экранированный апостроф,
|
||
открыть строку». Это правило самого concat-демультиплексора. }
|
||
Escaped := StringReplace(APath, '''', '''\''''', [rfReplaceAll]);
|
||
{ Префикс file: обязателен: иначе при чтении списка не из файла ffmpeg
|
||
подставит протокол родительского входа и путь не найдётся. }
|
||
Result := 'file ''file:' + Escaped + '''';
|
||
end;
|
||
|
||
{ Строка вида 00:01:23.456789 в секунды. }
|
||
function TimecodeToSeconds(const AValue: string; out ASeconds: Double): Boolean;
|
||
var
|
||
Parts: TStringArray;
|
||
H, M: Integer;
|
||
S: Double;
|
||
begin
|
||
Result := False;
|
||
ASeconds := 0;
|
||
Parts := AValue.Split([':']);
|
||
if Length(Parts) <> 3 then
|
||
Exit;
|
||
if not TryStrToInt(Parts[0], H) then
|
||
Exit;
|
||
if not TryStrToInt(Parts[1], M) then
|
||
Exit;
|
||
if not TryStrToFloat(Parts[2], S, InvariantFormat) then
|
||
Exit;
|
||
ASeconds := H * 3600 + M * 60 + S;
|
||
Result := True;
|
||
end;
|
||
|
||
function ParseProgressLine(const ALine: string; var ASeconds: Double;
|
||
var AFinished: Boolean): Boolean;
|
||
var
|
||
P: Integer;
|
||
Key, Value: string;
|
||
Micro: Int64;
|
||
begin
|
||
Result := False;
|
||
P := Pos('=', ALine);
|
||
if P <= 1 then
|
||
Exit;
|
||
Key := Copy(ALine, 1, P - 1);
|
||
Value := Copy(ALine, P + 1, Length(ALine) - P);
|
||
|
||
if Key = 'out_time_us' then
|
||
begin
|
||
{ Значение бывает N/A, пока первый кадр не обработан. }
|
||
if TryStrToInt64(Value, Micro) and (Micro >= 0) then
|
||
begin
|
||
ASeconds := Micro / 1000000;
|
||
Result := True;
|
||
end;
|
||
end
|
||
else if Key = 'out_time' then
|
||
begin
|
||
if TimecodeToSeconds(Value, ASeconds) then
|
||
Result := True;
|
||
end
|
||
else if Key = 'progress' then
|
||
begin
|
||
AFinished := Value = 'end';
|
||
Result := True;
|
||
end;
|
||
end;
|
||
|
||
end.
|