feat(735): bound the numeric FFmpeg profile fields with a 422, and expose readrate pacing (#847)
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
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
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
This commit was merged in pull request #847.
This commit is contained in:
@@ -35,4 +35,6 @@ public record CreateFFmpegProfile(
|
||||
bool NormalizeFramerate,
|
||||
bool NormalizeColors,
|
||||
bool DeinterlaceVideo,
|
||||
bool QsvPreferNativeDecoder) : IRequest<Either<BaseError, CreateFFmpegProfileResult>>;
|
||||
bool QsvPreferNativeDecoder,
|
||||
double? ReadRate,
|
||||
double? ReadRateCatchup) : IRequest<Either<BaseError, CreateFFmpegProfileResult>>;
|
||||
|
||||
@@ -50,8 +50,12 @@ public class CreateFFmpegProfileHandler :
|
||||
private static Validation<BaseError, FFmpegProfile> Validate(
|
||||
CreateFFmpegProfile request,
|
||||
int resolutionId) =>
|
||||
(ValidateName(request), ValidateThreadCount(request))
|
||||
.Apply((name, threadCount) =>
|
||||
(ValidateName(request),
|
||||
ValidateThreadCount(request),
|
||||
FFmpegProfileBounds.ValidateQsvExtraHardwareFrames(request.QsvExtraHardwareFrames, stored: null),
|
||||
FFmpegProfileBounds.ValidateReadRate(request.ReadRate),
|
||||
FFmpegProfileBounds.ValidateReadRateCatchup(request.ReadRateCatchup, request.ReadRate))
|
||||
.Apply((name, threadCount, _, _, _) =>
|
||||
{
|
||||
var hwAccel = request.NormalizeVideo
|
||||
? request.HardwareAcceleration
|
||||
@@ -68,11 +72,9 @@ public class CreateFFmpegProfileHandler :
|
||||
HardwareAcceleration = hwAccel,
|
||||
VaapiDriver = request.VaapiDriver,
|
||||
VaapiDevice = request.VaapiDevice,
|
||||
// store what the pipeline will actually use, never a pool size FFmpegState would
|
||||
// floor away at render time (ersatztv#529)
|
||||
QsvExtraHardwareFrames = request.QsvExtraHardwareFrames is { } frames
|
||||
? Math.Max(frames, FFmpegState.MinimumQsvExtraHardwareFrames)
|
||||
: null,
|
||||
// stored exactly as submitted: an out-of-range value was already rejected with a
|
||||
// 422 naming the bound, so there is nothing left to silently rewrite (ersatztv#735)
|
||||
QsvExtraHardwareFrames = request.QsvExtraHardwareFrames,
|
||||
ResolutionId = resolutionId,
|
||||
ScalingBehavior = request.ScalingBehavior,
|
||||
|
||||
@@ -111,7 +113,9 @@ public class CreateFFmpegProfileHandler :
|
||||
NormalizeFramerate = request.NormalizeFramerate,
|
||||
NormalizeColors = request.NormalizeColors,
|
||||
DeinterlaceVideo = request.DeinterlaceVideo,
|
||||
QsvPreferNativeDecoder = request.QsvPreferNativeDecoder
|
||||
QsvPreferNativeDecoder = request.QsvPreferNativeDecoder,
|
||||
ReadRate = request.ReadRate,
|
||||
ReadRateCatchup = request.ReadRateCatchup
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -36,4 +36,6 @@ public record UpdateFFmpegProfile(
|
||||
bool NormalizeFramerate,
|
||||
bool NormalizeColors,
|
||||
bool DeinterlaceVideo,
|
||||
bool QsvPreferNativeDecoder) : IRequest<Either<BaseError, UpdateFFmpegProfileResult>>;
|
||||
bool QsvPreferNativeDecoder,
|
||||
double? ReadRate,
|
||||
double? ReadRateCatchup) : IRequest<Either<BaseError, UpdateFFmpegProfileResult>>;
|
||||
|
||||
@@ -55,11 +55,10 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFa
|
||||
p.VaapiDisplay = update.VaapiDisplay;
|
||||
p.VaapiDriver = update.VaapiDriver;
|
||||
p.VaapiDevice = update.VaapiDevice;
|
||||
// store what the pipeline will actually use, so a profile doesn't keep displaying a pool
|
||||
// size that FFmpegState floors away at render time (ersatztv#529)
|
||||
p.QsvExtraHardwareFrames = update.QsvExtraHardwareFrames is { } frames
|
||||
? Math.Max(frames, FFmpegState.MinimumQsvExtraHardwareFrames)
|
||||
: null;
|
||||
// stored exactly as submitted: an out-of-range NEW value was already rejected with a 422
|
||||
// naming the bound. an unchanged value that predates that validation is written back as-is
|
||||
// rather than rewritten, and FFmpegState floors it at render time (ersatztv#735)
|
||||
p.QsvExtraHardwareFrames = update.QsvExtraHardwareFrames;
|
||||
p.ResolutionId = update.ResolutionId;
|
||||
p.ScalingBehavior = update.ScalingBehavior;
|
||||
p.PadMode = update.PadMode;
|
||||
@@ -108,6 +107,8 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFa
|
||||
p.NormalizeColors = update.NormalizeColors;
|
||||
p.DeinterlaceVideo = update.DeinterlaceVideo;
|
||||
p.QsvPreferNativeDecoder = update.QsvPreferNativeDecoder;
|
||||
p.ReadRate = update.ReadRate;
|
||||
p.ReadRateCatchup = update.ReadRateCatchup;
|
||||
|
||||
// don't save invalid preset
|
||||
ICollection<string> presets = FFmpegLibraryHelper.PresetsForFFmpegProfile(
|
||||
@@ -131,8 +132,14 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFa
|
||||
TvContext dbContext,
|
||||
UpdateFFmpegProfile request,
|
||||
FFmpegProfile profile) =>
|
||||
(await ValidateName(dbContext, request), ValidateThreadCount(request))
|
||||
.Apply((_, _) => profile);
|
||||
(await ValidateName(dbContext, request),
|
||||
ValidateThreadCount(request),
|
||||
FFmpegProfileBounds.ValidateQsvExtraHardwareFrames(
|
||||
request.QsvExtraHardwareFrames,
|
||||
profile.QsvExtraHardwareFrames),
|
||||
FFmpegProfileBounds.ValidateReadRate(request.ReadRate),
|
||||
FFmpegProfileBounds.ValidateReadRateCatchup(request.ReadRateCatchup, request.ReadRate))
|
||||
.Apply((_, _, _, _, _) => profile);
|
||||
|
||||
private static Task<Option<FFmpegProfile>> FFmpegProfileMustExist(
|
||||
TvContext dbContext,
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.FFmpeg;
|
||||
|
||||
namespace ErsatzTV.Application.FFmpegProfiles;
|
||||
|
||||
/// <summary>
|
||||
/// Write-path bounds for the consequential numeric FFmpeg profile fields.
|
||||
/// A submitted value outside its documented range is REJECTED, naming the bound, rather than
|
||||
/// accepted and silently rewritten to something the caller never sent (ersatztv#735). The
|
||||
/// render-time clamps in <see cref="FFmpegState" /> stay as they are: they cover rows that
|
||||
/// predate this validation or were written out of band, which is what keeps the fix
|
||||
/// migration-free.
|
||||
/// </summary>
|
||||
internal static class FFmpegProfileBounds
|
||||
{
|
||||
internal static Validation<BaseError, Unit> ValidateQsvExtraHardwareFrames(int? requested, int? stored)
|
||||
{
|
||||
// a row stored before this validation existed may hold anything, and the SPA sends the whole
|
||||
// profile back on every edit — so rejecting an UNCHANGED legacy value would make an old
|
||||
// profile uneditable over a field the operator never touched (and cannot even see unless
|
||||
// hardware acceleration is QSV). only a NEWLY submitted out-of-range value is rejected;
|
||||
// FFmpegState.QsvExtraHardwareFrames still floors the legacy one at render time
|
||||
if (requested is null || requested == stored)
|
||||
{
|
||||
return Success<BaseError, Unit>(Unit.Default);
|
||||
}
|
||||
|
||||
return requested < FFmpegState.MinimumQsvExtraHardwareFrames
|
||||
? BaseError.New(
|
||||
$"QSV extra hardware frames must be at least {FFmpegState.MinimumQsvExtraHardwareFrames}; " +
|
||||
$"{requested} leaves the QSV upload pool with too little headroom and the transcode writes nothing at all")
|
||||
: Success<BaseError, Unit>(Unit.Default);
|
||||
}
|
||||
|
||||
internal static Validation<BaseError, Unit> ValidateReadRate(double? requested)
|
||||
{
|
||||
if (requested is null)
|
||||
{
|
||||
return Success<BaseError, Unit>(Unit.Default);
|
||||
}
|
||||
|
||||
return requested is < FFmpegState.MinimumReadRate or > FFmpegState.MaximumReadRate
|
||||
? BaseError.New(
|
||||
$"Read rate must be between {Format(FFmpegState.MinimumReadRate)} and {Format(FFmpegState.MaximumReadRate)}; " +
|
||||
"below realtime the channel stalls, and above this the input is no longer meaningfully paced")
|
||||
: Success<BaseError, Unit>(Unit.Default);
|
||||
}
|
||||
|
||||
internal static Validation<BaseError, Unit> ValidateReadRateCatchup(double? requested, double? requestedReadRate)
|
||||
{
|
||||
if (requested is null)
|
||||
{
|
||||
return Success<BaseError, Unit>(Unit.Default);
|
||||
}
|
||||
|
||||
if (requested is < FFmpegState.MinimumReadRateCatchup or > FFmpegState.MaximumReadRateCatchup)
|
||||
{
|
||||
return BaseError.New(
|
||||
$"Read rate catchup must be between {Format(FFmpegState.MinimumReadRateCatchup)} and " +
|
||||
$"{Format(FFmpegState.MaximumReadRateCatchup)}");
|
||||
}
|
||||
|
||||
// catchup is the rate a LAGGING input may read at until it is level again, so a value at or
|
||||
// below the base rate cannot let it recover: EQUAL is rejected too, because a catchup with
|
||||
// zero headroom is functionally no catchup while still reading as configured. compared
|
||||
// against the transcode default rather than the stream-copy one because that is the higher
|
||||
// of the two: a value that clears it clears both, without this check having to know the
|
||||
// profile's video format
|
||||
double effectiveReadRate = requestedReadRate ?? FFmpegState.DefaultReadRate;
|
||||
return requested <= effectiveReadRate
|
||||
? BaseError.New(
|
||||
$"Read rate catchup ({Format(requested.Value)}) must be greater than the read rate " +
|
||||
$"({Format(effectiveReadRate)}); a lagging input cannot catch up at a rate it is already paced at")
|
||||
: Success<BaseError, Unit>(Unit.Default);
|
||||
}
|
||||
|
||||
private static string Format(double value) =>
|
||||
value.ToString("0.0####", System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
@@ -36,4 +36,6 @@ public record FFmpegProfileViewModel(
|
||||
bool NormalizeFramerate,
|
||||
bool NormalizeColors,
|
||||
bool DeinterlaceVideo,
|
||||
bool QsvPreferNativeDecoder);
|
||||
bool QsvPreferNativeDecoder,
|
||||
double? ReadRate,
|
||||
double? ReadRateCatchup);
|
||||
|
||||
@@ -38,7 +38,9 @@ internal static class Mapper
|
||||
profile.NormalizeFramerate,
|
||||
profile.NormalizeColors,
|
||||
profile.DeinterlaceVideo == true,
|
||||
profile.QsvPreferNativeDecoder != false);
|
||||
profile.QsvPreferNativeDecoder != false,
|
||||
profile.ReadRate,
|
||||
profile.ReadRateCatchup);
|
||||
|
||||
internal static FFmpegProfileResponseModel ProjectToResponseModel(FFmpegProfile ffmpegProfile) =>
|
||||
new(
|
||||
@@ -82,5 +84,7 @@ internal static class Mapper
|
||||
ffmpegProfile.NormalizeFramerate,
|
||||
ffmpegProfile.NormalizeColors,
|
||||
ffmpegProfile.DeinterlaceVideo == true,
|
||||
ffmpegProfile.QsvPreferNativeDecoder != false);
|
||||
ffmpegProfile.QsvPreferNativeDecoder != false,
|
||||
ffmpegProfile.ReadRate,
|
||||
ffmpegProfile.ReadRateCatchup);
|
||||
}
|
||||
|
||||
@@ -37,4 +37,6 @@ public record FFmpegFullProfileResponseModel(
|
||||
bool NormalizeFramerate,
|
||||
bool NormalizeColors,
|
||||
bool DeinterlaceVideo,
|
||||
bool QsvPreferNativeDecoder);
|
||||
bool QsvPreferNativeDecoder,
|
||||
double? ReadRate,
|
||||
double? ReadRateCatchup);
|
||||
|
||||
@@ -14,6 +14,8 @@ public record FFmpegProfile
|
||||
public VaapiDriver VaapiDriver { get; set; }
|
||||
public string VaapiDevice { get; set; }
|
||||
public int? QsvExtraHardwareFrames { get; set; }
|
||||
public double? ReadRate { get; set; }
|
||||
public double? ReadRateCatchup { get; set; }
|
||||
public bool? QsvPreferNativeDecoder { get; set; }
|
||||
public int ResolutionId { get; set; }
|
||||
public Resolution Resolution { get; set; }
|
||||
|
||||
@@ -610,7 +610,9 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
false,
|
||||
GetTonemapAlgorithm(playbackSettings),
|
||||
channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel,
|
||||
channel.FFmpegProfile.QsvPreferNativeDecoder != false);
|
||||
channel.FFmpegProfile.QsvPreferNativeDecoder != false,
|
||||
Optional(channel.FFmpegProfile.ReadRate),
|
||||
Optional(channel.FFmpegProfile.ReadRateCatchup));
|
||||
|
||||
_logger.LogDebug("FFmpeg desired state {FrameState}", desiredState);
|
||||
|
||||
@@ -827,7 +829,9 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
false,
|
||||
false,
|
||||
GetTonemapAlgorithm(playbackSettings),
|
||||
channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel);
|
||||
channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel,
|
||||
MaybeReadRate: Optional(channel.FFmpegProfile.ReadRate),
|
||||
MaybeReadRateCatchup: Optional(channel.FFmpegProfile.ReadRateCatchup));
|
||||
|
||||
var ffmpegSubtitleStream = new ErsatzTV.FFmpeg.MediaStream(0, "ass", StreamKind.Video);
|
||||
|
||||
@@ -968,7 +972,9 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
false,
|
||||
false,
|
||||
GetTonemapAlgorithm(playbackSettings),
|
||||
channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel);
|
||||
channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel,
|
||||
MaybeReadRate: Optional(channel.FFmpegProfile.ReadRate),
|
||||
MaybeReadRateCatchup: Optional(channel.FFmpegProfile.ReadRateCatchup));
|
||||
|
||||
var audioInputFile = new NullAudioInputFile(audioState);
|
||||
|
||||
|
||||
@@ -680,10 +680,56 @@ public class PipelineBuilderBaseTests
|
||||
command.ShouldContain("-readrate 1.05 -readrate_initial_burst 8 -readrate_catchup 6.0 -i /tmp/whatever.mkv");
|
||||
}
|
||||
|
||||
// ersatztv#735: the pacing values became operator-tunable profile fields. these pin that a
|
||||
// configured value actually reaches the command line -- the defaults above are the OTHER half
|
||||
// of the same guard, and they are what an unset profile still gets
|
||||
[Test]
|
||||
public void Realtime_Input_Should_Use_A_Configured_ReadRate_And_Catchup()
|
||||
{
|
||||
string command = BuildRealtimeCommand(
|
||||
new CatchupCapableFFmpegCapabilities(),
|
||||
readRate: 1.5,
|
||||
readRateCatchup: 4.0);
|
||||
|
||||
command.ShouldContain("-readrate 1.5 -readrate_initial_burst 8 -readrate_catchup 4.0 -i /tmp/whatever.mkv");
|
||||
command.ShouldNotContain("-readrate 1.05");
|
||||
command.ShouldNotContain("-readrate_catchup 6.0");
|
||||
}
|
||||
|
||||
// the write path rejects an out-of-range value with a 422, so this only fires for a row written
|
||||
// out of band -- but FFmpeg must never see the unbounded value either way
|
||||
[Test]
|
||||
public void Realtime_Input_Should_Clamp_An_Out_Of_Range_ReadRate()
|
||||
{
|
||||
string command = BuildRealtimeCommand(
|
||||
new CatchupCapableFFmpegCapabilities(),
|
||||
readRate: 9.0,
|
||||
readRateCatchup: 0.1);
|
||||
|
||||
// 9.0 clamps to the 2.0 ceiling, and 0.1 is raised to the resolved base rate, because a
|
||||
// catchup below it could never let a lagging input recover
|
||||
command.ShouldContain("-readrate 2.0 -readrate_initial_burst 8 -readrate_catchup 2.0 -i /tmp/whatever.mkv");
|
||||
}
|
||||
|
||||
// ...and the catchup CEILING isolated from the base rate, which the case above cannot show:
|
||||
// there both clamps land on the same 2.0, so either one alone would satisfy it
|
||||
[Test]
|
||||
public void Realtime_Input_Should_Clamp_An_Out_Of_Range_ReadRateCatchup()
|
||||
{
|
||||
string command = BuildRealtimeCommand(
|
||||
new CatchupCapableFFmpegCapabilities(),
|
||||
readRate: 1.2,
|
||||
readRateCatchup: 15.0);
|
||||
|
||||
command.ShouldContain("-readrate 1.2 -readrate_initial_burst 8 -readrate_catchup 10.0 -i /tmp/whatever.mkv");
|
||||
}
|
||||
|
||||
private string BuildRealtimeCommand(
|
||||
IFFmpegCapabilities capabilities,
|
||||
bool stillImage = false,
|
||||
bool imageSubtitle = false)
|
||||
bool imageSubtitle = false,
|
||||
Option<double> readRate = default,
|
||||
Option<double> readRateCatchup = default)
|
||||
{
|
||||
var videoInputFile = new VideoInputFile(
|
||||
"/tmp/whatever.mkv",
|
||||
@@ -748,7 +794,9 @@ public class PipelineBuilderBaseTests
|
||||
false,
|
||||
false,
|
||||
"clip",
|
||||
false);
|
||||
false,
|
||||
MaybeReadRate: readRate,
|
||||
MaybeReadRateCatchup: readRateCatchup);
|
||||
|
||||
// a *separate* audio input matters here: for a still image the video input takes no readrate
|
||||
// at all, so only a distinct audio input can prove the burst was suppressed (this is the
|
||||
|
||||
@@ -28,7 +28,9 @@ public record FFmpegState(
|
||||
bool IsHdrTonemap,
|
||||
string TonemapAlgorithm,
|
||||
bool IsTroubleshooting,
|
||||
bool QsvPreferNativeDecoder = false)
|
||||
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
|
||||
@@ -42,6 +44,49 @@ public record FFmpegState(
|
||||
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,
|
||||
|
||||
@@ -22,14 +22,6 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
|
||||
// an operator who raises that setting above 2 gets less of the benefit (ersatztv#350)
|
||||
private const int InitialBurstSeconds = OutputFormatHls.SegmentSeconds * 2;
|
||||
|
||||
// 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)
|
||||
private const double CatchupReadRate = 6.0;
|
||||
|
||||
private readonly Option<AudioInputFile> _audioInputFile;
|
||||
private readonly Option<ConcatInputFile> _concatInputFile;
|
||||
private readonly IFFmpegCapabilities _ffmpegCapabilities;
|
||||
@@ -660,7 +652,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
|
||||
}
|
||||
|
||||
//SetStillImageInfiniteLoop(videoInputFile, videoStream, ffmpegState);
|
||||
SetRealtimeInput(videoInputFile, desiredState);
|
||||
SetRealtimeInput(videoInputFile, ffmpegState, desiredState);
|
||||
SetInfiniteLoop(videoInputFile, videoStream, ffmpegState, desiredState);
|
||||
SetFrameRateOutput(desiredState, pipelineSteps);
|
||||
SetVideoTrackTimescaleOutput(desiredState, pipelineSteps);
|
||||
@@ -855,14 +847,17 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
|
||||
}
|
||||
}
|
||||
|
||||
private void SetRealtimeInput(VideoInputFile videoInputFile, FrameState desiredState)
|
||||
private void SetRealtimeInput(VideoInputFile videoInputFile, FFmpegState ffmpegState, FrameState desiredState)
|
||||
{
|
||||
if (videoInputFile.StreamInputKind is StreamInputKind.Live || !desiredState.Realtime)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
double readRate = desiredState.VideoFormat == VideoFormat.Copy ? 1.0 : 1.05;
|
||||
// both defaults and both bounds live on FFmpegState, beside the profile fields that
|
||||
// override them, so the pacing contract is readable in one place (ersatztv#735)
|
||||
bool isStreamCopy = desiredState.VideoFormat == VideoFormat.Copy;
|
||||
double readRate = ffmpegState.ReadRateFor(isStreamCopy);
|
||||
|
||||
// without a burst, the readrate throttle applies from the very first read, so the first
|
||||
// segment cannot be written faster than ~realtime and every start pays a multi-second wait.
|
||||
@@ -894,7 +889,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
|
||||
// subtitle always rides the video path, so this shape cannot suffer the starvation anyway
|
||||
Option<double> catchupReadRate =
|
||||
!isStillImage && _ffmpegCapabilities.HasOption(FFmpegKnownOption.ReadrateCatchup)
|
||||
? CatchupReadRate
|
||||
? ffmpegState.ReadRateCatchupFor(isStreamCopy)
|
||||
: Option<double>.None;
|
||||
|
||||
_audioInputFile.Iter(a => a.AddOption(new ReadrateInputOption(readRate, initialBurstSeconds, catchupReadRate)));
|
||||
|
||||
ErsatzTV.Infrastructure.MySql/Migrations/20260826191057_Add_FFmpegProfile_ReadRatePacing.Designer.cs
Generated
+7348
File diff suppressed because it is too large
Load Diff
+38
@@ -0,0 +1,38 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Add_FFmpegProfile_ReadRatePacing : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<double>(
|
||||
name: "ReadRate",
|
||||
table: "FFmpegProfile",
|
||||
type: "double",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<double>(
|
||||
name: "ReadRateCatchup",
|
||||
table: "FFmpegProfile",
|
||||
type: "double",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ReadRate",
|
||||
table: "FFmpegProfile");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ReadRateCatchup",
|
||||
table: "FFmpegProfile");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -929,6 +929,12 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
.HasColumnType("tinyint(1)")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<double?>("ReadRate")
|
||||
.HasColumnType("double");
|
||||
|
||||
b.Property<double?>("ReadRateCatchup")
|
||||
.HasColumnType("double");
|
||||
|
||||
b.Property<int>("ResolutionId")
|
||||
.HasColumnType("int");
|
||||
|
||||
|
||||
+7173
File diff suppressed because it is too large
Load Diff
+38
@@ -0,0 +1,38 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Add_FFmpegProfile_ReadRatePacing : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<double>(
|
||||
name: "ReadRate",
|
||||
table: "FFmpegProfile",
|
||||
type: "REAL",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<double>(
|
||||
name: "ReadRateCatchup",
|
||||
table: "FFmpegProfile",
|
||||
type: "REAL",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ReadRate",
|
||||
table: "FFmpegProfile");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ReadRateCatchup",
|
||||
table: "FFmpegProfile");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -896,6 +896,12 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<double?>("ReadRate")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<double?>("ReadRateCatchup")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<int>("ResolutionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@ using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.FFmpeg;
|
||||
using ErsatzTV.FFmpeg.OutputFormat;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
@@ -134,15 +137,30 @@ public class FFmpegProfileHandlerTests
|
||||
persisted.QsvPreferNativeDecoder.ShouldBe(false);
|
||||
}
|
||||
|
||||
// ersatztv#529: a stored 0 reached ffmpeg as hwupload=extra_hw_frames=0, leaving the QSV pool no
|
||||
// headroom; FFmpegState floors it at render time, and these pin that the stored row converges too
|
||||
// so the profile never keeps displaying a value the pipeline would override.
|
||||
[TestCase(0, 64)]
|
||||
[TestCase(-8, 64)]
|
||||
[TestCase(63, 64)]
|
||||
[TestCase(64, 64)]
|
||||
[TestCase(128, 128)]
|
||||
public async Task Create_Should_Floor_QsvExtraHardwareFrames(int configured, int expected)
|
||||
// ersatztv#735: the write path used to accept an out-of-range pool size and store the floored
|
||||
// value instead, so a client that PUT 0 got a 200 and read back 64. it is now rejected, naming
|
||||
// the bound; FFmpegState still floors at render time for rows that predate this.
|
||||
[TestCase(0)]
|
||||
[TestCase(-8)]
|
||||
[TestCase(63)]
|
||||
public async Task Create_Should_Reject_QsvExtraHardwareFrames_Below_Minimum(int configured)
|
||||
{
|
||||
await SeedResolution(1);
|
||||
var handler = new CreateFFmpegProfileHandler(_db.Factory, _searchTargets);
|
||||
|
||||
Either<BaseError, CreateFFmpegProfileResult> result = await handler.Handle(
|
||||
MakeCreate(1, qsvExtraHardwareFrames: configured),
|
||||
CancellationToken.None);
|
||||
|
||||
LeftOf(result).Value.ShouldContain("at least 64");
|
||||
|
||||
await using TvContext context = _db.CreateContext();
|
||||
(await context.FFmpegProfiles.CountAsync()).ShouldBe(0);
|
||||
}
|
||||
|
||||
[TestCase(64)]
|
||||
[TestCase(128)]
|
||||
public async Task Create_Should_Store_QsvExtraHardwareFrames_Exactly_As_Submitted(int configured)
|
||||
{
|
||||
await SeedResolution(1);
|
||||
var handler = new CreateFFmpegProfileHandler(_db.Factory, _searchTargets);
|
||||
@@ -155,15 +173,15 @@ public class FFmpegProfileHandlerTests
|
||||
|
||||
await using TvContext context = _db.CreateContext();
|
||||
FFmpegProfile persisted = await context.FFmpegProfiles.FindAsync(created.FFmpegProfileId);
|
||||
persisted.QsvExtraHardwareFrames.ShouldBe(expected);
|
||||
persisted.QsvExtraHardwareFrames.ShouldBe(configured);
|
||||
}
|
||||
|
||||
[TestCase(0, 64)]
|
||||
[TestCase(-8, 64)]
|
||||
[TestCase(128, 128)]
|
||||
public async Task Update_Should_Floor_QsvExtraHardwareFrames(int configured, int expected)
|
||||
[TestCase(0)]
|
||||
[TestCase(-8)]
|
||||
[TestCase(63)]
|
||||
public async Task Update_Should_Reject_A_Newly_Submitted_QsvExtraHardwareFrames_Below_Minimum(int configured)
|
||||
{
|
||||
await SeedProfile(1);
|
||||
await SeedProfile(1, qsvExtraHardwareFrames: 128);
|
||||
await SeedResolution(1);
|
||||
var handler = new UpdateFFmpegProfileHandler(_db.Factory, _searchTargets);
|
||||
|
||||
@@ -171,11 +189,59 @@ public class FFmpegProfileHandlerTests
|
||||
MakeUpdate(1, qsvExtraHardwareFrames: configured),
|
||||
CancellationToken.None);
|
||||
|
||||
LeftOf(result).Value.ShouldContain("at least 64");
|
||||
|
||||
await using TvContext context = _db.CreateContext();
|
||||
FFmpegProfile persisted = await context.FFmpegProfiles.FindAsync(1);
|
||||
persisted.QsvExtraHardwareFrames.ShouldBe(128);
|
||||
}
|
||||
|
||||
// the other half of the same rule: the SPA sends the whole profile back on every edit, so a row
|
||||
// stored before this validation existed must stay editable over fields the operator did touch.
|
||||
// an UNCHANGED out-of-range value is written back as-is and floored at render time instead
|
||||
[Test]
|
||||
public async Task Update_Should_Accept_An_Unchanged_Legacy_QsvExtraHardwareFrames()
|
||||
{
|
||||
await SeedProfile(1, qsvExtraHardwareFrames: 0);
|
||||
await SeedResolution(1);
|
||||
var handler = new UpdateFFmpegProfileHandler(_db.Factory, _searchTargets);
|
||||
|
||||
Either<BaseError, UpdateFFmpegProfileResult> result = await handler.Handle(
|
||||
MakeUpdate(1, qsvExtraHardwareFrames: 0),
|
||||
CancellationToken.None);
|
||||
|
||||
RightOf(result);
|
||||
|
||||
await using TvContext context = _db.CreateContext();
|
||||
FFmpegProfile persisted = await context.FFmpegProfiles.FindAsync(1);
|
||||
persisted.QsvExtraHardwareFrames.ShouldBe(expected);
|
||||
persisted.QsvExtraHardwareFrames.ShouldBe(0);
|
||||
new FFmpegState(
|
||||
false,
|
||||
HardwareAccelerationMode.None,
|
||||
HardwareAccelerationMode.None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
OutputFormatKind.MpegTs,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
TimeSpan.Zero,
|
||||
None,
|
||||
Optional(persisted.QsvExtraHardwareFrames),
|
||||
false,
|
||||
false,
|
||||
"linear",
|
||||
false)
|
||||
.QsvExtraHardwareFrames.ShouldBe(FFmpegState.MinimumQsvExtraHardwareFrames);
|
||||
}
|
||||
|
||||
// null means "unconfigured" and FFmpegState already resolves it to the same 64; it must stay
|
||||
@@ -196,6 +262,133 @@ public class FFmpegProfileHandlerTests
|
||||
persisted.QsvExtraHardwareFrames.ShouldBeNull();
|
||||
}
|
||||
|
||||
// ersatztv#735: readrate pacing is an operator-tunable bounded field. out of band it is a dead
|
||||
// channel either way — below realtime the client starves, above the ceiling the input is no
|
||||
// longer meaningfully paced (which is the unthrottled read #529 measured to write no segments)
|
||||
[TestCase(0.9)]
|
||||
[TestCase(0.0)]
|
||||
[TestCase(2.5)]
|
||||
public async Task Create_Should_Reject_ReadRate_Outside_Bounds(double configured)
|
||||
{
|
||||
await SeedResolution(1);
|
||||
var handler = new CreateFFmpegProfileHandler(_db.Factory, _searchTargets);
|
||||
|
||||
Either<BaseError, CreateFFmpegProfileResult> result = await handler.Handle(
|
||||
MakeCreate(1, readRate: configured),
|
||||
CancellationToken.None);
|
||||
|
||||
LeftOf(result).Value.ShouldContain("Read rate must be between 1.0 and 2.0");
|
||||
}
|
||||
|
||||
[TestCase(0.9)]
|
||||
[TestCase(10.5)]
|
||||
public async Task Create_Should_Reject_ReadRateCatchup_Outside_Bounds(double configured)
|
||||
{
|
||||
await SeedResolution(1);
|
||||
var handler = new CreateFFmpegProfileHandler(_db.Factory, _searchTargets);
|
||||
|
||||
Either<BaseError, CreateFFmpegProfileResult> result = await handler.Handle(
|
||||
MakeCreate(1, readRateCatchup: configured),
|
||||
CancellationToken.None);
|
||||
|
||||
LeftOf(result).Value.ShouldContain("Read rate catchup must be between 1.0 and 10.0");
|
||||
}
|
||||
|
||||
// a catchup rate inside its own band can still be at or below the base rate, where it cannot
|
||||
// let a lagging input recover — the cross-field bound is the one a per-field check cannot see.
|
||||
// the EQUAL cases matter: zero headroom is functionally no catchup, while still reading as a
|
||||
// configured one
|
||||
[TestCase(null, 1.0)]
|
||||
[TestCase(null, 1.05)]
|
||||
[TestCase(1.5, 1.2)]
|
||||
[TestCase(1.5, 1.5)]
|
||||
[TestCase(2.0, 2.0)]
|
||||
public async Task Create_Should_Reject_ReadRateCatchup_At_Or_Below_The_ReadRate(double? readRate, double catchup)
|
||||
{
|
||||
await SeedResolution(1);
|
||||
var handler = new CreateFFmpegProfileHandler(_db.Factory, _searchTargets);
|
||||
|
||||
Either<BaseError, CreateFFmpegProfileResult> result = await handler.Handle(
|
||||
MakeCreate(1, readRate: readRate, readRateCatchup: catchup),
|
||||
CancellationToken.None);
|
||||
|
||||
LeftOf(result).Value.ShouldContain("must be greater than the read rate");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_Should_Persist_ReadRate_Pacing()
|
||||
{
|
||||
await SeedResolution(1);
|
||||
var handler = new CreateFFmpegProfileHandler(_db.Factory, _searchTargets);
|
||||
|
||||
Either<BaseError, CreateFFmpegProfileResult> result = await handler.Handle(
|
||||
MakeCreate(1, readRate: 1.2, readRateCatchup: 4.0),
|
||||
CancellationToken.None);
|
||||
|
||||
CreateFFmpegProfileResult created = RightOf(result);
|
||||
|
||||
await using TvContext context = _db.CreateContext();
|
||||
FFmpegProfile persisted = await context.FFmpegProfiles.FindAsync(created.FFmpegProfileId);
|
||||
persisted.ReadRate.ShouldBe(1.2);
|
||||
persisted.ReadRateCatchup.ShouldBe(4.0);
|
||||
}
|
||||
|
||||
// unset is the default posture and must stay null: FFmpegState resolves null to the values the
|
||||
// pipeline used before the fields existed, so an untouched profile paces exactly as it did
|
||||
[Test]
|
||||
public async Task Create_Should_Leave_Unset_ReadRate_Pacing_Null()
|
||||
{
|
||||
await SeedResolution(1);
|
||||
var handler = new CreateFFmpegProfileHandler(_db.Factory, _searchTargets);
|
||||
|
||||
Either<BaseError, CreateFFmpegProfileResult> result =
|
||||
await handler.Handle(MakeCreate(1), CancellationToken.None);
|
||||
|
||||
CreateFFmpegProfileResult created = RightOf(result);
|
||||
|
||||
await using TvContext context = _db.CreateContext();
|
||||
FFmpegProfile persisted = await context.FFmpegProfiles.FindAsync(created.FFmpegProfileId);
|
||||
persisted.ReadRate.ShouldBeNull();
|
||||
persisted.ReadRateCatchup.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Should_Reject_ReadRate_Outside_Bounds()
|
||||
{
|
||||
await SeedProfile(1);
|
||||
await SeedResolution(1);
|
||||
var handler = new UpdateFFmpegProfileHandler(_db.Factory, _searchTargets);
|
||||
|
||||
Either<BaseError, UpdateFFmpegProfileResult> result = await handler.Handle(
|
||||
MakeUpdate(1, readRate: 3.0),
|
||||
CancellationToken.None);
|
||||
|
||||
LeftOf(result).Value.ShouldContain("Read rate must be between 1.0 and 2.0");
|
||||
|
||||
await using TvContext context = _db.CreateContext();
|
||||
FFmpegProfile persisted = await context.FFmpegProfiles.FindAsync(1);
|
||||
persisted.ReadRate.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Should_Persist_ReadRate_Pacing()
|
||||
{
|
||||
await SeedProfile(1);
|
||||
await SeedResolution(1);
|
||||
var handler = new UpdateFFmpegProfileHandler(_db.Factory, _searchTargets);
|
||||
|
||||
Either<BaseError, UpdateFFmpegProfileResult> result = await handler.Handle(
|
||||
MakeUpdate(1, readRate: 1.5, readRateCatchup: 8.0),
|
||||
CancellationToken.None);
|
||||
|
||||
RightOf(result);
|
||||
|
||||
await using TvContext context = _db.CreateContext();
|
||||
FFmpegProfile persisted = await context.FFmpegProfiles.FindAsync(1);
|
||||
persisted.ReadRate.ShouldBe(1.5);
|
||||
persisted.ReadRateCatchup.ShouldBe(8.0);
|
||||
}
|
||||
|
||||
private static TR RightOf<TR>(Either<BaseError, TR> either) =>
|
||||
either.Match(Left: e => throw new AssertionException($"Expected a Right result, got {e}"), Right: r => r);
|
||||
|
||||
@@ -209,12 +402,13 @@ public class FFmpegProfileHandlerTests
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task SeedProfile(int id)
|
||||
private async Task SeedProfile(int id, int? qsvExtraHardwareFrames = null)
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
context.FFmpegProfiles.Add(new FFmpegProfile
|
||||
{
|
||||
Id = id,
|
||||
QsvExtraHardwareFrames = qsvExtraHardwareFrames,
|
||||
Name = "Default",
|
||||
ThreadCount = 1,
|
||||
NormalizeAudio = true,
|
||||
@@ -249,7 +443,9 @@ public class FFmpegProfileHandlerTests
|
||||
private static CreateFFmpegProfile MakeCreate(
|
||||
int resolutionId,
|
||||
bool qsvPreferNativeDecoder = true,
|
||||
int? qsvExtraHardwareFrames = null) =>
|
||||
int? qsvExtraHardwareFrames = null,
|
||||
double? readRate = null,
|
||||
double? readRateCatchup = null) =>
|
||||
new(
|
||||
"Default",
|
||||
1,
|
||||
@@ -281,13 +477,17 @@ public class FFmpegProfileHandlerTests
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
qsvPreferNativeDecoder);
|
||||
qsvPreferNativeDecoder,
|
||||
readRate,
|
||||
readRateCatchup);
|
||||
|
||||
private static UpdateFFmpegProfile MakeUpdate(
|
||||
int id,
|
||||
int resolutionId = 1,
|
||||
bool qsvPreferNativeDecoder = true,
|
||||
int? qsvExtraHardwareFrames = null) =>
|
||||
int? qsvExtraHardwareFrames = null,
|
||||
double? readRate = null,
|
||||
double? readRateCatchup = null) =>
|
||||
new(
|
||||
id,
|
||||
"Default",
|
||||
@@ -320,5 +520,7 @@ public class FFmpegProfileHandlerTests
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
qsvPreferNativeDecoder);
|
||||
qsvPreferNativeDecoder,
|
||||
readRate,
|
||||
readRateCatchup);
|
||||
}
|
||||
|
||||
@@ -217,7 +217,9 @@ public class FFmpegProfileControllerTests
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true);
|
||||
true,
|
||||
null,
|
||||
null);
|
||||
|
||||
private static CreateFFmpegProfileRequest MakeCreateRequest() =>
|
||||
new(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.ComponentModel;
|
||||
using ErsatzTV.Application.FFmpegProfiles;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
@@ -13,6 +14,11 @@ public record CreateFFmpegProfileRequest(
|
||||
string VaapiDisplay,
|
||||
VaapiDriver VaapiDriver,
|
||||
string VaapiDevice,
|
||||
[property: Description(
|
||||
"Extra surfaces in the QSV upload pool. Must be at least 64 when set; a smaller pool "
|
||||
+ "leaves no headroom for frames in flight and the transcode writes nothing at all. On update, a "
|
||||
+ "value equal to the one already stored is accepted unchanged, so a profile written before this "
|
||||
+ "validation existed stays editable.")]
|
||||
int? QsvExtraHardwareFrames,
|
||||
int ResolutionId,
|
||||
ScalingBehavior ScalingBehavior,
|
||||
@@ -35,7 +41,16 @@ public record CreateFFmpegProfileRequest(
|
||||
bool NormalizeFramerate,
|
||||
bool NormalizeColors,
|
||||
bool DeinterlaceVideo,
|
||||
bool? QsvPreferNativeDecoder = null)
|
||||
bool? QsvPreferNativeDecoder = null,
|
||||
[property: Description(
|
||||
"Realtime pacing multiplier for the input. Unset keeps the built-in pacing "
|
||||
+ "(1.05, or 1.0 for a stream copy). Must be between 1.0 and 2.0 when set.")]
|
||||
double? ReadRate = null,
|
||||
[property: Description(
|
||||
"Rate a lagging realtime input may read at until it is level again. Unset keeps the "
|
||||
+ "built-in 6.0. Must be between 1.0 and 10.0, and GREATER than the read rate — equal is "
|
||||
+ "zero headroom, which is functionally no catchup.")]
|
||||
double? ReadRateCatchup = null)
|
||||
{
|
||||
public CreateFFmpegProfile ToCommand() =>
|
||||
new(
|
||||
@@ -69,5 +84,7 @@ public record CreateFFmpegProfileRequest(
|
||||
NormalizeFramerate,
|
||||
NormalizeColors,
|
||||
DeinterlaceVideo,
|
||||
QsvPreferNativeDecoder ?? true);
|
||||
QsvPreferNativeDecoder ?? true,
|
||||
ReadRate,
|
||||
ReadRateCatchup);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.ComponentModel;
|
||||
using ErsatzTV.Application.FFmpegProfiles;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
@@ -13,6 +14,11 @@ public record UpdateFFmpegProfileRequest(
|
||||
string VaapiDisplay,
|
||||
VaapiDriver VaapiDriver,
|
||||
string VaapiDevice,
|
||||
[property: Description(
|
||||
"Extra surfaces in the QSV upload pool. Must be at least 64 when set; a smaller pool "
|
||||
+ "leaves no headroom for frames in flight and the transcode writes nothing at all. On update, a "
|
||||
+ "value equal to the one already stored is accepted unchanged, so a profile written before this "
|
||||
+ "validation existed stays editable.")]
|
||||
int? QsvExtraHardwareFrames,
|
||||
int ResolutionId,
|
||||
ScalingBehavior ScalingBehavior,
|
||||
@@ -35,7 +41,16 @@ public record UpdateFFmpegProfileRequest(
|
||||
bool NormalizeFramerate,
|
||||
bool NormalizeColors,
|
||||
bool DeinterlaceVideo,
|
||||
bool? QsvPreferNativeDecoder = null)
|
||||
bool? QsvPreferNativeDecoder = null,
|
||||
[property: Description(
|
||||
"Realtime pacing multiplier for the input. Unset keeps the built-in pacing "
|
||||
+ "(1.05, or 1.0 for a stream copy). Must be between 1.0 and 2.0 when set.")]
|
||||
double? ReadRate = null,
|
||||
[property: Description(
|
||||
"Rate a lagging realtime input may read at until it is level again. Unset keeps the "
|
||||
+ "built-in 6.0. Must be between 1.0 and 10.0, and GREATER than the read rate — equal is "
|
||||
+ "zero headroom, which is functionally no catchup.")]
|
||||
double? ReadRateCatchup = null)
|
||||
{
|
||||
public UpdateFFmpegProfile ToCommand(int id) =>
|
||||
new(
|
||||
@@ -70,5 +85,7 @@ public record UpdateFFmpegProfileRequest(
|
||||
NormalizeFramerate,
|
||||
NormalizeColors,
|
||||
DeinterlaceVideo,
|
||||
QsvPreferNativeDecoder ?? true);
|
||||
QsvPreferNativeDecoder ?? true,
|
||||
ReadRate,
|
||||
ReadRateCatchup);
|
||||
}
|
||||
|
||||
@@ -25391,6 +25391,7 @@
|
||||
"null",
|
||||
"integer"
|
||||
],
|
||||
"description": "Extra surfaces in the QSV upload pool. Must be at least 64 when set; a smaller pool leaves no headroom for frames in flight and the transcode writes nothing at all. On update, a value equal to the one already stored is accepted unchanged, so a profile written before this validation existed stays editable.",
|
||||
"format": "int32"
|
||||
},
|
||||
"resolutionId": {
|
||||
@@ -25478,6 +25479,22 @@
|
||||
"null",
|
||||
"boolean"
|
||||
]
|
||||
},
|
||||
"readRate": {
|
||||
"type": [
|
||||
"null",
|
||||
"number"
|
||||
],
|
||||
"description": "Realtime pacing multiplier for the input. Unset keeps the built-in pacing (1.05, or 1.0 for a stream copy). Must be between 1.0 and 2.0 when set.",
|
||||
"format": "double"
|
||||
},
|
||||
"readRateCatchup": {
|
||||
"type": [
|
||||
"null",
|
||||
"number"
|
||||
],
|
||||
"description": "Rate a lagging realtime input may read at until it is level again. Unset keeps the built-in 6.0. Must be between 1.0 and 10.0, and GREATER than the read rate — equal is zero headroom, which is functionally no catchup.",
|
||||
"format": "double"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -26485,7 +26502,9 @@
|
||||
"normalizeFramerate",
|
||||
"normalizeColors",
|
||||
"deinterlaceVideo",
|
||||
"qsvPreferNativeDecoder"
|
||||
"qsvPreferNativeDecoder",
|
||||
"readRate",
|
||||
"readRateCatchup"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -26604,6 +26623,20 @@
|
||||
},
|
||||
"qsvPreferNativeDecoder": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"readRate": {
|
||||
"type": [
|
||||
"null",
|
||||
"number"
|
||||
],
|
||||
"format": "double"
|
||||
},
|
||||
"readRateCatchup": {
|
||||
"type": [
|
||||
"null",
|
||||
"number"
|
||||
],
|
||||
"format": "double"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -32016,6 +32049,7 @@
|
||||
"null",
|
||||
"integer"
|
||||
],
|
||||
"description": "Extra surfaces in the QSV upload pool. Must be at least 64 when set; a smaller pool leaves no headroom for frames in flight and the transcode writes nothing at all. On update, a value equal to the one already stored is accepted unchanged, so a profile written before this validation existed stays editable.",
|
||||
"format": "int32"
|
||||
},
|
||||
"resolutionId": {
|
||||
@@ -32103,6 +32137,22 @@
|
||||
"null",
|
||||
"boolean"
|
||||
]
|
||||
},
|
||||
"readRate": {
|
||||
"type": [
|
||||
"null",
|
||||
"number"
|
||||
],
|
||||
"description": "Realtime pacing multiplier for the input. Unset keeps the built-in pacing (1.05, or 1.0 for a stream copy). Must be between 1.0 and 2.0 when set.",
|
||||
"format": "double"
|
||||
},
|
||||
"readRateCatchup": {
|
||||
"type": [
|
||||
"null",
|
||||
"number"
|
||||
],
|
||||
"description": "Rate a lagging realtime input may read at until it is level again. Unset keeps the built-in 6.0. Must be between 1.0 and 10.0, and GREATER than the read rate — equal is zero headroom, which is functionally no catchup.",
|
||||
"format": "double"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -28,6 +28,7 @@ doc below, or that changes which sections a task signal points to.**
|
||||
| Auth / security-surface work | `docs/decisions/api-auth-security.md` |
|
||||
| CI / release pipeline work | `docs/ci-cd.md` + `docs/decisions/release-ci-governance.md` |
|
||||
| Proposing a new guard / CI check / regression test convention | `docs/defect-shapes-773.md` §4 (detector menu + the classes where no detector is plausible), then the three rules every guard must satisfy: `docs/decisions/records/testing/guard-derives-population-from-source.md`, `…/guard-ships-with-mutation-proof.md` and `…/mutation-claims-are-executed.md` (a `MUTATION` grade carries a DECLARED clause mutation that is re-run every suite) |
|
||||
| Adding or bounding a consequential numeric config field (an FFmpeg profile tunable, a pipeline knob) | `docs/api-conventions.md` §3d — reject out of range with a 422 naming the bound and its consequence, never accept-then-rewrite; validate against the constants the renderer reads, keep the render-time clamp for pre-existing rows, and let an UNCHANGED legacy value through on update. Then `api.ffmpeg-profile-numeric-bounds` |
|
||||
| Testing a surface gated by config / an env var / a credential | `docs/decisions/records/testing/deny-path-at-production-config-value.md` — cover the setting absent, at its production value, and each opt-out, and assert the DENY branch |
|
||||
| Touching a full-replace write path or a hand-built request object | `docs/decisions/records/testing/full-replace-asserts-field-list.md` — derive the field list from the DTO and assert set equality; reconcile by id where child state exists. In the SPA the same rule is enforced by the type system: `docs/spa-conventions.md` §4b — build the body as `Complete<T>`, annotating BOTH the wrapper parameter and every construction site |
|
||||
| Writing or editing any doc, or answering a review finding in prose | `docs/decisions/records/docs/no-session-narrative.md` — the doc records the END STATE; the path to it goes in the commit message. Apply the who-benefits test, and read the carve-out before you cut (dated measurements, stated snapshot boundaries and tested-and-rejected results stay) |
|
||||
|
||||
@@ -347,6 +347,42 @@ and "synced". See `docs/decisions.md` 2026-07-11 (#202) for the fuller rationale
|
||||
bug this pattern corrected (Blazor's Plex `Unlock` ordering released the library lock before a
|
||||
dependent second message ran).
|
||||
|
||||
### 3d. Bound a consequential numeric field with a 422 — never accept-then-rewrite
|
||||
|
||||
A write path that accepts an out-of-range number, stores a *different* one and returns `200` teaches
|
||||
the caller nothing and leaves the stored config no longer describing the behavior: the SPA keeps
|
||||
rendering what was typed while the pipeline uses the substitute, and a machine client that `PUT`s a
|
||||
value reads back another. **Validate to the documented range and return 422 naming the bound**, with
|
||||
the consequence of exceeding it in the message ("…leaves the QSV upload pool with too little headroom
|
||||
and the transcode writes nothing at all"), so the error teaches the bound instead of hiding it.
|
||||
|
||||
Three rules that come with it (exemplar: `ErsatzTV.Application/FFmpegProfiles/FFmpegProfileBounds.cs`,
|
||||
ersatztv#735):
|
||||
|
||||
- **Put the constants where the renderer reads them, and validate against those** — `FFmpegState`
|
||||
owns `MinimumQsvExtraHardwareFrames`, `Minimum/MaximumReadRate` and the defaults, and the
|
||||
write-path validator reads those symbols rather than restating numbers. **The SPA cannot: it
|
||||
restates each bound as a literal** (`web/src/screens/ffmpegProfileDraft.ts`), and nothing pins the
|
||||
two together, so raising a server bound leaves every test green while the form keeps enforcing the
|
||||
old one. Mirror the value AND the wording of the server's message, and treat the drift as a known
|
||||
residual rather than assuming the literal is checked.
|
||||
- **Keep the render-time clamp as well.** It is what makes the change migration-free: rows written
|
||||
before the validation existed, or out of band, still cannot reach FFmpeg unbounded. Validation is
|
||||
the primary guard; the clamp is belt-and-braces, and both need a test.
|
||||
- **On update, reject a NEWLY submitted out-of-range value, not an unchanged legacy one.** The SPA
|
||||
sends the whole profile back on every edit, so rejecting a stored-but-out-of-range value would
|
||||
make an old row uneditable over a field the operator never touched — and, when the field is
|
||||
conditionally rendered, cannot even see. Compare against the stored value and let an unchanged one
|
||||
through — **and mirror the exemption in the client**, or the form blocks a save the server would
|
||||
have accepted and the row is uneditable in the surface that matters (`validate(draft, stored)` in
|
||||
`ffmpegProfileDraft.ts`; the add/copy path passes no stored draft and stays strict, matching the
|
||||
create handler's `stored: null`).
|
||||
|
||||
`null` keeps meaning **unset**, resolved by the renderer to the value it used before the field was
|
||||
configurable — never materialized into a stored number on save, so an untouched profile behaves
|
||||
identically. Document the range in the schema with `[property: Description("…")]` on the request
|
||||
record's positional parameter (`System.ComponentModel`); it renders into `v1.json`.
|
||||
|
||||
## 4. Artwork contract
|
||||
|
||||
API response DTOs return **rooted, directly-usable artwork URLs** — e.g. `/artwork/posters/...`,
|
||||
|
||||
@@ -13,6 +13,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
|
||||
| `api.channel-health-object` | `ChannelResponseModel`/`ChannelDetailResponseModel` carry a server-derived `health` object (`ChannelHealthResponseModel { Status, Faults[], PlayoutCount, BrokenSourceItemCount }`) computed **read-time** from the built timeline (`Playout.BuildStatus` + upcoming `PlayoutItem → MediaItem.State`, `Finish >= now`), kind-agnostic across all 5 `PlayoutScheduleKind` values; `Status`/`Faults` are const-string classes (`ChannelHealthStatus`, `ChannelFault`), not C# enums, so the SPA hand-maintains the union (mirrors `ChannelPreviewAvailability`). This supersedes #72's "raw fact only, no derived enum, empty-schedule/broken-source deliberately not computed" stance now that the auto-tune taxonomy churn (#383/#384) it was waiting on has landed (see `channel.origin-marker` sibling record, #414). | 2026-07-23 | [link](records/api/channel-health-object.md) |
|
||||
| `api.channel-preview-capability` | Whether a channel can be previewed in the browser is declared by the server, not derived by the SPA, as an additive `Preview` field (`{Availability, ManifestUrl, UnavailableReason}`) on `ChannelResponseModel`. | 2026-07-21 | [link](records/api/channel-preview-capability.md) |
|
||||
| `api.decode-by-id` | Endpoints that decode/expand opaque stored state accept a database row id and resolve it server-side rather than round-tripping client-supplied serialized state. | 2026-07-07 | [link](records/api/decode-by-id.md) |
|
||||
| `api.ffmpeg-profile-numeric-bounds` | A write path that receives an out-of-range value for a consequential numeric FFmpeg profile field returns 422 naming the bound AND the consequence of exceeding it, instead of storing a substitute and returning 200. `FFmpegProfileBounds` (ErsatzTV.Application/FFmpegProfiles) is the single validator, called from both the create and the update handler, and it validates against constants declared on `FFmpegState` beside the render-time resolution rather than restating numbers — `MinimumQsvExtraHardwareFrames`, `Minimum/MaximumReadRate` and `MaximumReadRateCatchup` are read by BOTH the validator and the renderer, while `MinimumReadRateCatchup` is write-path-only (at render time the resolved base rate is always at least `MinimumReadRate`, so it can never be the binding floor). THE RENDER-TIME CLAMPS STAY: they cover rows written before this validation existed or out of band, and keeping them is what makes the change migration-free. ON UPDATE, only a NEWLY submitted out-of-range value is rejected — an UNCHANGED legacy value is written back as-is, because the SPA sends the whole profile on every edit and rejecting it would make an old row uneditable over a field the operator never touched and, when hardware acceleration is not QSV, cannot see. Separately, the readrate pacing that `PipelineBuilderBase` hardcoded is now two nullable profile fields, `ReadRate` and `ReadRateCatchup`; `null` means unset and resolves to the values the pipeline used before they were configurable, so an untouched profile paces identically. `-readrate_catchup` stays ON by default and capability-gated in code — this makes it tunable, not optional. | 2026-08-26 | [link](records/api/ffmpeg-profile-numeric-bounds.md) |
|
||||
| `api.from-lineup-clear-to-none` | `POST /api/v1/channels/from-lineup` (and the Auto-Tune per-channel `advanced`, which reuses the same DTO) distinguishes *inherit* from *clear-to-none* with a typed `clear` enum list on `advanced`. A field left null/omitted still inherits the template value (unchanged for every existing client); naming a field in `clear` forces it to none on the new channel even when the template sets one. Sending both a set value and a clear for the same field is a validation error. | 2026-07-21 | [link](records/api/from-lineup-clear-to-none.md) |
|
||||
| `api.healthcheck-remediation-dto` | Health-check remediation is server-declared `{Kind, Target}` metadata on an additive DTO field; the SPA renders/acts on it, it doesn't derive labels itself. | 2026-07-17 | [link](records/api/healthcheck-remediation-dto.md) |
|
||||
| `api.healthcheck-ttl-cache` | Health-check results are held in a 30s TTL cache inside `HealthCheckService`; a non-forced `GET /api/v1/health` returns the cached list, and `?refresh=true` (or a forced internal caller) bypasses it to run fresh. | 2026-07-19 | [link](records/api/healthcheck-ttl-cache.md) |
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
key: api.ffmpeg-profile-numeric-bounds
|
||||
title: '2026-08-26 — Consequential numeric FFmpeg profile fields are REJECTED out of range, not accepted and rewritten; readrate pacing becomes a bounded profile field (#735)'
|
||||
status: active
|
||||
since: '2026-08-26'
|
||||
supersedes: none
|
||||
superseded-by: none
|
||||
rule: 'A write path that receives an out-of-range value for a consequential numeric FFmpeg profile field returns 422 naming the bound AND the consequence of exceeding it, instead of storing a substitute and returning 200. `FFmpegProfileBounds` (ErsatzTV.Application/FFmpegProfiles) is the single validator, called from both the create and the update handler, and it validates against constants declared on `FFmpegState` beside the render-time resolution rather than restating numbers — `MinimumQsvExtraHardwareFrames`, `Minimum/MaximumReadRate` and `MaximumReadRateCatchup` are read by BOTH the validator and the renderer, while `MinimumReadRateCatchup` is write-path-only (at render time the resolved base rate is always at least `MinimumReadRate`, so it can never be the binding floor). THE RENDER-TIME CLAMPS STAY: they cover rows written before this validation existed or out of band, and keeping them is what makes the change migration-free. ON UPDATE, only a NEWLY submitted out-of-range value is rejected — an UNCHANGED legacy value is written back as-is, because the SPA sends the whole profile on every edit and rejecting it would make an old row uneditable over a field the operator never touched and, when hardware acceleration is not QSV, cannot see. Separately, the readrate pacing that `PipelineBuilderBase` hardcoded is now two nullable profile fields, `ReadRate` and `ReadRateCatchup`; `null` means unset and resolves to the values the pipeline used before they were configurable, so an untouched profile paces identically. `-readrate_catchup` stays ON by default and capability-gated in code — this makes it tunable, not optional.'
|
||||
signals: 'silent transform of a submitted value · PUT 0 returns 200 and reads back 64 · 422 naming the bound · extra_hw_frames floor · readrate · readrate_catchup · pacing is not exposed to an operator · bounded numeric profile field · legacy row stays editable · paths: `ErsatzTV.Application/FFmpegProfiles/FFmpegProfileBounds.cs`, `ErsatzTV.FFmpeg/FFmpegState.cs`, `ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs`, `ErsatzTV/Controllers/Api/Requests/CreateFFmpegProfileRequest.cs`, `web/src/screens/ffmpegProfileDraft.ts`, `web/src/screens/FFmpegProfilesScreen.tsx` · issues: #735, #726, #529, #350'
|
||||
mechanics: '`FFmpegProfileBounds.Validate{QsvExtraHardwareFrames,ReadRate,ReadRateCatchup}` return `Validation<BaseError, Unit>` and are applied alongside the name/thread-count checks in both handlers; the payload is `Unit` because LanguageExt `Validation.Success` throws on a null-valued `Nullable<T>`. `FFmpegState.ReadRateFor(isStreamCopy)` / `ReadRateCatchupFor(isStreamCopy)` resolve and clamp at render time. Pinned by `FFmpegProfileHandlerTests` (rejection, the unchanged-legacy acceptance, and the persisted-null default), `PipelineBuilderBaseTests` (a configured value reaches the command line; an out-of-range one is clamped) and `web/src/screens/ffmpegProfileDraft.test.ts` (the SPA mirrors the same bounds and the same legacy exemption). The SPA constants are hand-mirrored literals with NO cross-language pin — see the record body.'
|
||||
---
|
||||
|
||||
- **The wart was the silence, not the floor.** `ffmpeg.qsv-extra-hw-frames-floor` recorded, as an
|
||||
accepted residual, that a client which `PUT`s `0` gets a `200` and then reads back `64`. The floor
|
||||
itself was right — `extra_hw_frames=0` is a channel that serves nothing at all — but an accepted
|
||||
value that is stored as a different one leaves the config no longer describing the behavior, and
|
||||
the OpenAPI document never advertised the substitution. A rejection with a reason teaches the
|
||||
bound; a rewrite hides it. The floor is now the *second* line rather than the first.
|
||||
|
||||
- **Both halves are needed, and each has its own failure.** Validation alone would leave every row
|
||||
written before it existed unbounded at render time; the render clamp alone is what produced the
|
||||
silent transform in the first place. So: validate on write, clamp on render, and test both paths.
|
||||
The clamp is also the reason no backfill migration is required, which was the deliberate #529
|
||||
trade-off and still holds.
|
||||
|
||||
- **An unchanged legacy value is accepted — deliberately.** The obvious strict rule ("reject any
|
||||
out-of-range value") makes an old profile uneditable: the SPA round-trips the whole profile, so
|
||||
renaming a pre-#529 profile would 422 over `qsvExtraHardwareFrames`, a field the editor only
|
||||
renders when hardware acceleration is QSV. The validator therefore compares against the stored
|
||||
value and rejects only a change. The cost is a contract that is not purely a function of the
|
||||
request — the same body can be accepted or rejected depending on stored state — and that is
|
||||
stated in the OpenAPI description rather than left to be discovered.
|
||||
|
||||
- **The pacing values were unreachable, and that cost a whole diagnosis.** During #726 the only way
|
||||
to try a different `-readrate` was to rebuild the app; the diagnosis had to be done by replaying
|
||||
command lines by hand on the Docker host. They are now `ReadRate` / `ReadRateCatchup` on the
|
||||
profile. The defaults are unchanged and live on `FFmpegState` beside the bounds, so "what does an
|
||||
unset profile do" is answerable in one place.
|
||||
|
||||
- **Bounded, not free-form, and the ceilings are chosen rather than measured.** The floors are
|
||||
evidence-backed: below realtime the process reads slower than a live client consumes and the
|
||||
channel stalls, which is #726 measured at an effective 0.53x, and a catchup below the base rate
|
||||
cannot let a lagging input recover at all. The ceilings (2.0 and 10.0) are *chosen* — they exist so
|
||||
the field cannot be used to effectively disable pacing, which is the unthrottled-read condition
|
||||
#529 measured to produce zero segments on a QSV pipeline. Say so rather than implying a cliff was
|
||||
found there.
|
||||
|
||||
- **What is deliberately NOT exposed.** `-readrate_initial_burst` stays derived from the HLS segment
|
||||
length (`OutputFormatHls.SegmentSeconds * 2`) rather than becoming a third field: it is a function
|
||||
of the segmenter's own configuration, not an independent dial. The concat/wrap-segmenter wrapper's
|
||||
bare `-readrate 1.0` also stays hardcoded: it reads ErsatzTV's own loopback output rather than a
|
||||
media file's demuxer, so it is not the input #726 was about. (`FFmpegState.Concat` takes no
|
||||
profile, but that is a consequence of the decision, not the reason for it — both call sites have
|
||||
`channel.FFmpegProfile` in scope and could pass it.) And a raw-args passthrough remains out of
|
||||
scope here (#736 tracks it as a separate advanced-gated feature with its own safety posture):
|
||||
these are known-dangerous knobs, which is exactly the case for bounding and explaining them.
|
||||
|
||||
- **Two residuals, stated rather than implied.** (1) `FFmpegProfileRepository.Copy` clones every
|
||||
current value (`CurrentValues.Clone()`), including these fields, and `CopyFFmpegProfileHandler`
|
||||
validates only the name — so it can propagate a legacy out-of-range value into a new row. That is
|
||||
consistent with the unchanged-value exemption (Copy takes no operator-supplied number, so it can
|
||||
never *introduce* one), and the command has no route today: no controller action, no MCP tool, and
|
||||
the SPA's copy goes through `POST` to the validated create handler. If Copy is ever given a route,
|
||||
route it through `FFmpegProfileBounds` first. (2) The SPA's mirrored bounds are hand-written
|
||||
literals in `ffmpegProfileDraft.ts` with nothing pinning them to `FFmpegState`; raising a server
|
||||
bound leaves every test green while the form keeps enforcing the old one. Left unpinned on
|
||||
purpose: a C#-constant-versus-TS-literal guard is a string predicate over two languages, and the
|
||||
drift it would catch is a needlessly strict form, not a bad value reaching FFmpeg. Mirror the
|
||||
value AND the wording instead, and re-read this residual when a bound moves.
|
||||
|
||||
- **The SPA's numeric bound is checked whatever the acceleration is, matching the server.** The
|
||||
editor only *renders* `qsvExtraHardwareFrames` under QSV, but `validate()` does not gate on that:
|
||||
copying a legacy QSV profile and switching acceleration to None would otherwise submit a draft the
|
||||
create handler rejects over a field the form is no longer showing. A conditionally-rendered field
|
||||
still needs its unconditional check.
|
||||
@@ -68,3 +68,11 @@ stored value moves too (harmless — only the QSV path ever reads it — but it
|
||||
on a field the user didn't touch); and a machine client that `PUT`s `0` gets a `200` and then reads
|
||||
back `64`, which is a silent transform of a submitted value that the OpenAPI description does not
|
||||
advertise.
|
||||
|
||||
**The residual's write-path half was closed by #735** (`api.ffmpeg-profile-numeric-bounds`): the
|
||||
create/update handlers no longer normalize on save — a newly submitted value below the floor is
|
||||
rejected with a 422 naming the bound, and the schema documents it. The render-time floor described
|
||||
above is unchanged and still authoritative, because it is what covers rows written before that
|
||||
validation existed (an UNCHANGED legacy value is still accepted on update, precisely so an old
|
||||
profile stays editable). So the accepted residual now reads: a *stored* `0` still displays as `0`
|
||||
while FFmpeg receives 64 — but no new write can create one.
|
||||
|
||||
+7
-2
@@ -298,7 +298,7 @@ reused dir fails that spec by design.
|
||||
|
||||
**What it covers, and the rule for extending it.** Only assert here what curl *structurally cannot* —
|
||||
the curl harness above already covers the auth HTTP contracts, so re-asserting them through a browser
|
||||
buys nothing but flake surface. The four things that qualify:
|
||||
buys nothing but flake surface. What qualifies:
|
||||
|
||||
| Contract | Why curl can't reach it |
|
||||
| --- | --- |
|
||||
@@ -306,6 +306,7 @@ buys nothing but flake surface. The four things that qualify:
|
||||
| `AuthGate` states (Setup vs Login vs app) | Assertion is about what *renders*, not a status code |
|
||||
| Session cookie on the SPA's own `/api` XHRs | curl proves the cookie works *for curl*, not that the app sends it |
|
||||
| Sign-out via the `UserMenu` | A DOM interaction chain, not a single endpoint |
|
||||
| A client-side field bound that gates the Save button (`ffmpeg-pacing.spec.ts`, #735) | The rejected draft never leaves the browser — the screen renders the message and disables Save, so there is no request for curl to observe. The server's own 422 for the same input IS curl-reachable and belongs there, not here |
|
||||
|
||||
**Determinism rules** (#445 asked for deterministic flows, so these are deliberate):
|
||||
- `retries: 0`, in CI too — a retry would let a genuinely flaky flow merge looking green.
|
||||
@@ -315,7 +316,11 @@ buys nothing but flake surface. The four things that qualify:
|
||||
must stay inside one `test`.**
|
||||
- Address form fields by their unique **placeholders**, not labels: the shared `<Input>` wraps its
|
||||
`<input>` in a `<label>`, so a field's accessible name absorbs its error text when invalid
|
||||
(`"Confirm password Passwords do not match."`).
|
||||
(`"Confirm password Passwords do not match."`). A **settings-row** field is the other way round —
|
||||
`Row`'s label is a plain `<div>`, not bound to the control, so such an input has no accessible name
|
||||
at all until the screen passes `<Input ariaLabel>`. Pass it when you add a settings field you intend
|
||||
to reach by role (`FFmpegProfilesScreen`'s pacing fields do); do not fall back to positional
|
||||
selectors.
|
||||
- A fresh config is **not** empty — `DbInitializer` seeds one default channel (`"ErsatzTV"`, number 1),
|
||||
so the Channels *empty state* is unreachable there; assert the seeded row instead.
|
||||
|
||||
|
||||
@@ -1083,4 +1083,4 @@ if you change the offset, the placement logic or `.ctv-card`'s overflow.
|
||||
|
||||
Backfilling every screen is deliberately **not** this convention's job — adopt it where a field's
|
||||
consequences are severe and non-obvious, which is what makes the icon meaningful rather than
|
||||
decorative. `FFmpegProfilesScreen.tsx` is the reference implementation (nine fields).
|
||||
decorative. `FFmpegProfilesScreen.tsx` is the reference implementation (eleven fields).
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
import { PASSWORD, USERNAME } from './credentials';
|
||||
|
||||
// UI-interactive boot-gate flows (ersatztv#445), deferred from #363 because they cannot be expressed
|
||||
// as curl calls. `scripts/e2e-functional.sh` already asserts the auth *HTTP* contracts (setup-claim
|
||||
@@ -22,8 +23,6 @@ import { expect, test, type Page } from '@playwright/test';
|
||||
// tests 2 and 3 a genuinely signed-out browser without a logout dance. Anything that depends on
|
||||
// holding a session across steps must therefore stay INSIDE one test.
|
||||
|
||||
const USERNAME = 'e2eadmin';
|
||||
const PASSWORD = 'e2e-Passw0rd!';
|
||||
|
||||
// The Setup/Login cards use the shared <Input>, which wraps its <input> in a <label> — so the
|
||||
// accessible name picks up the error text too when a field is invalid ("Confirm password Passwords
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
// The local-admin credentials every UI-E2E spec uses. Shared rather than duplicated per spec: the
|
||||
// specs run serially against ONE server, so the claim in boot-gate.spec.ts is what every later spec
|
||||
// signs in with — two copies drift the moment one is changed and the break is silent.
|
||||
export const USERNAME = 'e2eadmin';
|
||||
export const PASSWORD = 'e2e-Passw0rd!';
|
||||
@@ -0,0 +1,52 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
import { PASSWORD, USERNAME } from './credentials';
|
||||
|
||||
|
||||
async function claimAndSignIn(page: Page) {
|
||||
await page.goto('/app');
|
||||
const setupUser = page.getByPlaceholder('Choose a username');
|
||||
if (await setupUser.isVisible().catch(() => false)) {
|
||||
await setupUser.fill(USERNAME);
|
||||
await page.getByPlaceholder('Choose a password').fill(PASSWORD);
|
||||
await page.getByPlaceholder('Re-enter the password').fill(PASSWORD);
|
||||
} else {
|
||||
await page.getByPlaceholder('Username', { exact: true }).fill(USERNAME);
|
||||
await page.getByPlaceholder('Password', { exact: true }).fill(PASSWORD);
|
||||
}
|
||||
await page.getByRole('button', { name: /Create|Sign in/ }).first().click();
|
||||
await expect(page.getByRole('button', { name: new RegExp(USERNAME) })).toBeVisible({ timeout: 15000 });
|
||||
}
|
||||
|
||||
test('the readrate pacing fields render, validate and save', async ({ page }) => {
|
||||
await claimAndSignIn(page);
|
||||
await page.goto('/app/ffmpeg-profiles/1');
|
||||
|
||||
const readRate = page.getByLabel('Read rate', { exact: true });
|
||||
const catchup = page.getByLabel('Read rate catchup', { exact: true });
|
||||
|
||||
await expect(readRate).toBeVisible({ timeout: 15000 });
|
||||
await expect(catchup).toBeVisible();
|
||||
|
||||
// unset renders as an empty box showing the built-in default as a placeholder
|
||||
await expect(readRate).toHaveValue('');
|
||||
await expect(readRate).toHaveAttribute('placeholder', '1.05');
|
||||
await expect(catchup).toHaveAttribute('placeholder', '6.0');
|
||||
|
||||
// the cross-field bound is enforced before the request leaves the browser: the screen surfaces
|
||||
// it as a badge and disables Save
|
||||
const save = page.getByRole('button', { name: 'Save profile' });
|
||||
await catchup.fill('1');
|
||||
await expect(page.getByText('Read rate catchup must be greater than the read rate (1.05)')).toBeVisible();
|
||||
await expect(save).toBeDisabled();
|
||||
|
||||
// a valid pair saves and survives a reload through the read path
|
||||
await readRate.fill('1.2');
|
||||
await catchup.fill('4');
|
||||
await expect(save).toBeEnabled();
|
||||
await save.click();
|
||||
// saving returns to the list, so re-enter the editor: that reload is the READ path
|
||||
await expect(page.getByRole('button', { name: 'Save profile' })).toHaveCount(0, { timeout: 15000 });
|
||||
await page.goto('/app/ffmpeg-profiles/1');
|
||||
await expect(page.getByLabel('Read rate', { exact: true })).toHaveValue('1.2', { timeout: 15000 });
|
||||
await expect(page.getByLabel('Read rate catchup', { exact: true })).toHaveValue('4');
|
||||
});
|
||||
@@ -39,6 +39,8 @@ const sampleRequest: Complete<CreateFFmpegProfileRequest> = {
|
||||
padMode: 'Software',
|
||||
qsvExtraHardwareFrames: null,
|
||||
qsvPreferNativeDecoder: true,
|
||||
readRate: null,
|
||||
readRateCatchup: null,
|
||||
resolutionId: 1,
|
||||
scalingBehavior: 'ScaleAndPad',
|
||||
tonemapAlgorithm: 'Linear',
|
||||
|
||||
Vendored
+12
@@ -477,6 +477,7 @@ export interface components {
|
||||
"vaapiDisplay": null | string;
|
||||
"vaapiDriver": components["schemas"]["VaapiDriver"];
|
||||
"vaapiDevice": null | string;
|
||||
/** Extra surfaces in the QSV upload pool. Must be at least 64 when set; a smaller pool leaves no headroom for frames in flight and the transcode writes nothing at all. On update, a value equal to the one already stored is accepted unchanged, so a profile written before this validation existed stays editable. */
|
||||
"qsvExtraHardwareFrames": null | number;
|
||||
"resolutionId": number;
|
||||
"scalingBehavior": components["schemas"]["ScalingBehavior"];
|
||||
@@ -500,6 +501,10 @@ export interface components {
|
||||
"normalizeColors": boolean;
|
||||
"deinterlaceVideo": boolean;
|
||||
"qsvPreferNativeDecoder"?: null | boolean;
|
||||
/** Realtime pacing multiplier for the input. Unset keeps the built-in pacing (1.05, or 1.0 for a stream copy). Must be between 1.0 and 2.0 when set. */
|
||||
"readRate"?: null | number;
|
||||
/** Rate a lagging realtime input may read at until it is level again. Unset keeps the built-in 6.0. Must be between 1.0 and 10.0, and GREATER than the read rate — equal is zero headroom, which is functionally no catchup. */
|
||||
"readRateCatchup"?: null | number;
|
||||
};
|
||||
"CreateFillerPresetRequest": {
|
||||
"name": string;
|
||||
@@ -720,6 +725,8 @@ export interface components {
|
||||
"normalizeColors": boolean;
|
||||
"deinterlaceVideo": boolean;
|
||||
"qsvPreferNativeDecoder": boolean;
|
||||
"readRate": null | number;
|
||||
"readRateCatchup": null | number;
|
||||
};
|
||||
"FFmpegProfileAudioFormat": "None" | "Aac" | "Ac3" | "AacLatm" | "Copy";
|
||||
"FFmpegProfileBitDepth": "EightBit" | "TenBit";
|
||||
@@ -1704,6 +1711,7 @@ export interface components {
|
||||
"vaapiDisplay": null | string;
|
||||
"vaapiDriver": components["schemas"]["VaapiDriver"];
|
||||
"vaapiDevice": null | string;
|
||||
/** Extra surfaces in the QSV upload pool. Must be at least 64 when set; a smaller pool leaves no headroom for frames in flight and the transcode writes nothing at all. On update, a value equal to the one already stored is accepted unchanged, so a profile written before this validation existed stays editable. */
|
||||
"qsvExtraHardwareFrames": null | number;
|
||||
"resolutionId": number;
|
||||
"scalingBehavior": components["schemas"]["ScalingBehavior"];
|
||||
@@ -1727,6 +1735,10 @@ export interface components {
|
||||
"normalizeColors": boolean;
|
||||
"deinterlaceVideo": boolean;
|
||||
"qsvPreferNativeDecoder"?: null | boolean;
|
||||
/** Realtime pacing multiplier for the input. Unset keeps the built-in pacing (1.05, or 1.0 for a stream copy). Must be between 1.0 and 2.0 when set. */
|
||||
"readRate"?: null | number;
|
||||
/** Rate a lagging realtime input may read at until it is level again. Unset keeps the built-in 6.0. Must be between 1.0 and 10.0, and GREATER than the read rate — equal is zero headroom, which is functionally no catchup. */
|
||||
"readRateCatchup"?: null | number;
|
||||
};
|
||||
"UpdateFFmpegSettingsRequest": {
|
||||
"fFmpegPath": null | string;
|
||||
|
||||
@@ -114,8 +114,11 @@ export interface InputProps {
|
||||
style?: CSSProperties;
|
||||
// Native <input> passthroughs — added for numeric fields (e.g. the multi-collection weight input,
|
||||
// #404): min/max bound the stepper, inputMode hints the mobile keyboard, onBlur normalizes on commit.
|
||||
// `step` matters for a decimal field: it defaults to 1, under which a fractional value fails the
|
||||
// browser's own constraint validation (the FFmpeg pacing fields, #735).
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
inputMode?: 'numeric' | 'decimal' | 'text';
|
||||
onBlur?: (e: FocusEvent<HTMLInputElement>) => void;
|
||||
ariaLabel?: string;
|
||||
@@ -136,6 +139,7 @@ export function Input({
|
||||
style,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
inputMode,
|
||||
onBlur,
|
||||
ariaLabel
|
||||
@@ -163,6 +167,7 @@ export function Input({
|
||||
disabled={disabled}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
inputMode={inputMode}
|
||||
aria-label={ariaLabel}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { Complete } from '../api/completeRequest';
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { ArrowLeft, Check, Copy, Plus, SlidersHorizontal, Trash2, TriangleAlert } from 'lucide-react';
|
||||
import { navigateToPath } from '../routing';
|
||||
@@ -15,6 +14,20 @@ import {
|
||||
Select,
|
||||
Spinner
|
||||
} from '../components';
|
||||
import {
|
||||
DEFAULT_READ_RATE,
|
||||
DEFAULT_READ_RATE_CATCHUP,
|
||||
MAXIMUM_READ_RATE,
|
||||
MAXIMUM_READ_RATE_CATCHUP,
|
||||
MINIMUM_QSV_EXTRA_HARDWARE_FRAMES,
|
||||
MINIMUM_READ_RATE,
|
||||
MINIMUM_READ_RATE_CATCHUP,
|
||||
bound,
|
||||
defaultDraft,
|
||||
draftFromProfile,
|
||||
validate,
|
||||
type Draft
|
||||
} from './ffmpegProfileDraft';
|
||||
import {
|
||||
createFFmpegProfile,
|
||||
deleteFFmpegProfile,
|
||||
@@ -24,7 +37,6 @@ import {
|
||||
getResolutions,
|
||||
messageFromFFmpegProfileError,
|
||||
updateFFmpegProfile,
|
||||
type CreateFFmpegProfileRequest,
|
||||
type FFmpegProfile,
|
||||
type HardwareAccelerationKind,
|
||||
type Resolution
|
||||
@@ -32,10 +44,6 @@ import {
|
||||
|
||||
const BASE_PATH = '/app/ffmpeg-profiles';
|
||||
|
||||
// mirrors FFmpegState.MinimumQsvExtraHardwareFrames — the server floors any smaller value, so the
|
||||
// form must not offer one it would silently override (ersatztv#529)
|
||||
const MINIMUM_QSV_EXTRA_HARDWARE_FRAMES = 64;
|
||||
|
||||
// Level-2 explainer copy for the progressive-disclosure pattern (#734). Colocated with the field
|
||||
// definitions it describes — see docs/spa-conventions.md §15. One short paragraph each; anything
|
||||
// longer belongs behind a `docsHref` once the external docs exist. Level 1 (the one-sentence
|
||||
@@ -52,7 +60,11 @@ const FIELD_HELP = {
|
||||
hardwareAcceleration:
|
||||
'Offloads decode and encode to the GPU. The list only offers what this FFmpeg build supports, so an unsupported kind never appears here. What nothing checks when you save is whether the device itself is present and passed through to the container — that is the mismatch that fails at playback time.',
|
||||
qsvExtraHardwareFrames:
|
||||
`Extra surfaces in the QSV upload pool. A pool of 0 is measured to fail outright — the channel serves nothing at all — while values between 1 and ${MINIMUM_QSV_EXTRA_HARDWARE_FRAMES - 1} are untested rather than known-bad. Rather than trust them, the server raises anything smaller to ${MINIMUM_QSV_EXTRA_HARDWARE_FRAMES} as it saves.`,
|
||||
`Extra surfaces in the QSV upload pool. A pool of 0 is measured to fail outright — the channel serves nothing at all — while values between 1 and ${MINIMUM_QSV_EXTRA_HARDWARE_FRAMES - 1} are untested rather than known-bad. Rather than trust them, the server rejects anything smaller than ${MINIMUM_QSV_EXTRA_HARDWARE_FRAMES} instead of quietly saving a different number than you typed.`,
|
||||
readRate:
|
||||
`How fast FFmpeg reads the input, as a multiple of realtime. Leave it empty to keep the built-in pacing (${bound(DEFAULT_READ_RATE)}, or ${bound(1.0)} for a stream copy), which is right for almost every channel. Below realtime the process falls behind what a live client consumes and the channel stalls; well above it the input is barely paced at all, which is the condition that starves a QSV upload pool.`,
|
||||
readRateCatchup:
|
||||
`The faster rate a lagging input may read at until it is level again — a ceiling that applies only while behind, never a target, so a caught-up input still paces at the read rate. Leave it empty for the built-in ${bound(DEFAULT_READ_RATE_CATCHUP)}. It must be greater than the read rate — equal is zero headroom, which is functionally no catchup at all.`,
|
||||
qsvPreferNativeDecoder:
|
||||
'Splits the pipeline: VA-API decodes, QSV encodes. The VA-API decoder tolerates imperfect streams that the QSV decoder rejects outright, and it is required for Dolby Vision, so this is the recommended setting on Intel.',
|
||||
normalizeLoudnessMode:
|
||||
@@ -63,12 +75,6 @@ const FIELD_HELP = {
|
||||
// types as `string`, so a mistyped key renders a trigger with an empty panel and no build error.
|
||||
} as const;
|
||||
|
||||
// `Complete<…>` so the draft must name every request member: the edit path PUTs this whole
|
||||
// object to a full-replace endpoint, where an unset member is written as its default rather
|
||||
// than left alone. `qsvPreferNativeDecoder` is optional in the schema and both draft builders
|
||||
// happened to set it; nothing required them to (#807).
|
||||
type Draft = Complete<CreateFFmpegProfileRequest>;
|
||||
|
||||
/* ---------- enum option lists (mirror ErsatzTV/Pages/FFmpegEditor.razor) ---------- */
|
||||
|
||||
const SCALING_OPTIONS = [
|
||||
@@ -170,156 +176,6 @@ function hwaccelSupportsH264Profile(hwaccel: HardwareAccelerationKind): boolean
|
||||
);
|
||||
}
|
||||
|
||||
const ALLOWED_FORMATS: Partial<Record<HardwareAccelerationKind, string[]>> = {
|
||||
Amf: ['H264', 'Hevc'],
|
||||
Nvenc: ['H264', 'Hevc', 'Av1'],
|
||||
Qsv: ['H264', 'Hevc', 'Mpeg2Video', 'Av1'],
|
||||
Rkmpp: ['H264', 'Hevc'],
|
||||
V4l2m2m: ['H264', 'Hevc'],
|
||||
Vaapi: ['H264', 'Hevc', 'Mpeg2Video', 'Av1'],
|
||||
VideoToolbox: ['H264', 'Hevc']
|
||||
};
|
||||
|
||||
// Mirrors FFmpegProfileEditViewModelValidator. Returns the first blocking message, or null.
|
||||
function validate(draft: Draft): null | string {
|
||||
if (!draft.name?.trim()) {
|
||||
return 'Name is required';
|
||||
}
|
||||
|
||||
if (draft.threadCount < 0) {
|
||||
return 'Thread count must be 0 or greater';
|
||||
}
|
||||
|
||||
if (draft.normalizeVideo) {
|
||||
if (draft.videoBitrate <= 0) {
|
||||
return 'Video bitrate must be greater than 0';
|
||||
}
|
||||
|
||||
if (draft.videoBufferSize <= 0) {
|
||||
return 'Video buffer size must be greater than 0';
|
||||
}
|
||||
|
||||
const allowed = ALLOWED_FORMATS[draft.hardwareAcceleration];
|
||||
if (allowed && !allowed.includes(draft.videoFormat)) {
|
||||
return `${draft.hardwareAcceleration} supports formats (${allowed.join(', ').toLowerCase()})`;
|
||||
}
|
||||
|
||||
if (draft.videoFormat === 'Mpeg2Video' && draft.bitDepth === 'TenBit') {
|
||||
return 'Mpeg2Video does not support 10-bit content';
|
||||
}
|
||||
|
||||
if (draft.videoFormat === 'H264' && draft.bitDepth === 'TenBit') {
|
||||
if (draft.hardwareAcceleration !== 'Nvenc' && draft.videoProfile !== 'high10') {
|
||||
return 'VideoProfile must be high10 with 10-bit h264';
|
||||
}
|
||||
|
||||
if (draft.hardwareAcceleration === 'Nvenc' && draft.videoProfile !== 'high444p') {
|
||||
return 'VideoProfile must be high444p with NVIDIA 10-bit h264';
|
||||
}
|
||||
}
|
||||
|
||||
if (draft.videoFormat === 'H264' && draft.bitDepth === 'EightBit' && draft.videoProfile === 'high10') {
|
||||
return 'VideoProfile cannot be high10 with 8-bit h264';
|
||||
}
|
||||
}
|
||||
|
||||
if (draft.normalizeAudio) {
|
||||
if (draft.audioBitrate <= 0) {
|
||||
return 'Audio bitrate must be greater than 0';
|
||||
}
|
||||
|
||||
if (draft.audioChannels <= 0) {
|
||||
return 'Audio channels must be greater than 0';
|
||||
}
|
||||
}
|
||||
|
||||
// the `min` on the input only bounds the spinner arrows — a typed value still submits, and the
|
||||
// server would floor it silently with a 200 (ersatztv#529)
|
||||
if (
|
||||
draft.hardwareAcceleration === 'Qsv' &&
|
||||
draft.qsvExtraHardwareFrames != null &&
|
||||
draft.qsvExtraHardwareFrames < MINIMUM_QSV_EXTRA_HARDWARE_FRAMES
|
||||
) {
|
||||
return `QSV extra hardware frames must be at least ${MINIMUM_QSV_EXTRA_HARDWARE_FRAMES}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function defaultDraft(resolutionId: number): Draft {
|
||||
return {
|
||||
allowBFrames: false,
|
||||
audioBitrate: 192,
|
||||
audioBufferSize: 384,
|
||||
audioChannels: 2,
|
||||
audioFormat: 'Aac',
|
||||
audioSampleRate: 48,
|
||||
bitDepth: 'EightBit',
|
||||
deinterlaceVideo: true,
|
||||
hardwareAcceleration: 'None',
|
||||
name: '',
|
||||
normalizeAudio: true,
|
||||
normalizeColors: true,
|
||||
normalizeFramerate: false,
|
||||
normalizeLoudnessMode: 'Off',
|
||||
normalizeVideo: true,
|
||||
padMode: 'Software',
|
||||
qsvExtraHardwareFrames: MINIMUM_QSV_EXTRA_HARDWARE_FRAMES,
|
||||
qsvPreferNativeDecoder: true,
|
||||
resolutionId,
|
||||
scalingBehavior: 'ScaleAndPad',
|
||||
targetLoudness: null,
|
||||
threadCount: 0,
|
||||
tonemapAlgorithm: 'Linear',
|
||||
vaapiDevice: '/dev/dri/renderD128',
|
||||
vaapiDisplay: 'drm',
|
||||
vaapiDriver: 'Default',
|
||||
videoBitrate: 2000,
|
||||
videoBufferSize: 4000,
|
||||
videoFormat: 'H264',
|
||||
videoPreset: '',
|
||||
videoProfile: 'high'
|
||||
};
|
||||
}
|
||||
|
||||
// A full profile response carries every request field (round-trip complete) plus id +
|
||||
// resolution name; drop those two to get the mutation body.
|
||||
function draftFromProfile(profile: FFmpegProfile): Draft {
|
||||
return {
|
||||
allowBFrames: profile.allowBFrames,
|
||||
audioBitrate: profile.audioBitrate,
|
||||
audioBufferSize: profile.audioBufferSize,
|
||||
audioChannels: profile.audioChannels,
|
||||
audioFormat: profile.audioFormat,
|
||||
audioSampleRate: profile.audioSampleRate,
|
||||
bitDepth: profile.bitDepth,
|
||||
deinterlaceVideo: profile.deinterlaceVideo,
|
||||
hardwareAcceleration: profile.hardwareAcceleration,
|
||||
name: profile.name,
|
||||
normalizeAudio: profile.normalizeAudio,
|
||||
normalizeColors: profile.normalizeColors,
|
||||
normalizeFramerate: profile.normalizeFramerate,
|
||||
normalizeLoudnessMode: profile.normalizeLoudnessMode,
|
||||
normalizeVideo: profile.normalizeVideo,
|
||||
padMode: profile.padMode,
|
||||
qsvExtraHardwareFrames: profile.qsvExtraHardwareFrames,
|
||||
qsvPreferNativeDecoder: profile.qsvPreferNativeDecoder,
|
||||
resolutionId: profile.resolutionId,
|
||||
scalingBehavior: profile.scalingBehavior,
|
||||
targetLoudness: profile.targetLoudness,
|
||||
threadCount: profile.threadCount,
|
||||
tonemapAlgorithm: profile.tonemapAlgorithm,
|
||||
vaapiDevice: profile.vaapiDevice,
|
||||
vaapiDisplay: profile.vaapiDisplay,
|
||||
vaapiDriver: profile.vaapiDriver,
|
||||
videoBitrate: profile.videoBitrate,
|
||||
videoBufferSize: profile.videoBufferSize,
|
||||
videoFormat: profile.videoFormat,
|
||||
videoPreset: profile.videoPreset,
|
||||
videoProfile: profile.videoProfile
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------- form row helper (mirrors ChannelEditScreen) ---------- */
|
||||
|
||||
function Row({
|
||||
@@ -381,6 +237,40 @@ function NumberField({
|
||||
);
|
||||
}
|
||||
|
||||
// a nullable numeric field: an empty box means "unset", which is distinct from any number the
|
||||
// operator could type, so it cannot reuse NumberField's `?? 0` coercion
|
||||
function OptionalNumberField({
|
||||
ariaLabel,
|
||||
max,
|
||||
min,
|
||||
onChange,
|
||||
placeholder,
|
||||
step,
|
||||
value
|
||||
}: {
|
||||
ariaLabel: string;
|
||||
max?: number;
|
||||
min?: number;
|
||||
onChange: (next: null | number) => void;
|
||||
placeholder?: string;
|
||||
step?: number;
|
||||
value: null | number | undefined;
|
||||
}) {
|
||||
return (
|
||||
<Input
|
||||
ariaLabel={ariaLabel}
|
||||
max={max}
|
||||
min={min}
|
||||
onChange={(event) => onChange(event.target.value === '' ? null : Number(event.target.value))}
|
||||
placeholder={placeholder}
|
||||
size="sm"
|
||||
step={step}
|
||||
type="number"
|
||||
value={value == null ? '' : String(value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- routing ---------- */
|
||||
|
||||
type Mode = { kind: 'add'; from: null | number } | { kind: 'edit'; id: number } | { kind: 'list' };
|
||||
@@ -579,6 +469,8 @@ function ProfileEditor({ mode }: { mode: { kind: 'add'; from: null | number } |
|
||||
const [resolutions, setResolutions] = useState<Resolution[]>([]);
|
||||
const [hwaccelKinds, setHwaccelKinds] = useState<HardwareAccelerationKind[]>(['None']);
|
||||
const [draft, setDraft] = useState<Draft | null>(null);
|
||||
// the profile as last loaded — see validate()'s `stored` parameter (ersatztv#735)
|
||||
const [storedDraft, setStoredDraft] = useState<Draft | null>(null);
|
||||
const [loadError, setLoadError] = useState<null | string>(null);
|
||||
const [saveError, setSaveError] = useState<null | string>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -598,8 +490,12 @@ function ProfileEditor({ mode }: { mode: { kind: 'add'; from: null | number } |
|
||||
const [resolutionList, kinds] = await Promise.all([getResolutions(), getHardwareAccelerationKinds()]);
|
||||
|
||||
let nextDraft: Draft;
|
||||
// only the EDIT path carries a stored draft into validate(): a copy is POSTed to the create
|
||||
// handler, where the server passes `stored: null` and is strict, so the editor must be too
|
||||
let nextStored: Draft | null = null;
|
||||
if (loadEditId != null) {
|
||||
nextDraft = draftFromProfile(await getFFmpegProfile(loadEditId));
|
||||
nextStored = nextDraft;
|
||||
} else if (loadCopyFrom != null) {
|
||||
const source = await getFFmpegProfile(loadCopyFrom);
|
||||
nextDraft = { ...draftFromProfile(source), name: `${source.name} (copy)` };
|
||||
@@ -614,6 +510,7 @@ function ProfileEditor({ mode }: { mode: { kind: 'add'; from: null | number } |
|
||||
if (active) {
|
||||
setResolutions(resolutionList);
|
||||
setHwaccelKinds(kinds);
|
||||
setStoredDraft(nextStored);
|
||||
setDraft(nextDraft);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -655,7 +552,7 @@ function ProfileEditor({ mode }: { mode: { kind: 'add'; from: null | number } |
|
||||
setDraft((current) => (current ? { ...current, ...patch } : current));
|
||||
};
|
||||
|
||||
const validationError = validate(draft);
|
||||
const validationError = validate(draft, storedDraft);
|
||||
const presets = presetsFor(draft.hardwareAcceleration, draft.videoFormat, draft.bitDepth);
|
||||
|
||||
const save = async () => {
|
||||
@@ -726,6 +623,38 @@ function ProfileEditor({ mode }: { mode: { kind: 'add'; from: null | number } |
|
||||
<Row control={200} detail={FIELD_HELP.threadCount} help="0 lets FFmpeg pick a thread count." label="Thread count">
|
||||
<NumberField onChange={(threadCount) => set({ threadCount })} value={draft.threadCount} />
|
||||
</Row>
|
||||
<Row
|
||||
control={200}
|
||||
detail={FIELD_HELP.readRate}
|
||||
help={`Empty keeps the built-in pacing (${bound(DEFAULT_READ_RATE)}, or ${bound(1.0)} for a stream copy).`}
|
||||
label="Read rate"
|
||||
>
|
||||
<OptionalNumberField
|
||||
ariaLabel="Read rate"
|
||||
max={MAXIMUM_READ_RATE}
|
||||
min={MINIMUM_READ_RATE}
|
||||
onChange={(readRate) => set({ readRate })}
|
||||
placeholder={bound(DEFAULT_READ_RATE)}
|
||||
step={0.05}
|
||||
value={draft.readRate}
|
||||
/>
|
||||
</Row>
|
||||
<Row
|
||||
control={200}
|
||||
detail={FIELD_HELP.readRateCatchup}
|
||||
help={`Empty keeps the built-in ${bound(DEFAULT_READ_RATE_CATCHUP)}. Only applies while an input is behind.`}
|
||||
label="Read rate catchup"
|
||||
>
|
||||
<OptionalNumberField
|
||||
ariaLabel="Read rate catchup"
|
||||
max={MAXIMUM_READ_RATE_CATCHUP}
|
||||
min={MINIMUM_READ_RATE_CATCHUP}
|
||||
onChange={(readRateCatchup) => set({ readRateCatchup })}
|
||||
placeholder={bound(DEFAULT_READ_RATE_CATCHUP)}
|
||||
step={0.5}
|
||||
value={draft.readRateCatchup}
|
||||
/>
|
||||
</Row>
|
||||
<Row control={200} label="Normalize audio">
|
||||
<Checkbox checked={draft.normalizeAudio} onChange={(normalizeAudio) => set({ normalizeAudio })} />
|
||||
</Row>
|
||||
@@ -840,11 +769,11 @@ function ProfileEditor({ mode }: { mode: { kind: 'add'; from: null | number } |
|
||||
<Row
|
||||
control={200}
|
||||
detail={FIELD_HELP.qsvExtraHardwareFrames}
|
||||
help={`Values below ${MINIMUM_QSV_EXTRA_HARDWARE_FRAMES} are raised to ${MINIMUM_QSV_EXTRA_HARDWARE_FRAMES} by the server.`}
|
||||
help={`Values below ${MINIMUM_QSV_EXTRA_HARDWARE_FRAMES} are rejected by the server.`}
|
||||
label="QSV extra hardware frames"
|
||||
>
|
||||
{/* below this the QSV upload pool has too little headroom and transcoding fails on
|
||||
any unthrottled read; the server floors it anyway (ersatztv#529) */}
|
||||
any unthrottled read; the server rejects it outright (ersatztv#529, #735) */}
|
||||
<NumberField
|
||||
min={MINIMUM_QSV_EXTRA_HARDWARE_FRAMES}
|
||||
onChange={(qsvExtraHardwareFrames) => set({ qsvExtraHardwareFrames })}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { defaultDraft, validate, type Draft } from './ffmpegProfileDraft';
|
||||
|
||||
// ersatztv#735: the FFmpeg pacing fields are bounded, and the SPA names the bound before the round
|
||||
// trip. these mirror FFmpegProfileBounds on the server, which is the authority — a green here does
|
||||
// not prove the server agrees, it proves the form does not submit a draft the server would 422.
|
||||
describe('FFmpegProfilesScreen draft validation', () => {
|
||||
it('accepts the default draft, which leaves both pacing fields unset', () => {
|
||||
const draft = defaultDraft(1);
|
||||
|
||||
expect(draft.readRate).toBeNull();
|
||||
expect(draft.readRateCatchup).toBeNull();
|
||||
// defaultDraft has an empty name, which is its own (unrelated) validation failure
|
||||
expect(validate({ ...draft, name: 'x' })).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[0.9, 'Read rate must be between 1.0 and 2.0'],
|
||||
[2.5, 'Read rate must be between 1.0 and 2.0']
|
||||
])('rejects a read rate of %s', (readRate, message) => {
|
||||
expect(validate({ ...defaultDraft(1), name: 'x', readRate })).toBe(message);
|
||||
});
|
||||
|
||||
it.each([0.9, 10.5])('rejects a read rate catchup of %s', (readRateCatchup) => {
|
||||
expect(validate({ ...defaultDraft(1), name: 'x', readRateCatchup })).toBe(
|
||||
'Read rate catchup must be between 1.0 and 10.0'
|
||||
);
|
||||
});
|
||||
|
||||
// the cross-field bound: both values sit inside their own bands, and the pair is still invalid
|
||||
it('rejects a catchup below an explicit read rate', () => {
|
||||
expect(validate({ ...defaultDraft(1), name: 'x', readRate: 1.5, readRateCatchup: 1.2 })).toBe(
|
||||
'Read rate catchup must be greater than the read rate (1.5)'
|
||||
);
|
||||
});
|
||||
|
||||
// EQUAL is rejected too — zero headroom is functionally no catchup while reading as configured
|
||||
it('rejects a catchup equal to the read rate', () => {
|
||||
expect(validate({ ...defaultDraft(1), name: 'x', readRate: 1.5, readRateCatchup: 1.5 })).toBe(
|
||||
'Read rate catchup must be greater than the read rate (1.5)'
|
||||
);
|
||||
});
|
||||
|
||||
// ...and with the read rate left unset it is compared against the built-in 1.05, not against 1.0.
|
||||
// the message names that comparand, because an empty box shows it nowhere else on the screen
|
||||
it('rejects a catchup below the built-in read rate when the read rate is unset', () => {
|
||||
expect(validate({ ...defaultDraft(1), name: 'x', readRateCatchup: 1.0 })).toBe(
|
||||
'Read rate catchup must be greater than the read rate (1.05)'
|
||||
);
|
||||
});
|
||||
|
||||
// the SPA must mirror FFmpegProfileBounds' legacy exemption, or the editor blocks a save the
|
||||
// server would accept and a pre-#529 QSV profile becomes uneditable from the UI
|
||||
describe('the unchanged-legacy qsvExtraHardwareFrames exemption', () => {
|
||||
const legacy = (frames: number): Draft => ({
|
||||
...defaultDraft(1),
|
||||
hardwareAcceleration: 'Qsv',
|
||||
name: 'legacy',
|
||||
qsvExtraHardwareFrames: frames
|
||||
});
|
||||
|
||||
it('accepts an unchanged out-of-range stored value', () => {
|
||||
const stored = legacy(0);
|
||||
expect(validate({ ...stored, name: 'renamed' }, stored)).toBeNull();
|
||||
});
|
||||
|
||||
it('still rejects a NEWLY typed out-of-range value on the same profile', () => {
|
||||
expect(validate(legacy(32), legacy(0))).toBe('QSV extra hardware frames must be at least 64');
|
||||
});
|
||||
|
||||
it('is strict with no stored draft, which is the add/copy path', () => {
|
||||
expect(validate(legacy(0))).toBe('QSV extra hardware frames must be at least 64');
|
||||
});
|
||||
|
||||
// the server checks this field whatever the acceleration is, so the form must too — otherwise
|
||||
// copying a legacy QSV profile and switching acceleration to None submits a draft the POST
|
||||
// rejects over a field the editor is no longer rendering
|
||||
it('checks the bound even when hardware acceleration is not QSV', () => {
|
||||
expect(validate({ ...legacy(0), hardwareAcceleration: 'None' })).toBe(
|
||||
'QSV extra hardware frames must be at least 64'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts a valid pacing pair', () => {
|
||||
expect(validate({ ...defaultDraft(1), name: 'x', readRate: 1.2, readRateCatchup: 4 })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
import type { Complete } from '../api/completeRequest';
|
||||
import type { CreateFFmpegProfileRequest, FFmpegProfile, HardwareAccelerationKind } from '../api';
|
||||
|
||||
// mirrors FFmpegState.MinimumQsvExtraHardwareFrames — the server now REJECTS a smaller value with a
|
||||
// 422 rather than raising it, so the form must not offer one it would bounce (ersatztv#529, #735)
|
||||
export const MINIMUM_QSV_EXTRA_HARDWARE_FRAMES = 64;
|
||||
|
||||
// mirror FFmpegState's pacing bounds and defaults. leaving a field empty stores null, which is what
|
||||
// keeps an untouched profile pacing exactly as it did before these became configurable (#735)
|
||||
export const MINIMUM_READ_RATE = 1.0;
|
||||
export const MAXIMUM_READ_RATE = 2.0;
|
||||
export const DEFAULT_READ_RATE = 1.05;
|
||||
export const MINIMUM_READ_RATE_CATCHUP = 1.0;
|
||||
export const MAXIMUM_READ_RATE_CATCHUP = 10.0;
|
||||
export const DEFAULT_READ_RATE_CATCHUP = 6.0;
|
||||
|
||||
// `Complete<…>` so the draft must name every request member: the edit path PUTs this whole
|
||||
// object to a full-replace endpoint, where an unset member is written as its default rather
|
||||
// than left alone. `qsvPreferNativeDecoder` is optional in the schema and both draft builders
|
||||
// happened to set it; nothing required them to (#807).
|
||||
export type Draft = Complete<CreateFFmpegProfileRequest>;
|
||||
|
||||
const ALLOWED_FORMATS: Partial<Record<HardwareAccelerationKind, string[]>> = {
|
||||
Amf: ['H264', 'Hevc'],
|
||||
Nvenc: ['H264', 'Hevc', 'Av1'],
|
||||
Qsv: ['H264', 'Hevc', 'Mpeg2Video', 'Av1'],
|
||||
Rkmpp: ['H264', 'Hevc'],
|
||||
V4l2m2m: ['H264', 'Hevc'],
|
||||
Vaapi: ['H264', 'Hevc', 'Mpeg2Video', 'Av1'],
|
||||
VideoToolbox: ['H264', 'Hevc']
|
||||
};
|
||||
|
||||
// Format a bound the way the server's own 422 does, so the two surfaces do not disagree over the
|
||||
// same rejection ("1.0 and 2.0", not "1 and 2"). Exported because the screen's help copy and
|
||||
// placeholders name the same numbers and must not render them a second way.
|
||||
export function bound(value: number): string {
|
||||
return Number.isInteger(value) ? value.toFixed(1) : String(value);
|
||||
}
|
||||
|
||||
// Returns the first blocking message, or null. Mirrors the server-side checks: the name/thread/
|
||||
// bitrate rules in the create+update handlers, and `FFmpegProfileBounds` for the numeric bounds.
|
||||
//
|
||||
// `stored` is the profile as last loaded from the server, or null when adding. It exists for ONE
|
||||
// reason: `FFmpegProfileBounds` accepts an UNCHANGED out-of-range `qsvExtraHardwareFrames` so a row
|
||||
// written before that validation existed stays editable, and without the same exemption here the
|
||||
// editor would block the save the server would have accepted (ersatztv#735).
|
||||
export function validate(draft: Draft, stored: Draft | null = null): null | string {
|
||||
if (!draft.name?.trim()) {
|
||||
return 'Name is required';
|
||||
}
|
||||
|
||||
if (draft.threadCount < 0) {
|
||||
return 'Thread count must be 0 or greater';
|
||||
}
|
||||
|
||||
if (draft.normalizeVideo) {
|
||||
if (draft.videoBitrate <= 0) {
|
||||
return 'Video bitrate must be greater than 0';
|
||||
}
|
||||
|
||||
if (draft.videoBufferSize <= 0) {
|
||||
return 'Video buffer size must be greater than 0';
|
||||
}
|
||||
|
||||
const allowed = ALLOWED_FORMATS[draft.hardwareAcceleration];
|
||||
if (allowed && !allowed.includes(draft.videoFormat)) {
|
||||
return `${draft.hardwareAcceleration} supports formats (${allowed.join(', ').toLowerCase()})`;
|
||||
}
|
||||
|
||||
if (draft.videoFormat === 'Mpeg2Video' && draft.bitDepth === 'TenBit') {
|
||||
return 'Mpeg2Video does not support 10-bit content';
|
||||
}
|
||||
|
||||
if (draft.videoFormat === 'H264' && draft.bitDepth === 'TenBit') {
|
||||
if (draft.hardwareAcceleration !== 'Nvenc' && draft.videoProfile !== 'high10') {
|
||||
return 'VideoProfile must be high10 with 10-bit h264';
|
||||
}
|
||||
|
||||
if (draft.hardwareAcceleration === 'Nvenc' && draft.videoProfile !== 'high444p') {
|
||||
return 'VideoProfile must be high444p with NVIDIA 10-bit h264';
|
||||
}
|
||||
}
|
||||
|
||||
if (draft.videoFormat === 'H264' && draft.bitDepth === 'EightBit' && draft.videoProfile === 'high10') {
|
||||
return 'VideoProfile cannot be high10 with 8-bit h264';
|
||||
}
|
||||
}
|
||||
|
||||
if (draft.normalizeAudio) {
|
||||
if (draft.audioBitrate <= 0) {
|
||||
return 'Audio bitrate must be greater than 0';
|
||||
}
|
||||
|
||||
if (draft.audioChannels <= 0) {
|
||||
return 'Audio channels must be greater than 0';
|
||||
}
|
||||
}
|
||||
|
||||
// the `min` on the input only bounds the spinner arrows — a typed value still submits. the server
|
||||
// rejects it too (ersatztv#735); this just names the bound before the round trip
|
||||
// NOT gated on `hardwareAcceleration === 'Qsv'`: the server checks unconditionally, so gating
|
||||
// here would let a draft through that the POST then 422s over a field the editor only renders
|
||||
// under QSV (reachable by copying a legacy QSV profile and switching acceleration to None)
|
||||
if (
|
||||
draft.qsvExtraHardwareFrames != null &&
|
||||
draft.qsvExtraHardwareFrames < MINIMUM_QSV_EXTRA_HARDWARE_FRAMES &&
|
||||
draft.qsvExtraHardwareFrames !== stored?.qsvExtraHardwareFrames
|
||||
) {
|
||||
return `QSV extra hardware frames must be at least ${MINIMUM_QSV_EXTRA_HARDWARE_FRAMES}`;
|
||||
}
|
||||
|
||||
if (draft.readRate != null && (draft.readRate < MINIMUM_READ_RATE || draft.readRate > MAXIMUM_READ_RATE)) {
|
||||
return `Read rate must be between ${bound(MINIMUM_READ_RATE)} and ${bound(MAXIMUM_READ_RATE)}`;
|
||||
}
|
||||
|
||||
if (
|
||||
draft.readRateCatchup != null &&
|
||||
(draft.readRateCatchup < MINIMUM_READ_RATE_CATCHUP || draft.readRateCatchup > MAXIMUM_READ_RATE_CATCHUP)
|
||||
) {
|
||||
return `Read rate catchup must be between ${bound(MINIMUM_READ_RATE_CATCHUP)} and ${bound(
|
||||
MAXIMUM_READ_RATE_CATCHUP
|
||||
)}`;
|
||||
}
|
||||
|
||||
// mirrors the server's cross-field bound: catchup is compared against the transcode default when
|
||||
// the read rate is left unset, because that is the higher of the two built-in rates. EQUAL is
|
||||
// rejected too — zero headroom is functionally no catchup. the comparand is named in the message
|
||||
// because when the read-rate box is empty it appears nowhere else on the screen
|
||||
const effectiveReadRate = draft.readRate ?? DEFAULT_READ_RATE;
|
||||
if (draft.readRateCatchup != null && draft.readRateCatchup <= effectiveReadRate) {
|
||||
return `Read rate catchup must be greater than the read rate (${bound(effectiveReadRate)})`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function defaultDraft(resolutionId: number): Draft {
|
||||
return {
|
||||
allowBFrames: false,
|
||||
audioBitrate: 192,
|
||||
audioBufferSize: 384,
|
||||
audioChannels: 2,
|
||||
audioFormat: 'Aac',
|
||||
audioSampleRate: 48,
|
||||
bitDepth: 'EightBit',
|
||||
deinterlaceVideo: true,
|
||||
hardwareAcceleration: 'None',
|
||||
name: '',
|
||||
normalizeAudio: true,
|
||||
normalizeColors: true,
|
||||
normalizeFramerate: false,
|
||||
normalizeLoudnessMode: 'Off',
|
||||
normalizeVideo: true,
|
||||
padMode: 'Software',
|
||||
qsvExtraHardwareFrames: MINIMUM_QSV_EXTRA_HARDWARE_FRAMES,
|
||||
qsvPreferNativeDecoder: true,
|
||||
readRate: null,
|
||||
readRateCatchup: null,
|
||||
resolutionId,
|
||||
scalingBehavior: 'ScaleAndPad',
|
||||
targetLoudness: null,
|
||||
threadCount: 0,
|
||||
tonemapAlgorithm: 'Linear',
|
||||
vaapiDevice: '/dev/dri/renderD128',
|
||||
vaapiDisplay: 'drm',
|
||||
vaapiDriver: 'Default',
|
||||
videoBitrate: 2000,
|
||||
videoBufferSize: 4000,
|
||||
videoFormat: 'H264',
|
||||
videoPreset: '',
|
||||
videoProfile: 'high'
|
||||
};
|
||||
}
|
||||
|
||||
// A full profile response carries every request field (round-trip complete) plus id +
|
||||
// resolution name; drop those two to get the mutation body.
|
||||
export function draftFromProfile(profile: FFmpegProfile): Draft {
|
||||
return {
|
||||
allowBFrames: profile.allowBFrames,
|
||||
audioBitrate: profile.audioBitrate,
|
||||
audioBufferSize: profile.audioBufferSize,
|
||||
audioChannels: profile.audioChannels,
|
||||
audioFormat: profile.audioFormat,
|
||||
audioSampleRate: profile.audioSampleRate,
|
||||
bitDepth: profile.bitDepth,
|
||||
deinterlaceVideo: profile.deinterlaceVideo,
|
||||
hardwareAcceleration: profile.hardwareAcceleration,
|
||||
name: profile.name,
|
||||
normalizeAudio: profile.normalizeAudio,
|
||||
normalizeColors: profile.normalizeColors,
|
||||
normalizeFramerate: profile.normalizeFramerate,
|
||||
normalizeLoudnessMode: profile.normalizeLoudnessMode,
|
||||
normalizeVideo: profile.normalizeVideo,
|
||||
padMode: profile.padMode,
|
||||
qsvExtraHardwareFrames: profile.qsvExtraHardwareFrames,
|
||||
qsvPreferNativeDecoder: profile.qsvPreferNativeDecoder,
|
||||
readRate: profile.readRate,
|
||||
readRateCatchup: profile.readRateCatchup,
|
||||
resolutionId: profile.resolutionId,
|
||||
scalingBehavior: profile.scalingBehavior,
|
||||
targetLoudness: profile.targetLoudness,
|
||||
threadCount: profile.threadCount,
|
||||
tonemapAlgorithm: profile.tonemapAlgorithm,
|
||||
vaapiDevice: profile.vaapiDevice,
|
||||
vaapiDisplay: profile.vaapiDisplay,
|
||||
vaapiDriver: profile.vaapiDriver,
|
||||
videoBitrate: profile.videoBitrate,
|
||||
videoBufferSize: profile.videoBufferSize,
|
||||
videoFormat: profile.videoFormat,
|
||||
videoPreset: profile.videoPreset,
|
||||
videoProfile: profile.videoProfile
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user