Продолжение прерванной сборки потоком — с недописанного файла
Потоком кэша кусков нет, и продолжать было не с чего. Но недописанный файл сам по себе — уже собранная часть фильма: в нём видно, сколько целых кадров успело записаться. Программа отступает до границы последнего целого куска, переносит эту часть и досылает остаток. Дописать в конец существующего MKV нельзя, поэтому продолжение пишется в новый файл. MP4 продолжать нельзя вовсе: оглавление в конце, и оборванный файл не открывается — об этом говорится прямо. Готовый файл теперь цел до последнего: сборка идёт в файл рядом и занимает место готового, только когда дописана. Сорвавшаяся попытка не отбирает уже собранное — из двух недописанных остаётся тот, где кадров больше, и считаются они даже после запроса остановки. Отдельно пришлось снять сдвиг, который приёмник накладывает на весь ролик: перенесённая часть несла его в себе, и куски за ней задваивали кадр. Замерено: обрыв в четырёх разных местах, продолженная сборка каждый раз совпала с собранной за один раз попиксельно. В nlvetool у render появились --cancel-after=<сек> и сочетание слов: cache resume проверяет, что старый путь через кэш не сломался.
This commit is contained in:
parent
38f4b295b9
commit
4522f0000a
4 changed files with 346 additions and 35 deletions
42
README.md
42
README.md
|
|
@ -582,15 +582,34 @@ Linux потом искала по нему `.dll`.
|
|||
|
||||
## Прерванная сборка
|
||||
|
||||
Каждый доведённый до конца кусок помечается в списке рядом с самими
|
||||
кусками (`готово.txt` в кэше). Если сборка не дошла до конца — упала,
|
||||
была отменена или программу закрыли, — при следующей сборке в тот же файл
|
||||
программа предложит продолжить с готовых кусков.
|
||||
Если сборка не дошла до конца — упала, была отменена или программу
|
||||
закрыли, — при следующей сборке в тот же файл программа предложит
|
||||
продолжить.
|
||||
|
||||
Проверяется не только наличие файла: сборку могли прервать посреди
|
||||
записи, и файл оказался бы обрезанным. Поэтому куском считается только
|
||||
тот, который успел попасть в список.
|
||||
**При сборке потоком** продолжают с самого недописанного файла: он и есть
|
||||
уже собранная часть фильма, и в нём видно, сколько целых кадров успело
|
||||
записаться. Программа отступает назад до границы последнего целого куска,
|
||||
переносит эту часть в новый файл и досылает остаток. Собранное таким
|
||||
образом совпадает с собранным за один раз попиксельно.
|
||||
|
||||
Дописать в конец существующего MKV нельзя — Matroska этого не даёт, —
|
||||
поэтому продолжение всегда пишется в новый файл, и на диске в этот момент
|
||||
лежат оба. Места нужно вдвое больше готового ролика.
|
||||
|
||||
**MP4 продолжать нельзя вовсе:** оглавление пишется в конец, и оборванный
|
||||
файл не открывается — `moov atom not found`. Программа говорит об этом
|
||||
прямо и собирает заново. Если сборка длинная, лучше собрать в MKV, а потом
|
||||
переупаковать в MP4 окном ремукса: это копирование потоков, без
|
||||
перекодирования.
|
||||
|
||||
Готовый файл до последнего цел: сборка идёт в файл рядом и занимает место
|
||||
готового, только когда дописана. Сорвавшаяся попытка не отбирает то, что
|
||||
уже было собрано, — из двух недописанных остаётся тот, где кадров больше.
|
||||
|
||||
**При сборке через кэш кусков** продолжают иначе: каждый доведённый до
|
||||
конца кусок помечается в списке рядом с самими кусками (`готово.txt`).
|
||||
Проверяется не только наличие файла — сборку могли прервать посреди
|
||||
записи; куском считается только тот, который успел попасть в список.
|
||||
Первая строка списка — отпечаток плана: пути, места и длины всех кусков.
|
||||
Не совпал — значит монтаж с тех пор менялся, и готовые куски не подойдут;
|
||||
программа скажет об этом и соберёт заново.
|
||||
|
|
@ -815,6 +834,15 @@ Wine с новой библиотекой не грузилось ничего.
|
|||
`TThread`, `Name` и `Caption` — у формы. Компилятор ловит это как
|
||||
«Duplicate identifier», но причину не объясняет.
|
||||
|
||||
**Приёмник двигает весь ролик.** `-avoid_negative_ts make_zero` у
|
||||
приёмника сдвигает поток так, чтобы самая ранняя метка декодирования
|
||||
стала нулём, — а у первого куска она уходит в минус на задержку
|
||||
кодировщика, и весь ролик встаёт на 21 мс позже. Само по себе это
|
||||
безобидно, но продолжение прерванной сборки несёт этот сдвиг в
|
||||
перенесённой части, и куски за ней, размещённые по счёту кадров от нуля,
|
||||
налезают на неё на кадр. Поэтому при переносе сдвиг снимается
|
||||
отрицательным `-output_ts_offset`, а приёмник накладывает его заново.
|
||||
|
||||
**Сдвиг к нулю отменяет сдвиг по времени.** При сборке потоком место
|
||||
куску задаёт `-output_ts_offset`, но `-avoid_negative_ts make_zero`
|
||||
считается уже после него и двигает кусок обратно в ноль — ключ просто
|
||||
|
|
|
|||
250
src/uExport.pas
250
src/uExport.pas
|
|
@ -157,6 +157,19 @@ type
|
|||
куска: считать кадрами, а не складывая секунды, — единственный
|
||||
способ не накопить ошибку округления на тысяче кусков. }
|
||||
FSentFrames: Int64;
|
||||
{ Продолжение прерванной сборки: недописанный файл прошлого раза,
|
||||
сколько кадров в нём читается и отправлена ли уже его целая часть. }
|
||||
FResumeSource: string;
|
||||
FResumeGood: Int64;
|
||||
FPrefixSent: Boolean;
|
||||
{ На сколько прошлая сборка сдвинула весь ролик вперёд. Приёмник
|
||||
двигает поток так, чтобы самая ранняя метка декодирования стала
|
||||
нулём, а у первого куска она уходит в минус на задержку кодировщика.
|
||||
При переносе этот сдвиг снимается, иначе продолжение встало бы
|
||||
на него внахлёст. }
|
||||
FResumeShift: Double;
|
||||
{ Приёмник метки первого кадра при замере сдвига. }
|
||||
FFirstVideoPts: Double;
|
||||
|
||||
function DistinctSourceCount: Integer;
|
||||
procedure ChooseIntermediateFormat;
|
||||
|
|
@ -187,15 +200,32 @@ type
|
|||
procedure PacketCountLine(const ALine: string);
|
||||
|
||||
function RunFfmpeg(const AArgs: array of string; const AWhat: string): Boolean;
|
||||
{ Один кусок: в файл кэша или в трубу — смотря как собираем. }
|
||||
function RunPart(const AArgs: array of string; const AWhat: string): Boolean;
|
||||
function CountPackets(const APath: string): Int64;
|
||||
{ Один кусок: в файл кэша или в трубу — смотря как собираем.
|
||||
AFrames нужен продолжению: по нему видно, лежит ли этот кусок
|
||||
целиком в том, что успело записаться прошлый раз. }
|
||||
function RunPart(const AArgs: array of string; const AWhat: string;
|
||||
AFrames: Integer): Boolean;
|
||||
{ Сколько пакетов картинки в файле. AEvenIfCancelled — считать даже
|
||||
после того, как работу попросили прекратить: по этому числу
|
||||
решается, какой из недописанных файлов оставить, и остановка не
|
||||
повод терять уже собранное. }
|
||||
function CountPackets(const APath: string;
|
||||
AEvenIfCancelled: Boolean = False): Int64;
|
||||
function ExtractSegments: Boolean;
|
||||
function WriteConcatList(out AListPath: string): Boolean;
|
||||
function ConcatAll(const AListPath: string): Boolean;
|
||||
{ Сборка потоком: приёмник запускается один раз, куски идут в него
|
||||
по очереди, на диск ложится только готовый файл. }
|
||||
function StreamAll: Boolean;
|
||||
{ Готовит продолжение: смотрит недописанный файл прошлого раза. }
|
||||
procedure PrepareResume;
|
||||
{ Самая ранняя метка картинки в файле. Меньше нуля — не удалось. }
|
||||
function FirstVideoTime(const APath: string): Double;
|
||||
procedure FirstVideoLine(const ALine: string);
|
||||
{ Отправляет приёмнику целую часть недописанного файла. }
|
||||
function SendPrefix: Boolean;
|
||||
{ Решает судьбу двух файлов: собранного сейчас и лежавшего раньше. }
|
||||
procedure FinishResume(ASucceeded: Boolean; const AWork: string);
|
||||
procedure Verify;
|
||||
{ Сборка звука: куски сводятся в сырой поток и сжимаются одним
|
||||
проходом. Сырой поток нужен для точности — стык получается с
|
||||
|
|
@ -592,13 +622,33 @@ end;
|
|||
журнал с ходом работы приходит одним потоком. Всё остальное — те же
|
||||
проверки и тот же учёт работы. }
|
||||
function TExportThread.RunPart(const AArgs: array of string;
|
||||
const AWhat: string): Boolean;
|
||||
const AWhat: string; AFrames: Integer): Boolean;
|
||||
var
|
||||
Res: TProcResult;
|
||||
begin
|
||||
if not FStreaming then
|
||||
Exit(RunFfmpeg(AArgs, AWhat));
|
||||
|
||||
{ Продолжение прерванной сборки.
|
||||
|
||||
Пока очередной кусок целиком помещается в то, что успело записаться
|
||||
прошлый раз, делать его заново незачем: он придёт вместе с переносом
|
||||
собранного. Первый же кусок, который не помещается, и есть граница —
|
||||
перед ним переносим целую часть старого файла и дальше работаем как
|
||||
обычно. }
|
||||
if (FResumeGood > 0) and (not FPrefixSent) then
|
||||
begin
|
||||
if FSentFrames + AFrames <= FResumeGood then
|
||||
begin
|
||||
Inc(FReusedParts);
|
||||
FDoneWork := FDoneWork + FStepWork;
|
||||
PublishProgress;
|
||||
Exit(True);
|
||||
end;
|
||||
if not SendPrefix then
|
||||
Exit(False);
|
||||
end;
|
||||
|
||||
Log('> ' + CommandToString(FfmpegExe, AArgs));
|
||||
|
||||
FStepPosition := 0;
|
||||
|
|
@ -628,13 +678,18 @@ begin
|
|||
Result := True;
|
||||
end;
|
||||
|
||||
function TExportThread.CountPackets(const APath: string): Int64;
|
||||
function TExportThread.CountPackets(const APath: string;
|
||||
AEvenIfCancelled: Boolean): Int64;
|
||||
var
|
||||
Res: TProcResult;
|
||||
Cancel: TProcCancelQuery;
|
||||
begin
|
||||
FPacketCount := -1;
|
||||
Cancel := @CancelQuery;
|
||||
if AEvenIfCancelled then
|
||||
Cancel := nil;
|
||||
Res := RunProcess(FfprobeExe, ArgsCountVideoPackets(APath),
|
||||
@PacketCountLine, nil, @CancelQuery);
|
||||
@PacketCountLine, nil, Cancel);
|
||||
if (not Res.Started) or (Res.ExitCode <> 0) then
|
||||
Exit(-1);
|
||||
Result := FPacketCount;
|
||||
|
|
@ -823,14 +878,14 @@ var
|
|||
Format('part_%.3d_%s%s', [AIndex, ASuffix, FIntermediateExt]);
|
||||
end;
|
||||
|
||||
{ Время, на которое встанет эта часть в общем потоке. Отрицательное —
|
||||
{ Время, на которое встанет эта часть в общем потоке. NotPiped —
|
||||
значит пишем в файл, и время куску не нужно: он начинается с нуля. }
|
||||
function PartAt: Double;
|
||||
begin
|
||||
if FStreaming then
|
||||
Result := SentTime
|
||||
else
|
||||
Result := -1;
|
||||
Result := NotPiped;
|
||||
end;
|
||||
|
||||
{ Часть ушла — двигаем счётчик отправленных кадров. }
|
||||
|
|
@ -895,7 +950,8 @@ begin
|
|||
if not RunPart(ArgsExtract(FSegments[I].FilePath, Dst,
|
||||
0, FSegments[I].Duration, FIntermediateFormat,
|
||||
FSegments[I].Volume, FMaster.AudioCodec, Rep.WantFrames, WithAudio,
|
||||
PartAt), 'Подготовка вставки ' + IntToStr(I + 1)) then
|
||||
PartAt), 'Подготовка вставки ' + IntToStr(I + 1),
|
||||
Rep.WantFrames) then
|
||||
Exit;
|
||||
|
||||
Rep.TempPath := Dst;
|
||||
|
|
@ -930,7 +986,7 @@ begin
|
|||
PreferredEncoder(FMaster.VideoCodec),
|
||||
FIntermediateFormat = 'mpegts', FMaster.VideoBitrate,
|
||||
FSegments[I].SubFile, PartAt),
|
||||
'Наложение в куске ' + IntToStr(I + 1)) then
|
||||
'Наложение в куске ' + IntToStr(I + 1), Rep.WantFrames) then
|
||||
Exit;
|
||||
|
||||
Rep.TempPath := Dst;
|
||||
|
|
@ -992,7 +1048,7 @@ begin
|
|||
FMaster.SampleRate, FMaster.Channels, FSegments[I].Volume,
|
||||
FIntermediateFormat, FSegments[I].Filter, FSegments[I].SubFile,
|
||||
FMaster.VideoBitrate, PartAt),
|
||||
'Пересборка куска ' + IntToStr(I + 1)) then
|
||||
'Пересборка куска ' + IntToStr(I + 1), Rep.WantFrames) then
|
||||
Exit;
|
||||
|
||||
Rep.TempPath := Dst;
|
||||
|
|
@ -1027,7 +1083,7 @@ begin
|
|||
if not RunPart(ArgsExtract(FSegments[I].FilePath, Dst,
|
||||
FSegments[I].SrcStart, SegEnd, FIntermediateFormat,
|
||||
FSegments[I].Volume, FMaster.AudioCodec, WantFrames, WithAudio,
|
||||
PartAt), 'Вырезание куска ' + IntToStr(I + 1)) then
|
||||
PartAt), 'Вырезание куска ' + IntToStr(I + 1), WantFrames) then
|
||||
Exit;
|
||||
|
||||
Rep.ActualStart := FSegments[I].SrcStart;
|
||||
|
|
@ -1076,7 +1132,7 @@ begin
|
|||
FMaster.VideoCodec, FMaster.PixFmt, FMaster.AudioCodec,
|
||||
FMaster.SampleRate, FMaster.Channels, FIntermediateFormat,
|
||||
HeadFrames, WithAudio, FMaster.VideoBitrate, PartAt),
|
||||
'Перекодирование стыка куска ' + IntToStr(I + 1)) then
|
||||
'Перекодирование стыка куска ' + IntToStr(I + 1), HeadFrames) then
|
||||
Exit;
|
||||
|
||||
Rep.ActualStart := FSegments[I].SrcStart;
|
||||
|
|
@ -1099,7 +1155,7 @@ begin
|
|||
if not RunPart(ArgsExtract(FSegments[I].FilePath, Dst,
|
||||
EncodeUntil, SegEnd, FIntermediateFormat,
|
||||
FSegments[I].Volume, FMaster.AudioCodec, TailFrames, WithAudio,
|
||||
PartAt), 'Вырезание куска ' + IntToStr(I + 1)) then
|
||||
PartAt), 'Вырезание куска ' + IntToStr(I + 1), TailFrames) then
|
||||
Exit;
|
||||
|
||||
Rep.ActualStart := EncodeUntil;
|
||||
|
|
@ -1215,13 +1271,176 @@ end;
|
|||
этого он дописывает оглавление и завершается.
|
||||
|
||||
Звук должен быть готов заранее: приёмник берёт его вторым входом. }
|
||||
{ Продолжение прерванной сборки при работе потоком.
|
||||
|
||||
Кэша кусков тут нет, и продолжать не с чего — кроме самого
|
||||
недописанного файла. Он и есть уже собранная часть фильма: в нём видно,
|
||||
сколько целых кадров успело записаться.
|
||||
|
||||
Дописать в него нельзя: Matroska не даёт приписывать в конец. Поэтому
|
||||
он отодвигается в сторону, продолжение собирается в новый файл, и целая
|
||||
часть старого переливается туда первой — ровно так же, как обычный
|
||||
кусок.
|
||||
|
||||
MP4 сюда не попадает: его оглавление пишется в конец, и оборванный файл
|
||||
не открывается вовсе. }
|
||||
procedure TExportThread.FirstVideoLine(const ALine: string);
|
||||
var
|
||||
Value: Double;
|
||||
begin
|
||||
if not TryStrToFloat(Trim(ALine), Value, InvariantFormat) then
|
||||
Exit;
|
||||
if (FFirstVideoPts < 0) or (Value < FFirstVideoPts) then
|
||||
FFirstVideoPts := Value;
|
||||
end;
|
||||
|
||||
function TExportThread.FirstVideoTime(const APath: string): Double;
|
||||
var
|
||||
Res: TProcResult;
|
||||
begin
|
||||
FFirstVideoPts := -1;
|
||||
Res := RunProcess(FfprobeExe, ArgsFirstVideoTimes(APath),
|
||||
@FirstVideoLine, nil, @CancelQuery);
|
||||
if (not Res.Started) or (Res.ExitCode <> 0) then
|
||||
Exit(-1);
|
||||
Result := FFirstVideoPts;
|
||||
end;
|
||||
|
||||
procedure TExportThread.PrepareResume;
|
||||
var
|
||||
Good: Int64;
|
||||
begin
|
||||
FResumeSource := '';
|
||||
FResumeGood := 0;
|
||||
FResumeShift := 0;
|
||||
FPrefixSent := False;
|
||||
|
||||
if CancelQuery or not (FResume and FileExists(FOutputPath)) then
|
||||
Exit;
|
||||
|
||||
if LowerCase(ExtractFileExt(FOutputPath)) <> '.mkv' then
|
||||
begin
|
||||
Log('Продолжить нельзя: оборванный ' +
|
||||
Copy(ExtractFileExt(FOutputPath), 2, 10) + ' не читается. Собираю заново.');
|
||||
Exit;
|
||||
end;
|
||||
|
||||
Good := CountPackets(FOutputPath);
|
||||
if Good <= 0 then
|
||||
begin
|
||||
Log('В недописанном файле читаемых кадров нет, собираю заново.');
|
||||
Exit;
|
||||
end;
|
||||
|
||||
FResumeSource := FOutputPath;
|
||||
FResumeGood := Good;
|
||||
FResumeShift := FirstVideoTime(FOutputPath);
|
||||
if FResumeShift < 0 then
|
||||
FResumeShift := 0;
|
||||
Log(Format('В недописанном файле читается %d кадров — продолжаю с ' +
|
||||
'ближайшей границы куска. Ролик в нём начинается с %s.',
|
||||
[Good, SecondsToStr(FResumeShift)]));
|
||||
end;
|
||||
|
||||
function TExportThread.SendPrefix: Boolean;
|
||||
var
|
||||
Upto: Double;
|
||||
begin
|
||||
FPrefixSent := True;
|
||||
if FSentFrames <= 0 then
|
||||
begin
|
||||
{ Первый же кусок не влез целиком — брать из старого файла нечего. }
|
||||
Log('Целых кусков в недописанном файле не нашлось, собираю заново.');
|
||||
Exit(True);
|
||||
end;
|
||||
|
||||
SetStage('Переношу собранное прошлый раз');
|
||||
{ Времени с запасом: длину задаёт число кадров, а -to здесь только
|
||||
ограничитель на случай, если кадров в файле окажется больше. Отсчёт
|
||||
от сдвига: в старом файле ролик начинается не с нуля. }
|
||||
Upto := FResumeShift + (FSentFrames + 2) * FMaster.FpsDen / FMaster.FpsNum;
|
||||
FStepWork := SentTime;
|
||||
{ Сдвиг снимается: куски, которые пойдут следом, встают на свои места
|
||||
по счёту кадров от нуля, и перенесённое должно лечь на ту же сетку.
|
||||
Приёмник потом сдвинет всё обратно — ровно так же, как при сборке с
|
||||
начала. }
|
||||
Result := RunPart(ArgsExtract(FResumeSource, 'pipe:1', 0, Upto,
|
||||
'mpegts', 1, '', FSentFrames, False, -FResumeShift),
|
||||
'Перенос собранного прошлый раз', 0);
|
||||
if Result then
|
||||
Log(Format('Перенесено %d кадров, продолжаю с %s.',
|
||||
[FSentFrames, SecondsToStr(SentTime)]));
|
||||
end;
|
||||
|
||||
{ Что делать с двумя файлами по итогам сборки: тем, что писали сейчас, и
|
||||
тем, что лежал раньше.
|
||||
|
||||
Удача — новый занимает место готового. Неудача — остаётся тот из двух,
|
||||
в котором кадров больше: с него и продолжат в следующий раз. Прошлую
|
||||
работу нельзя терять из-за того, что новая попытка сорвалась раньше. }
|
||||
procedure TExportThread.FinishResume(ASucceeded: Boolean; const AWork: string);
|
||||
var
|
||||
New_, Old: Int64;
|
||||
begin
|
||||
if ASucceeded then
|
||||
begin
|
||||
DeleteFile(FOutputPath);
|
||||
if not RenameFile(AWork, FOutputPath) then
|
||||
begin
|
||||
FReport.Message := 'Ролик собран, но переименовать его не удалось: ' +
|
||||
AWork;
|
||||
Log(FReport.Message);
|
||||
end;
|
||||
Exit;
|
||||
end;
|
||||
|
||||
if not FileExists(FOutputPath) then
|
||||
begin
|
||||
{ Прошлого файла не было — недописанный занимает его место, с него и
|
||||
продолжат. }
|
||||
RenameFile(AWork, FOutputPath);
|
||||
Exit;
|
||||
end;
|
||||
|
||||
New_ := CountPackets(AWork, True);
|
||||
Old := CountPackets(FOutputPath, True);
|
||||
if New_ > Old then
|
||||
begin
|
||||
DeleteFile(FOutputPath);
|
||||
RenameFile(AWork, FOutputPath);
|
||||
Log(Format('Недописанного стало больше: %d кадров против %d.',
|
||||
[New_, Old]));
|
||||
end
|
||||
else
|
||||
begin
|
||||
DeleteFile(AWork);
|
||||
if Old > 0 then
|
||||
Log(Format('Прошлый недописанный файл полнее (%d кадров против %d), ' +
|
||||
'он и оставлен.', [Old, New_]));
|
||||
end;
|
||||
end;
|
||||
|
||||
function TExportThread.StreamAll: Boolean;
|
||||
var
|
||||
Args: TArgs;
|
||||
Work: string;
|
||||
Code: Integer;
|
||||
begin
|
||||
Result := False;
|
||||
Args := ArgsStreamMux(FAudioPath, FSubtitlePath, FOutputPath);
|
||||
PrepareResume;
|
||||
|
||||
{ Пишем не сразу в готовый файл, а рядом.
|
||||
|
||||
Так прошлая работа цела до последнего: недописанный файл прошлого
|
||||
раза — это уже собранная часть фильма, и стереть её новой попыткой,
|
||||
которая сорвётся раньше, значило бы отобрать у человека то, что уже
|
||||
посчитано. Место готового новый файл займёт, только когда будет
|
||||
дописан. }
|
||||
{ Расширение то же, что у готового: по нему ffmpeg выбирает контейнер. }
|
||||
Work := ChangeFileExt(FOutputPath, '.сборка' + ExtractFileExt(FOutputPath));
|
||||
DeleteFile(Work);
|
||||
|
||||
Args := ArgsStreamMux(FAudioPath, FSubtitlePath, Work);
|
||||
Log('> ' + CommandToString(FfmpegExe, Args) + ' (приёмник)');
|
||||
|
||||
FSink := TProcSink.Create(FfmpegExe, Args, nil, @ErrorLine);
|
||||
|
|
@ -1251,6 +1470,7 @@ begin
|
|||
Result := True;
|
||||
finally
|
||||
FreeAndNil(FSink);
|
||||
FinishResume(Result, Work);
|
||||
end;
|
||||
end;
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,13 @@ uses
|
|||
type
|
||||
TArgs = array of string;
|
||||
|
||||
const
|
||||
{ Значение AAt у сборщиков аргументов, означающее «пишем в файл, а не в
|
||||
трубу». Отдельная величина, а не просто «меньше нуля»: время куска в
|
||||
потоке бывает и отрицательным — так с перенесённого куска снимается
|
||||
сдвиг, который прошлая сборка наложила на весь ролик. }
|
||||
NotPiped = -1000000.0;
|
||||
|
||||
{ Пути к инструментам. Ищутся один раз и запоминаются. }
|
||||
function FfmpegExe: string;
|
||||
function FfprobeExe: string;
|
||||
|
|
@ -71,7 +78,7 @@ function ToolsAvailable(out AError: string): Boolean;
|
|||
function ArgsExtract(const ASrc, ADst: string; AStart, AEnd: Double;
|
||||
const AFormat: string; AVolume: Double = 1;
|
||||
const AAudioCodec: string = ''; AFrames: Integer = 0;
|
||||
AWithAudio: Boolean = True; AAt: Double = -1): TArgs;
|
||||
AWithAudio: Boolean = True; AAt: Double = NotPiped): TArgs;
|
||||
|
||||
{ Перепаковать файл целиком в заданный формат, копируя потоки. }
|
||||
function ArgsCopyTo(const ASrc, ADst, AFormat: string): TArgs;
|
||||
|
|
@ -88,7 +95,7 @@ function ArgsEncodeFragment(const ASrc, ADst: string; AStart, AEnd: Double;
|
|||
const AVideoCodec, APixFmt, AAudioCodec: string;
|
||||
ASampleRate, AChannels: Integer; const AFormat: string;
|
||||
AFrames: Integer = 0; AWithAudio: Boolean = True;
|
||||
ABitrate: Int64 = 0; AAt: Double = -1): TArgs;
|
||||
ABitrate: Int64 = 0; AAt: Double = NotPiped): TArgs;
|
||||
|
||||
{ Пересобрать кусок в параметры проекта.
|
||||
|
||||
|
|
@ -101,7 +108,7 @@ function ArgsTranscodeSegment(const ASrc, ADst: string; AStart, AEnd: Double;
|
|||
ASampleRate, AChannels: Integer; AVolume: Double;
|
||||
const AFormat: string; const AFilter: string = '';
|
||||
const ASubFile: string = ''; ABitrate: Int64 = 0;
|
||||
AAt: Double = -1): TArgs;
|
||||
AAt: Double = NotPiped): TArgs;
|
||||
|
||||
{ Путь внутри строки фильтра. Двоеточие там разделяет настройки, обратная
|
||||
косая и кавычка имеют своё значение — всё это надо обезвредить, иначе
|
||||
|
|
@ -197,7 +204,7 @@ function ArgsRenderLayers(const ALayers: array of TSegmentLayer;
|
|||
AWidth, AHeight, AFpsNum, AFpsDen: Integer;
|
||||
const APixFmt, ACodec: string; AIsTs: Boolean;
|
||||
ABitrate: Int64 = 0; const ASubFile: string = '';
|
||||
AAt: Double = -1): TArgs;
|
||||
AAt: Double = NotPiped): TArgs;
|
||||
|
||||
{ Сшивание готовой картинки с готовым звуком. Оба потока копируются.
|
||||
|
||||
|
|
@ -227,6 +234,10 @@ function ArgsFirstPacketTime(const ASrc: string): TArgs;
|
|||
индекса опорных кадров для контейнеров, кроме MP4. }
|
||||
function ArgsPacketFlags(const ASrc: string): TArgs;
|
||||
|
||||
{ Метки первых кадров картинки: по ним видно, с какого времени начинается
|
||||
файл. Нужно продолжению прерванной сборки. }
|
||||
function ArgsFirstVideoTimes(const ASrc: string): TArgs;
|
||||
|
||||
{ Название кодировщика для кодека.
|
||||
|
||||
Предпочитается аппаратный NVENC: на этой машине он кодирует HEVC в
|
||||
|
|
@ -608,7 +619,7 @@ end;
|
|||
24.0. }
|
||||
function OutputTail(const AFormat, ADst: string; AAt: Double): TArgs;
|
||||
begin
|
||||
if AAt < 0 then
|
||||
if AAt <= NotPiped then
|
||||
begin
|
||||
{ Метки времени вырезанного куска должны начинаться с нуля, иначе при
|
||||
склейке куски разъедутся по времени. }
|
||||
|
|
@ -653,7 +664,7 @@ end;
|
|||
{ Ход работы и хвост вывода разом: они всегда меняются вместе. }
|
||||
function TailArgs(const AFormat, ADst: string; AAt: Double): TArgs;
|
||||
begin
|
||||
if AAt < 0 then
|
||||
if AAt <= NotPiped then
|
||||
Result := ProgressArgs
|
||||
else
|
||||
Result := ProgressArgsToStderr;
|
||||
|
|
@ -1064,6 +1075,17 @@ begin
|
|||
'-read_intervals', '%+0.2', ASrc];
|
||||
end;
|
||||
|
||||
function ArgsFirstVideoTimes(const ASrc: string): TArgs;
|
||||
begin
|
||||
{ Первые полсекунды картинки: по ним видно, с какой метки файл
|
||||
начинается. Не один пакет, а несколько — у записи с B-кадрами первый
|
||||
по порядку хранения кадр показывается не первым, и самая ранняя метка
|
||||
может оказаться у второго или третьего. }
|
||||
Result := ['-v', 'error', '-select_streams', 'v:0',
|
||||
'-show_entries', 'packet=pts_time', '-of', 'csv=p=0',
|
||||
'-read_intervals', '%+0.5', ASrc];
|
||||
end;
|
||||
|
||||
function ArgsPacketFlags(const ASrc: string): TArgs;
|
||||
begin
|
||||
Result := ['-v', 'error', '-select_streams', 'v:0',
|
||||
|
|
|
|||
|
|
@ -21,9 +21,12 @@
|
|||
собрать проект-образец: видео внизу, картинка
|
||||
сверху посередине, дыра в начале
|
||||
render <проект.nlve> <выход> [burn|keep|resume|cache]
|
||||
[--cancel-after=<сек>]
|
||||
уплотить проект, создать вставки, собрать ролик
|
||||
и сверить число кадров; cache — собрать
|
||||
по-старому, через кэш кусков
|
||||
по-старому, через кэш кусков; --cancel-after
|
||||
оборвёт сборку на полпути, а resume продолжит
|
||||
её с недописанного файла
|
||||
subs <проект.nlve>
|
||||
собрать разметку субтитров из текстовых клипов
|
||||
и напечатать её
|
||||
|
|
@ -1525,8 +1528,34 @@ var
|
|||
Rep: TExportReport;
|
||||
I: Integer;
|
||||
WantSum: Int64;
|
||||
Ok: Boolean;
|
||||
Ok, Stopped: Boolean;
|
||||
CancelAfter, Waited: Double;
|
||||
|
||||
{ Слова после выхода идут в любом порядке и сочетаются: cache resume —
|
||||
это «по-старому и продолжая». }
|
||||
function HasWord(const AWord: string): Boolean;
|
||||
var
|
||||
J: Integer;
|
||||
begin
|
||||
Result := False;
|
||||
for J := 4 to ParamCount do
|
||||
if LowerCase(ParamStr(J)) = AWord then
|
||||
Exit(True);
|
||||
end;
|
||||
|
||||
begin
|
||||
{ Отдельно от слов может стоять --cancel-after=<сек>: оборвать сборку
|
||||
на полпути, чтобы было что продолжать. }
|
||||
CancelAfter := 0;
|
||||
for I := 4 to ParamCount do
|
||||
if Copy(ParamStr(I), 1, 15) = '--cancel-after=' then
|
||||
if not TryStrToFloat(Copy(ParamStr(I), 16, MaxInt), CancelAfter,
|
||||
InvariantFormat) then
|
||||
begin
|
||||
WriteLn('Не удалось разобрать значение --cancel-after');
|
||||
Halt(2);
|
||||
end;
|
||||
|
||||
Proj := TProject.Create;
|
||||
try
|
||||
if not Proj.LoadFromFile(ExpandFileName(ParamStr(2)), Err) then
|
||||
|
|
@ -1588,7 +1617,7 @@ begin
|
|||
{ Вжигание текста — последним, уже по окончательному списку кусков:
|
||||
повторное уплощение стёрло бы отметки о разметке. Так же устроено и
|
||||
окно сборки. }
|
||||
if (ParamCount >= 4) and (LowerCase(ParamStr(4)) = 'burn') then
|
||||
if HasWord('burn') then
|
||||
WriteLn('вжигаю текст, пересобрать придётся ',
|
||||
SecondsToStr(BurnTextIntoSegments(Proj, Segs, Proj.CacheDir)), ' с');
|
||||
|
||||
|
|
@ -1597,22 +1626,34 @@ begin
|
|||
чего собрался ролик. }
|
||||
Thread := TExportThread.Create(Segs, AudioSegs,
|
||||
ExpandFileName(ParamStr(3)),
|
||||
(ParamCount >= 4) and (LowerCase(ParamStr(4)) = 'keep'),
|
||||
HasWord('keep'),
|
||||
Proj.Master, Default(TEncodeProfile));
|
||||
{ Словом resume просят продолжить прерванную сборку: готовые куски
|
||||
берутся с диска, а не считаются заново. }
|
||||
Thread.Resume := (ParamCount >= 4) and (LowerCase(ParamStr(4)) = 'resume');
|
||||
Thread.Resume := HasWord('resume');
|
||||
{ Словом cache просят собрать по-старому, через кэш кусков: так два
|
||||
способа сверяются между собой на одном и том же монтаже. }
|
||||
Thread.AllowStream := not ((ParamCount >= 4) and
|
||||
(LowerCase(ParamStr(4)) = 'cache'));
|
||||
Thread.AllowStream := not HasWord('cache');
|
||||
Watcher := TExportWatcher.Create(Thread);
|
||||
try
|
||||
Thread.OnProgress := @Watcher.Progress;
|
||||
Thread.OnLog := @Watcher.LogLine;
|
||||
Thread.Start;
|
||||
{ Остановка на полпути: так получается недописанный файл, с
|
||||
которого потом просят продолжить. }
|
||||
Waited := 0;
|
||||
Stopped := False;
|
||||
while not Thread.Finished do
|
||||
begin
|
||||
CheckSynchronize(100);
|
||||
Waited := Waited + 0.1;
|
||||
if (CancelAfter > 0) and (not Stopped) and (Waited >= CancelAfter) then
|
||||
begin
|
||||
Stopped := True;
|
||||
WriteLn('[тест] запрашиваю остановку');
|
||||
Thread.RequestCancel;
|
||||
end;
|
||||
end;
|
||||
CheckSynchronize(0);
|
||||
Rep := Thread.Report;
|
||||
finally
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue