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

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

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

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

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

1083 lines
40 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.

{
uExport — сборка готового ролика из сегментов без перекодирования.
Схема работы и почему она такая.
Напрашивающийся способ — отдать ffmpeg один список кусков с директивами
inpoint и outpoint и получить результат за один проход, не создавая
временных файлов. На пробе он оказался неточным: запрошенные 600 кадров
превратились в 574, а метки времени не пересчитались. Поэтому каждый
кусок вырезается отдельным запуском ffmpeg с -ss и -to до -i, что даёт
ровно запрошенную длину, и только потом всё склеивается.
Плата — временные файлы размером с использованный материал. Это принятое
решение: диск дешевле, чем перекодирование, и дешевле, чем неточный рез.
Второй важный момент — через какой формат идут промежуточные куски.
Когда все куски происходят из одного файла, у них один и тот же набор
параметров кодека, и склеить их можно напрямую. Но стоит появиться
куску от другого кодировщика — из второго исходника или из созданной
нами вставки под картинку, — как наборы параметров расходятся. MP4 и
Matroska хранят такой набор один на дорожку, поэтому декодер применяет
параметры первого куска ко всем и на стыках сыплет ошибками.
Замерено на проекте из шести кусков: прямая склейка в Matroska дала
384 ошибки декодера HEVC при верном числе кадров, склейка через
промежуточный MPEG-TS — ни одной. MPEG-TS повторяет параметры прямо
в потоке перед каждым опорным кадром, поэтому их смена посреди ролика
для него нормальна.
Отсюда правило: один источник — прямая склейка (быстрее), несколько —
через MPEG-TS. Если кодек в MPEG-TS не помещается, собираем напрямую
и честно предупреждаем.
Начало каждого куска притягивается к опорному кадру. Без этого начало
вырезки нечем декодировать: кадры в середине группы хранят разницу с
соседями, а не самостоятельное изображение. Конец притягивать не нужно —
оборванная группа воспроизводится нормально.
Длина куска при этом сохраняется: сдвигается только то, какой участок
исходника показывается. Иначе притягивание одного реза сдвинуло бы всё,
что стоит на монтаже правее.
}
unit uExport;
{$mode objfpc}{$H+}
interface
uses
Classes, SysUtils, uAppTypes, uKeyframeIndex, uMediaProbe;
type
{ Настройки сборки с перекодированием.
Enabled = False означает обычную сборку копированием — то, ради чего
редактор и делался. Всё остальное имеет смысл только при True. }
TEncodeProfile = record
Enabled: Boolean;
Width: Integer;
Height: Integer;
BitrateKbps: Integer;
{ Кодек в привычных именах: h264 или hevc. Кодировщик под него
подбирается сам, с предпочтением аппаратного. }
Codec: string;
end;
{ Что получилось с одной частью.
Кусок монтажа обычно даёт одну часть, но при точном резе — две:
перекодированный огрызок до ближайшего опорного кадра и скопированный
остаток. Поэтому частей может быть больше, чем кусков, и у каждой
указано, из какого куска она произошла. }
TSegmentReport = record
Index: Integer;
SourcePath: string;
RequestedStart: Double;
ActualStart: Double;
Duration: Double;
TempPath: string;
Packets: Int64;
Encoded: Boolean;
end;
TSegmentReports = array of TSegmentReport;
TExportReport = record
Succeeded: Boolean;
Cancelled: Boolean;
Message: string;
OutputPath: string;
CacheDir: string;
Segments: TSegmentReports;
{ Сумма пакетов вырезанных кусков и число пакетов в готовом файле.
Расхождение означает, что склейка что-то потеряла или продублировала. }
ExpectedPackets: Int64;
ActualPackets: Int64;
TotalDuration: Double;
end;
TExportThread = class(TThread)
private
FSegments: TFlatSegmentArray;
{ Куски звука. Отдельный список: звук можно двигать независимо от
картинки, и точки реза у них разные. }
FAudio: TFlatSegmentArray;
{ Собранная звуковая дорожка и картинка без звука. }
FAudioPath: string;
FVideoPath: string;
FOutputPath: string;
FCacheDir: string;
FKeepIntermediate: Boolean;
FCancelRequested: Boolean;
FStageText: string;
FFraction: Double;
FPendingLog: string;
FReport: TExportReport;
FOnProgress: TNotifyEvent;
FOnLog: TNotifyEvent;
{ Учёт общего прогресса: сколько работы уже позади и сколько весит
текущий шаг. Единица работы — секунда обрабатываемого материала. }
FTotalWork: Double;
FDoneWork: Double;
FStepWork: Double;
FStepPosition: Double;
{ Приёмник числа, которое печатает ffprobe при подсчёте пакетов. }
FPacketCount: Int64;
FMaster: TMediaInfo;
FProfile: TEncodeProfile;
FSubtitlePath: string;
{ Формат промежуточных кусков: 'matroska' или 'mpegts'. }
FIntermediateFormat: string;
FIntermediateExt: string;
function DistinctSourceCount: Integer;
procedure ChooseIntermediateFormat;
procedure SyncProgress;
procedure SyncLog;
procedure Log(const AText: string);
procedure SetStage(const AText: string);
procedure PublishProgress;
function CancelQuery: Boolean;
procedure ProgressLine(const ALine: string);
procedure ErrorLine(const ALine: string);
procedure PacketCountLine(const ALine: string);
function RunFfmpeg(const AArgs: array of string; const AWhat: string): Boolean;
function CountPackets(const APath: string): Int64;
function ExtractSegments: Boolean;
function WriteConcatList(out AListPath: string): Boolean;
function ConcatAll(const AListPath: string): Boolean;
procedure Verify;
{ Сборка звука: куски сводятся в сырой поток и сжимаются одним
проходом. Сырой поток нужен для точности — стык получается с
точностью до отсчёта, а не до звукового кадра. }
function BuildAudio: Boolean;
function MixLayers(const ASeg: TFlatSegment; ADest: TStream;
ANeed: Int64; ARate, AChannels: Integer): Boolean;
function MuxFinal: Boolean;
protected
procedure Execute; override;
public
{ AMaster нужен, чтобы понять, влезут ли кодеки проекта в MPEG-TS.
Можно передать пустую запись — тогда способ склейки выберется
по одному лишь числу источников. }
constructor Create(const ASegments, AAudio: TFlatSegmentArray;
const AOutputPath: string; AKeepIntermediate: Boolean;
const AMaster: TMediaInfo; const AProfile: TEncodeProfile);
procedure RequestCancel;
{ Читать из потока интерфейса только внутри обработчиков. }
property StageText: string read FStageText;
property Fraction: Double read FFraction;
property PendingLog: string read FPendingLog;
property Report: TExportReport read FReport;
property OnProgress: TNotifyEvent read FOnProgress write FOnProgress;
property OnLog: TNotifyEvent read FOnLog write FOnLog;
{ Разметка субтитров, которую надо вложить в готовый файл отдельной
дорожкой. Пусто — либо текста нет, либо он вжигается в картинку.
Годится только для Matroska: MP4 хранит субтитры без оформления. }
property SubtitlePath: string read FSubtitlePath write FSubtitlePath;
end;
{ Каталог для временных кусков рядом с готовым файлом. }
function CacheDirFor(const AOutputPath: string): string;
implementation
uses
Math, FileUtil, uFfmpegCmd, uProcRunner;
function CacheDirFor(const AOutputPath: string): string;
begin
Result := IncludeTrailingPathDelimiter(ExtractFilePath(AOutputPath)) +
ChangeFileExt(ExtractFileName(AOutputPath), '') + '.nlve-cache' +
PathDelim;
end;
constructor TExportThread.Create(const ASegments, AAudio: TFlatSegmentArray;
const AOutputPath: string; AKeepIntermediate: Boolean;
const AMaster: TMediaInfo; const AProfile: TEncodeProfile);
var
I: Integer;
begin
FMaster := AMaster;
FProfile := AProfile;
inherited Create(True);
FreeOnTerminate := False;
{ Все пути приводятся к абсолютным немедленно.
Иначе получается ловушка: путь в списке склейки с префиксом file:
разбирается относительно текущего рабочего каталога, а не относительно
самого списка. Совпало — работает, не совпало — ffmpeg не находит
куски. Проще не давать относительным путям попасть внутрь вообще. }
SetLength(FSegments, Length(ASegments));
for I := 0 to High(ASegments) do
begin
FSegments[I] := ASegments[I];
if FSegments[I].FilePath <> '' then
FSegments[I].FilePath := ExpandFileName(FSegments[I].FilePath);
end;
SetLength(FAudio, Length(AAudio));
for I := 0 to High(AAudio) do
begin
FAudio[I] := AAudio[I];
if FAudio[I].FilePath <> '' then
FAudio[I].FilePath := ExpandFileName(FAudio[I].FilePath);
end;
FOutputPath := ExpandFileName(AOutputPath);
FCacheDir := CacheDirFor(FOutputPath);
FKeepIntermediate := AKeepIntermediate;
FReport.Succeeded := False;
FReport.Cancelled := False;
FReport.OutputPath := FOutputPath;
FReport.CacheDir := FCacheDir;
FReport.TotalDuration := TotalDuration(ASegments);
end;
procedure TExportThread.RequestCancel;
begin
FCancelRequested := True;
end;
function TExportThread.CancelQuery: Boolean;
begin
Result := FCancelRequested or Terminated;
end;
{ --- Передача состояния в поток интерфейса --- }
procedure TExportThread.SyncProgress;
begin
if Assigned(FOnProgress) then
FOnProgress(Self);
end;
procedure TExportThread.SyncLog;
begin
if Assigned(FOnLog) then
FOnLog(Self);
end;
procedure TExportThread.Log(const AText: string);
begin
FPendingLog := AText;
Synchronize(@SyncLog);
end;
procedure TExportThread.PublishProgress;
begin
if FTotalWork > 0 then
FFraction := Min(1, (FDoneWork + FStepPosition) / FTotalWork)
else
FFraction := 0;
Synchronize(@SyncProgress);
end;
procedure TExportThread.SetStage(const AText: string);
begin
FStageText := AText;
PublishProgress;
end;
{ --- Разбор вывода ffmpeg --- }
procedure TExportThread.ProgressLine(const ALine: string);
var
Seconds: Double;
{ Именно AtEnd, а не Finished: у TThread уже есть свойство с таким
именем, и локальная переменная его бы перекрыла. }
AtEnd: Boolean;
begin
Seconds := FStepPosition;
AtEnd := False;
if not ParseProgressLine(ALine, Seconds, AtEnd) then
Exit;
{ Позиция не может превысить вес шага: у ffmpeg она иногда чуть
забегает вперёд из-за округления меток времени. }
FStepPosition := Min(Seconds, FStepWork);
PublishProgress;
end;
procedure TExportThread.ErrorLine(const ALine: string);
begin
{ Журнал ffmpeg при -loglevel warning короткий: сюда попадают только
настоящие предупреждения и ошибки, их стоит показать. }
Log('ffmpeg: ' + ALine);
end;
procedure TExportThread.PacketCountLine(const ALine: string);
var
Value: Int64;
begin
{ ffprobe печатает ровно одно число — количество пакетов видеопотока. }
if TryStrToInt64(Trim(ALine), Value) then
FPacketCount := Value;
end;
{ --- Шаги --- }
function TExportThread.RunFfmpeg(const AArgs: array of string;
const AWhat: string): Boolean;
var
Res: TProcResult;
begin
Log('> ' + CommandToString(FfmpegExe, AArgs));
FStepPosition := 0;
Res := RunProcess(FfmpegExe, AArgs, @ProgressLine, @ErrorLine, @CancelQuery);
if not Res.Started then
begin
FReport.Message := Res.ErrorText;
Exit(False);
end;
if Res.Cancelled then
begin
FReport.Cancelled := True;
FReport.Message := 'Отменено.';
Exit(False);
end;
if Res.ExitCode <> 0 then
begin
FReport.Message := AWhat + ': ffmpeg завершился с кодом ' +
IntToStr(Res.ExitCode) + '. Подробности в журнале.';
Exit(False);
end;
FDoneWork := FDoneWork + FStepWork;
FStepPosition := 0;
PublishProgress;
Result := True;
end;
function TExportThread.CountPackets(const APath: string): Int64;
var
Res: TProcResult;
begin
FPacketCount := -1;
Res := RunProcess(FfprobeExe, ArgsCountVideoPackets(APath),
@PacketCountLine, nil, @CancelQuery);
if (not Res.Started) or (Res.ExitCode <> 0) then
Exit(-1);
Result := FPacketCount;
end;
function TExportThread.DistinctSourceCount: Integer;
var
I: Integer;
Seen: TStringList;
begin
Seen := TStringList.Create;
try
Seen.Sorted := True;
Seen.Duplicates := dupIgnore;
for I := 0 to High(FSegments) do
if FSegments[I].FilePath <> '' then
Seen.Add(FSegments[I].FilePath);
Result := Seen.Count;
finally
Seen.Free;
end;
end;
procedure TExportThread.ChooseIntermediateFormat;
var
Sources, I: Integer;
HasEncoded: Boolean;
begin
Sources := DistinctSourceCount;
{ Точный рез сам по себе создаёт кусок от другого кодировщика, даже если
исходник всего один. }
{ Любой кусок, который придётся закодировать заново, — это ещё один
набор параметров кодека рядом со скопированными. }
HasEncoded := False;
for I := 0 to High(FSegments) do
if FSegments[I].PreciseIn or FSegments[I].Transcode then
HasEncoded := True;
if (Sources <= 1) and (not HasEncoded) then
begin
{ Всё из одного файла — параметры кодека заведомо одни и те же,
можно склеивать напрямую и не тратить лишний проход по материалу. }
FIntermediateFormat := 'matroska';
FIntermediateExt := '.mkv';
Exit;
end;
if not TsSupportsCodecs(FMaster.VideoCodec, FMaster.AudioCodec) then
begin
FIntermediateFormat := 'matroska';
FIntermediateExt := '.mkv';
Log('ВНИМАНИЕ: кусков от разных кодировщиков несколько, но кодек ' +
FMaster.VideoCodec + ' не переносится в MPEG-TS. Собираю напрямую — ' +
'на стыках возможны ошибки декодирования.');
Exit;
end;
FIntermediateFormat := 'mpegts';
FIntermediateExt := '.ts';
if HasEncoded then
Log('Есть куски, которые кодируются заново, склейка идёт через ' +
'MPEG-TS: иначе декодер применил бы параметры первого куска ко ' +
'всем остальным.')
else
Log('Источников несколько (' + IntToStr(Sources) +
'), склейка идёт через MPEG-TS: иначе декодер применил бы параметры ' +
'первого куска ко всем остальным.');
end;
{ Первый опорный кадр строго правее указанного времени. Ноль означает,
что правее ничего нет. }
function NextKeyframeAfter(const AIndex: TKeyframeIndex;
const ATime: Double): Double;
var
I: Integer;
begin
Result := 0;
for I := 0 to High(AIndex.Times) do
if AIndex.Times[I] > ATime + 0.0005 then
Exit(AIndex.Times[I]);
end;
function TExportThread.ExtractSegments: Boolean;
var
I, PartCount: Integer;
Index: TKeyframeIndex;
IndexError: string;
HasIndex: Boolean;
Snapped, NextKey, EncodeUntil: Double;
SegEnd: Double;
Dst: string;
Rep: TSegmentReport;
procedure AddPart(const APart: TSegmentReport);
begin
if PartCount >= Length(FReport.Segments) then
SetLength(FReport.Segments, PartCount + 8);
FReport.Segments[PartCount] := APart;
Inc(PartCount);
end;
begin
Result := False;
PartCount := 0;
SetLength(FReport.Segments, Length(FSegments) + 8);
for I := 0 to High(FSegments) do
begin
if CancelQuery then
begin
FReport.Cancelled := True;
FReport.Message := 'Отменено.';
Exit;
end;
SetStage(Format('Вырезаю кусок %d из %d', [I + 1, Length(FSegments)]));
{ Сгенерированные вставки берутся из файла, который заведомо длиннее
нужного: так одна и та же чёрная вставка годится для дыр любой
длины. Значит вырезать надо и из них, но притягивать к опорному
кадру нечего — они начинаются с нуля, а ноль всегда опорный. }
if FSegments[I].Kind <> skVideo then
begin
Dst := FCacheDir + Format('part_%.3d_a%s', [I, FIntermediateExt]);
Rep.Index := I;
Rep.SourcePath := FSegments[I].FilePath;
Rep.RequestedStart := 0;
Rep.ActualStart := 0;
Rep.Duration := FSegments[I].Duration;
Rep.Packets := -1;
Rep.Encoded := False;
FStepWork := FSegments[I].Duration;
if not RunFfmpeg(ArgsExtract(FSegments[I].FilePath, Dst,
0, FSegments[I].Duration, FIntermediateFormat,
FSegments[I].Volume, FMaster.AudioCodec),
'Подготовка вставки ' + IntToStr(I + 1)) then
Exit;
Rep.TempPath := Dst;
AddPart(Rep);
Continue;
end;
{ Участок с наложением: полупрозрачный клип поверх нижнего либо
переход между двумя клипами одной дорожки. Собирается из двух
входов сразу, копированием такое не получить. }
if Length(FSegments[I].Layers) > 0 then
begin
SetStage(Format('Собираю наложение, кусок %d из %d',
[I + 1, Length(FSegments)]));
Dst := FCacheDir + Format('part_%.3d_x%s', [I, FIntermediateExt]);
Rep.Index := I;
Rep.SourcePath := FSegments[I].Layers[0].FilePath;
Rep.RequestedStart := FSegments[I].Layers[0].SrcStart;
Rep.ActualStart := Rep.RequestedStart;
Rep.Duration := FSegments[I].Duration;
Rep.Packets := -1;
Rep.Encoded := True;
FStepWork := FSegments[I].Duration;
if not RunFfmpeg(ArgsRenderLayers(FSegments[I].Layers, Dst,
FSegments[I].Duration, FMaster.Width, FMaster.Height,
FMaster.FpsNum, FMaster.FpsDen, FMaster.PixFmt,
PreferredEncoder(FMaster.VideoCodec),
FIntermediateFormat = 'mpegts', FMaster.VideoBitrate,
FSegments[I].SubFile),
'Наложение в куске ' + IntToStr(I + 1)) then
Exit;
Rep.TempPath := Dst;
AddPart(Rep);
Continue;
end;
{ Кусок от файла, не совпадающего с параметрами проекта. Скопировать
его нельзя — пересобираем. Но только его: соседние куски, снятые тем
же оборудованием, что и весь проект, по-прежнему копируются. }
if FSegments[I].Transcode then
begin
SetStage(Format('Пересобираю кусок %d из %d (файл не совпадает с проектом)',
[I + 1, Length(FSegments)]));
Dst := FCacheDir + Format('part_%.3d_a%s', [I, FIntermediateExt]);
Rep.Index := I;
Rep.SourcePath := FSegments[I].FilePath;
Rep.RequestedStart := FSegments[I].SrcStart;
Rep.ActualStart := FSegments[I].SrcStart;
Rep.Duration := FSegments[I].Duration;
Rep.Packets := -1;
Rep.Encoded := True;
FStepWork := FSegments[I].Duration;
if not RunFfmpeg(ArgsTranscodeSegment(FSegments[I].FilePath, Dst,
FSegments[I].SrcStart, FSegments[I].SrcStart + FSegments[I].Duration,
FMaster.Width, FMaster.Height, FMaster.FpsNum, FMaster.FpsDen,
FMaster.VideoCodec, FMaster.PixFmt, FMaster.AudioCodec,
FMaster.SampleRate, FMaster.Channels, FSegments[I].Volume,
FIntermediateFormat, FSegments[I].Filter, FSegments[I].SubFile,
FMaster.VideoBitrate),
'Пересборка куска ' + IntToStr(I + 1)) then
Exit;
Rep.TempPath := Dst;
AddPart(Rep);
Continue;
end;
HasIndex := BuildKeyframeIndex(FSegments[I].FilePath, Index, IndexError);
if not HasIndex then
Log('Кусок ' + IntToStr(I + 1) +
': индекс опорных кадров недоступен (' + IndexError +
'), режу как есть — начало может оказаться повреждённым.');
SegEnd := FSegments[I].SrcStart + FSegments[I].Duration;
Rep.Index := I;
Rep.SourcePath := FSegments[I].FilePath;
Rep.RequestedStart := FSegments[I].SrcStart;
Rep.Packets := -1;
if FSegments[I].PreciseIn and HasIndex then
begin
{ Точный рез. Копировать можно только с опорного кадра, поэтому
отрезок от запрошенной точки до следующего опорного кадра
пересобираем заново, а всё, что дальше, копируем как обычно.
Перекодируются доли секунды, а не весь материал. }
NextKey := NextKeyframeAfter(Index, FSegments[I].SrcStart);
if (NextKey <= 0) or (NextKey >= SegEnd) then
EncodeUntil := SegEnd
else
EncodeUntil := NextKey;
Log(Format('Кусок %d: точный рез, перекодирую %s..%s (%s с), остальное копирую',
[I + 1, SecondsToStr(FSegments[I].SrcStart), SecondsToStr(EncodeUntil),
SecondsToStr(EncodeUntil - FSegments[I].SrcStart)]));
Dst := FCacheDir + Format('part_%.3d_a%s', [I, FIntermediateExt]);
FStepWork := EncodeUntil - FSegments[I].SrcStart;
if not RunFfmpeg(ArgsEncodeFragment(FSegments[I].FilePath, Dst,
FSegments[I].SrcStart, EncodeUntil,
FMaster.FpsNum, FMaster.FpsDen,
FMaster.VideoCodec, FMaster.PixFmt, FMaster.AudioCodec,
FMaster.SampleRate, FMaster.Channels, FIntermediateFormat),
'Перекодирование стыка куска ' + IntToStr(I + 1)) then
Exit;
Rep.ActualStart := FSegments[I].SrcStart;
Rep.Duration := EncodeUntil - FSegments[I].SrcStart;
Rep.TempPath := Dst;
Rep.Encoded := True;
AddPart(Rep);
if EncodeUntil < SegEnd - 0.0005 then
begin
Dst := FCacheDir + Format('part_%.3d_b%s', [I, FIntermediateExt]);
FStepWork := SegEnd - EncodeUntil;
if not RunFfmpeg(ArgsExtract(FSegments[I].FilePath, Dst,
EncodeUntil, SegEnd, FIntermediateFormat,
FSegments[I].Volume, FMaster.AudioCodec),
'Вырезание куска ' + IntToStr(I + 1)) then
Exit;
Rep.ActualStart := EncodeUntil;
Rep.Duration := SegEnd - EncodeUntil;
Rep.TempPath := Dst;
Rep.Encoded := False;
AddPart(Rep);
end;
Continue;
end;
{ Обычный рез: начало притягивается к ближайшему опорному кадру,
длина сохраняется. }
Snapped := FSegments[I].SrcStart;
if HasIndex then
begin
Snapped := SnapNearest(Index, FSegments[I].SrcStart);
if Abs(Snapped - FSegments[I].SrcStart) > 0.0005 then
Log(Format('Кусок %d: начало притянуто к опорному кадру, %s вместо %s (сдвиг %s с)',
[I + 1, SecondsToStr(Snapped), SecondsToStr(FSegments[I].SrcStart),
SecondsToStr(Snapped - FSegments[I].SrcStart)]));
end;
Dst := FCacheDir + Format('part_%.3d_a%s', [I, FIntermediateExt]);
FStepWork := FSegments[I].Duration;
if not RunFfmpeg(ArgsExtract(FSegments[I].FilePath, Dst,
Snapped, Snapped + FSegments[I].Duration, FIntermediateFormat,
FSegments[I].Volume, FMaster.AudioCodec),
'Вырезание куска ' + IntToStr(I + 1)) then
Exit;
Rep.ActualStart := Snapped;
Rep.Duration := FSegments[I].Duration;
Rep.TempPath := Dst;
Rep.Encoded := False;
AddPart(Rep);
end;
SetLength(FReport.Segments, PartCount);
Result := True;
end;
function TExportThread.WriteConcatList(out AListPath: string): Boolean;
var
List: TStringList;
I: Integer;
begin
AListPath := FCacheDir + 'concat.txt';
List := TStringList.Create;
try
for I := 0 to High(FReport.Segments) do
List.Add(ConcatListLine(FReport.Segments[I].TempPath));
try
{ Концы строк только LF: список читает разборщик ffmpeg, а не
текстовый редактор, и лишний возврат каретки попадёт в имя файла. }
List.LineBreak := #10;
List.SaveToFile(AListPath);
Result := True;
except
on E: Exception do
begin
FReport.Message := 'Не удалось записать список склейки: ' + E.Message;
Result := False;
end;
end;
finally
List.Free;
end;
end;
function TExportThread.ConcatAll(const AListPath: string): Boolean;
begin
FStepWork := FReport.TotalDuration;
if FProfile.Enabled then
begin
{ Перекодирование всего ролика одним проходом. Так весь результат
выходит из одного кодировщика с одними настройками: пересобирать
куски по отдельности здесь нельзя, на стыках была бы видна смена
качества. }
SetStage('Склеиваю и перекодирую');
if RunFfmpeg(ArgsConcatEncode(AListPath, FVideoPath,
FProfile.Width, FProfile.Height, FMaster.FpsNum, FMaster.FpsDen,
FProfile.BitrateKbps, FProfile.Codec, FMaster.PixFmt, True),
'Склейка с перекодированием') then
Exit(True);
if Terminated then
Exit(False);
{ Аппаратная распаковка не пошла — повторяем без неё.
Она даёт впятеро, но зависит от драйвера и от того, знаком ли
видеокарте кодек исходника. Отказываться из-за неё от сборки
нельзя: медленно лучше, чем никак. }
Log('Аппаратная распаковка не пошла, повторяю на процессоре.');
Exit(RunFfmpeg(ArgsConcatEncode(AListPath, FVideoPath,
FProfile.Width, FProfile.Height, FMaster.FpsNum, FMaster.FpsDen,
FProfile.BitrateKbps, FProfile.Codec, FMaster.PixFmt, False),
'Склейка с перекодированием без аппаратной распаковки'));
end;
SetStage('Склеиваю');
Result := RunFfmpeg(ArgsConcat(AListPath, FVideoPath), 'Склейка');
end;
procedure TExportThread.Verify;
var
I: Integer;
Sum, Actual, N: Int64;
begin
SetStage('Проверяю результат');
Sum := 0;
for I := 0 to High(FReport.Segments) do
begin
N := CountPackets(FReport.Segments[I].TempPath);
FReport.Segments[I].Packets := N;
if N > 0 then
Inc(Sum, N);
end;
Actual := CountPackets(FOutputPath);
FReport.ExpectedPackets := Sum;
FReport.ActualPackets := Actual;
end;
{ --- Звук --- }
function TExportThread.BuildAudio: Boolean;
var
RawPath, PartPath: string;
All, Part: TFileStream;
I: Integer;
Need: Int64;
Zero: array[0..65535] of Byte;
Res: TProcResult;
Chunk: Int64;
Rate, Channels: Integer;
begin
Result := False;
FAudioPath := '';
if Length(FAudio) = 0 then
Exit;
Rate := FMaster.SampleRate;
if Rate <= 0 then
Rate := 48000;
Channels := FMaster.Channels;
if Channels <= 0 then
Channels := 2;
RawPath := FCacheDir + 'audio.raw';
PartPath := FCacheDir + 'audio_part.raw';
FillChar(Zero, SizeOf(Zero), 0);
SetStage('Собираю звук');
try
All := TFileStream.Create(RawPath, fmCreate);
try
for I := 0 to High(FAudio) do
begin
if Terminated then
Exit;
{ Сколько байт должен занять кусок. Считается из длительности, а
не берётся у ffmpeg: тогда длина всей дорожки совпадает с
монтажом до отсчёта, и звук не уезжает от картинки. }
Need := Round(FAudio[I].Duration * Rate) * Channels * 2;
if Length(FAudio[I].Layers) > 0 then
begin
{ Переход или наложение: слои сводятся прямо в отсчётах. Здесь
это проще и точнее, чем через фильтры ffmpeg — доля каждого
слоя считается на каждый отсчёт, без округлений по кадрам. }
if MixLayers(FAudio[I], All, Need, Rate, Channels) then
Need := 0;
end
else if FAudio[I].FilePath <> '' then
begin
Res := RunProcess(FfmpegExe,
ArgsExtractAudioRaw(FAudio[I].FilePath, PartPath,
FAudio[I].SrcStart, FAudio[I].Duration, Rate, Channels,
FAudio[I].Volume),
nil, @ErrorLine, @CancelQuery);
if Res.Cancelled then
Exit;
if Res.Started and (Res.ExitCode = 0) and FileExists(PartPath) then
begin
Part := TFileStream.Create(PartPath, fmOpenRead or fmShareDenyNone);
try
Chunk := Part.Size;
if Chunk > Need then
Chunk := Need;
if Chunk > 0 then
All.CopyFrom(Part, Chunk);
Dec(Need, Chunk);
finally
Part.Free;
end;
SysUtils.DeleteFile(PartPath);
end;
end;
{ Чего не хватило — дополняем тишиной. Так же заполняются дыры и
куски файлов без звука. }
while Need > 0 do
begin
Chunk := SizeOf(Zero);
if Chunk > Need then
Chunk := Need;
All.WriteBuffer(Zero, Chunk);
Dec(Need, Chunk);
end;
{ Учёт работы: секунда звука весит столько же, сколько секунда
картинки на вырезании. }
FDoneWork := FDoneWork + FAudio[I].Duration;
PublishProgress;
end;
finally
All.Free;
end;
SetStage('Сжимаю звук');
FAudioPath := FCacheDir + 'audio.m4a';
Res := RunProcess(FfmpegExe,
ArgsEncodeAudio(RawPath, FAudioPath, Rate, Channels, FMaster.AudioCodec),
@ProgressLine, @ErrorLine, @CancelQuery);
if Res.Cancelled then
Exit;
if (not Res.Started) or (Res.ExitCode <> 0) or (not FileExists(FAudioPath)) then
begin
FReport.Message := 'Не удалось сжать звук.';
Exit;
end;
Result := True;
finally
SysUtils.DeleteFile(RawPath);
SysUtils.DeleteFile(PartPath);
end;
end;
{ Сведение слоёв звука в один поток.
Доля каждого слоя меняется от начала куска к концу: так получается
переход. Смешивание идёт по отсчётам, поэтому длина результата ровно
такая, какая нужна. }
function TExportThread.MixLayers(const ASeg: TFlatSegment; ADest: TStream;
ANeed: Int64; ARate, AChannels: Integer): Boolean;
var
L, I, N: Integer;
Parts: array of TMemoryStream;
PartPath: string;
Res: TProcResult;
Src: TFileStream;
Samples, S, Ch: Integer;
Acc: Double;
K: Double;
V: SmallInt;
Out16: SmallInt;
Buf: array of SmallInt;
begin
Result := False;
SetLength(Parts, Length(ASeg.Layers));
for I := 0 to High(Parts) do
Parts[I] := nil;
try
for L := 0 to High(ASeg.Layers) do
begin
PartPath := FCacheDir + Format('mix_%d.raw', [L]);
Res := RunProcess(FfmpegExe,
ArgsExtractAudioRaw(ASeg.Layers[L].FilePath, PartPath,
ASeg.Layers[L].SrcStart, ASeg.Duration, ARate, AChannels, 1),
nil, @ErrorLine, @CancelQuery);
Parts[L] := TMemoryStream.Create;
if Res.Started and (Res.ExitCode = 0) and FileExists(PartPath) then
begin
Src := TFileStream.Create(PartPath, fmOpenRead or fmShareDenyNone);
try
Parts[L].CopyFrom(Src, Src.Size);
finally
Src.Free;
end;
SysUtils.DeleteFile(PartPath);
end;
Parts[L].Position := 0;
end;
Samples := ANeed div (AChannels * 2);
if Samples <= 0 then
Exit;
SetLength(Buf, AChannels);
for S := 0 to Samples - 1 do
begin
for Ch := 0 to AChannels - 1 do
begin
Acc := 0;
for L := 0 to High(ASeg.Layers) do
begin
{ Доля слоя меняется линейно по ходу куска. }
if Samples > 1 then
K := ASeg.Layers[L].FadeFrom +
(ASeg.Layers[L].FadeTo - ASeg.Layers[L].FadeFrom) * S / (Samples - 1)
else
K := ASeg.Layers[L].FadeFrom;
N := (S * AChannels + Ch) * 2;
if N + 1 < Parts[L].Size then
begin
Move(PByte(Parts[L].Memory)[N], V, 2);
Acc := Acc + V * K;
end;
end;
if Acc > 32767 then
Acc := 32767;
if Acc < -32768 then
Acc := -32768;
Out16 := Round(Acc);
Buf[Ch] := Out16;
end;
ADest.WriteBuffer(Buf[0], AChannels * 2);
end;
Result := True;
finally
for I := 0 to High(Parts) do
Parts[I].Free;
end;
end;
function TExportThread.MuxFinal: Boolean;
var
Res: TProcResult;
begin
SetStage('Сшиваю');
Res := RunProcess(FfmpegExe,
ArgsMux(FVideoPath, FAudioPath, FOutputPath, FSubtitlePath),
@ProgressLine, @ErrorLine, @CancelQuery);
Result := Res.Started and (Res.ExitCode = 0) and FileExists(FOutputPath);
if Res.Cancelled then
Result := False
else if not Result then
FReport.Message := 'Не удалось сшить картинку со звуком.';
end;
procedure TExportThread.Execute;
var
ListPath: string;
I: Integer;
ToolError: string;
begin
try
if Length(FSegments) = 0 then
begin
FReport.Message := 'Нечего экспортировать: список сегментов пуст.';
Exit;
end;
if not ToolsAvailable(ToolError) then
begin
FReport.Message := ToolError;
Exit;
end;
SetStage('Готовлю');
if not ForceDirectories(FCacheDir) then
begin
FReport.Message := 'Не удалось создать каталог для временных кусков: ' +
FCacheDir;
Exit;
end;
Log('Временные куски: ' + FCacheDir);
ChooseIntermediateFormat;
{ Единица учёта работы — секунда материала. Каждая секунда проходит
через вырезание, склейку и сборку звука, отсюда тройной вес. }
FTotalWork := FReport.TotalDuration * 3;
FDoneWork := 0;
if not ExtractSegments then
Exit;
if not WriteConcatList(ListPath) then
Exit;
{ Картинка собирается в отдельный файл, а не сразу в конечный: звук у
неё свой, из исходников, и он почти наверняка не тот, что нужен.
Настоящий звук собирается отдельно и подставляется при сшивании. }
FVideoPath := FCacheDir + 'video' + ExtractFileExt(FOutputPath);
if not ConcatAll(ListPath) then
Exit;
if not BuildAudio then
begin
if FReport.Cancelled then
Exit;
{ Без звука ролик всё равно лучше, чем ничего: картинка уже собрана. }
Log('Звук собрать не удалось, оставляю картинку как есть.');
if not RenameFile(FVideoPath, FOutputPath) then
FileUtil.CopyFile(FVideoPath, FOutputPath);
end
else if not MuxFinal then
Exit;
Verify;
if not FileExists(FOutputPath) then
begin
FReport.Message := 'ffmpeg отработал, но выходного файла нет: ' + FOutputPath;
Exit;
end;
FReport.Succeeded := True;
FReport.Message := 'Готово.';
if not FKeepIntermediate then
begin
for I := 0 to High(FReport.Segments) do
{ Удаляем только то, что сами создали в своём каталоге. Части,
указывающие на общий кэш сгенерированных вставок, трогать нельзя:
они нужны и предпросмотру, и следующей сборке. }
if Copy(FReport.Segments[I].TempPath, 1, Length(FCacheDir)) = FCacheDir then
DeleteFile(FReport.Segments[I].TempPath);
DeleteFile(ListPath);
RemoveDir(FCacheDir);
Log('Временные куски удалены.');
end;
except
on E: Exception do
begin
FReport.Succeeded := False;
FReport.Message := 'Внутренняя ошибка: ' + E.Message;
end;
end;
FFraction := 1;
Synchronize(@SyncProgress);
end;
end.