nlve/src/uFfmpegCmd.pas
4aiman bdb16f3cea Участок сборки, качество пересборки и пропавшие куски
Отмеченный участок. Клавиши I и O ставят начало и конец по курсору,
полоса под линейкой показывает отмеченное, сборка собирает только его.
Без этого проверить правку на длинном монтаже нельзя: каждая проверка
стоит столько же, сколько идёт весь ролик. Отметки живут в проекте.
Разметка текста при этом берётся с начала участка, иначе надписи легли бы
со сдвигом.

Фотография пропадала из ролика. Кусок с наложением подаёт слои в ffmpeg
как есть, а одиночная картинка даёт ровно один кадр: кусок в три секунды
получался длиной 0.016667 с. Теперь у слоя есть признак «это картинка», и
она повторяется ключом -loop с частотой кадров проекта.

Текст пропадал там же. Вжигание помечает кусок разметкой, но ветка
наложения про неё не знала. Пока оверлей лежит на всём ролике, этой
веткой идут все куски — и текста не было нигде. Теперь разметка вжигается
последним звеном цепочки, поверх сведённой картинки.

Качество пересобранных кусков. Битрейт не задавался вовсе, и кодировщик
брал своё значение по умолчанию: 5.9 Мбит/с там, где исходник 56. Теперь
пересборка целится в наибольший битрейт среди файлов проекта, с потолком
в полтора раза выше и буфером вдвое. Тем же битрейтом делаются вставки из
фотографий.

Проверки: обрезка списка кусков по участку (nlvetool flattest, 9),
сборка фотографии под наложением и вжигание текста поверх наложения
(nlvetool render ... burn).
2026-08-31 10:37:25 +03:00

1223 lines
53 KiB
ObjectPascal
Raw Blame History

This file contains ambiguous Unicode characters

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

{
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.