Files
ersatztv/ErsatzTV.FFmpeg/FFmpegState.cs
T
timothyandtimothy ed8b602445
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 11s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 25s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 9m9s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m41s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m23s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 6m23s
feat(735): bound the numeric FFmpeg profile fields with a 422, and expose readrate pacing (#847)
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-26 22:05:38 +00:00

118 lines
5.5 KiB
C#

using ErsatzTV.FFmpeg.OutputFormat;
namespace ErsatzTV.FFmpeg;
public record FFmpegState(
bool SaveReport,
HardwareAccelerationMode DecoderHardwareAccelerationMode,
HardwareAccelerationMode EncoderHardwareAccelerationMode,
Option<string> VaapiDriver,
Option<string> VaapiDevice,
Option<TimeSpan> Start,
Option<TimeSpan> Finish,
bool DoNotMapMetadata,
Option<string> MetadataServiceProvider,
Option<string> MetadataServiceName,
Option<string> MetadataAudioLanguage,
Option<string> MetadataSubtitleLanguage,
Option<string> MetadataSubtitleTitle,
OutputFormatKind OutputFormat,
Option<string> HlsPlaylistPath,
Option<string> HlsSegmentTemplate,
Option<string> HlsInitTemplate,
Option<string> HlsSegmentOptions,
TimeSpan PtsOffset,
Option<int> ThreadCount,
Option<int> MaybeQsvExtraHardwareFrames,
bool IsSongWithProgress,
bool IsHdrTonemap,
string TonemapAlgorithm,
bool IsTroubleshooting,
bool QsvPreferNativeDecoder = false,
Option<double> MaybeReadRate = default,
Option<double> MaybeReadRateCatchup = default)
{
// the QSV upload pool needs headroom for the frames in flight through the filter graph.
// extra_hw_frames=0 leaves none, so any input that is not throttled exhausts it: the graph
// fails with -12 (Cannot allocate memory), h264_qsv reports "Could not open encoder before
// EOF", and the output file gets no packets at all. Input throttling was the only thing
// hiding it — a work-ahead start (no -readrate) and #350's cold-start burst both remove that
// throttle, so the channel simply dies. A stored 0 is therefore treated as "no pool
// configured" rather than honored literally (ersatztv#529)
public const int MinimumQsvExtraHardwareFrames = 64;
public int QsvExtraHardwareFrames =>
Math.Max(MaybeQsvExtraHardwareFrames.IfNone(MinimumQsvExtraHardwareFrames), MinimumQsvExtraHardwareFrames);
// realtime pacing. an unset profile keeps the values these constants name, which are the ones
// the pipeline hardcoded before they became configurable (ersatztv#735)
public const double DefaultReadRate = 1.05;
public const double DefaultStreamCopyReadRate = 1.0;
// how fast a LAGGING realtime input may read until it is level again. measured on the #726
// repro (embedded dvd_subtitle -> overlay, QSV encode): 1.05 alone sustains 0.53x, catchup 2.0
// reaches 0.711x, and 6.0 restores the full 1.067x that the same pipeline achieves with no
// subtitle at all. 20.0 also measures 1.067x — i.e. the value is not a throughput dial above
// the point where the input catches up, so 6.0 is chosen as the smallest measured-sufficient
// ceiling rather than the largest that works (ersatztv#726)
public const double DefaultReadRateCatchup = 6.0;
// below realtime the process reads slower than a live client consumes and the channel stalls;
// ersatztv#726 is that failure, measured at an effective 0.53x. the ceiling is a CHOSEN bound,
// not a measured cliff: it exists so the field cannot be used to effectively disable pacing,
// which is the configuration ersatztv#529 measured to produce zero segments on a QSV pipeline
public const double MinimumReadRate = 1.0;
public const double MaximumReadRate = 2.0;
// catchup is a ceiling that applies only WHILE an input is behind, so it is bounded more
// loosely than the base rate; the same chosen-not-measured caveat applies to the ceiling.
// the FLOOR is only a write-path bound: at render time the resolved base rate is always at
// least MinimumReadRate, so Math.Max below already dominates it
public const double MinimumReadRateCatchup = 1.0;
public const double MaximumReadRateCatchup = 10.0;
// clamped for the same reason QsvExtraHardwareFrames is: a row written out of band (or before
// the write path validated the field) must not reach FFmpeg unbounded. the write path rejects
// an out-of-range value with a 422 naming the bound, so this is belt-and-braces, not the
// primary guard (ersatztv#735)
public double ReadRateFor(bool isStreamCopy) =>
MaybeReadRate.Match(
configured => Math.Clamp(configured, MinimumReadRate, MaximumReadRate),
() => isStreamCopy ? DefaultStreamCopyReadRate : DefaultReadRate);
// a catchup rate below the base rate cannot let a lagging input recover, so the resolved base
// rate is its real floor — no separate lower clamp, which would be unreachable behind this Max
public double ReadRateCatchupFor(bool isStreamCopy) =>
Math.Max(
Math.Min(MaybeReadRateCatchup.IfNone(DefaultReadRateCatchup), MaximumReadRateCatchup),
ReadRateFor(isStreamCopy));
public static FFmpegState Concat(bool saveReport, string channelName) =>
new(
saveReport,
HardwareAccelerationMode.None,
HardwareAccelerationMode.None,
Option<string>.None,
Option<string>.None,
Option<TimeSpan>.None,
Option<TimeSpan>.None,
true, // do not map metadata
"ErsatzTV",
channelName,
Option<string>.None,
Option<string>.None,
Option<string>.None,
OutputFormatKind.MpegTs,
Option<string>.None,
Option<string>.None,
Option<string>.None,
Option<string>.None,
TimeSpan.Zero,
Option<int>.None,
Option<int>.None,
false,
false,
"linear",
false);
}