Files
ersatztv/ErsatzTV/Controllers/Api/Requests/CreateFFmpegProfileRequest.cs
T
timothyandClaude Opus 5 5459e21e81
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 7s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 12s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 19s
review-verdict/h10 Review-verdict: MERGEABLE @ 5459e21 (base: main)
PR Gates / decisions lifecycle (pull_request) Successful in 18s
PR Gates / Docs update reminder (pull_request) Successful in 25s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 27s
Review verdict / Set review-verdict status (pull_request_target) Successful in 12s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 5m7s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m50s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m9s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 5m50s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 5m11s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 8s
feat(735): bound the numeric FFmpeg profile fields with a 422, and expose readrate pacing
The write path used to accept an out-of-range `qsvExtraHardwareFrames`, store the floored value
instead and return 200 — the accepted residual #529 recorded, where a client PUTs 0 and reads back
64. `FFmpegProfileBounds` now rejects a newly submitted out-of-range value with a 422 naming the
bound and its consequence; the render-time floor in `FFmpegState` stays, which is what keeps the
change migration-free for rows written before this existed. On update an UNCHANGED legacy value is
still accepted, because the SPA round-trips the whole profile and rejecting it would make an old
profile uneditable over a field the operator never touched and, outside QSV, cannot see.

The `-readrate` / `-readrate_catchup` pacing that `PipelineBuilderBase` hardcoded becomes two
nullable profile fields with the same bounded treatment. During #726 the only way to try a
different pacing value was to rebuild the app and replay command lines by hand on the Docker host.
`null` means unset and resolves to the values the pipeline used before, so an untouched profile
paces identically; catchup stays on by default and capability-gated in code.

The draft helpers moved out of `FFmpegProfilesScreen.tsx` into `ffmpegProfileDraft.ts` so the
bounds can be tested directly — a component module that exports non-components trips
react-refresh, and the baseline was warning-free.

Mutation-checked: ignoring the pacing override, disarming each bound, and removing the
unchanged-legacy exemption each redden their own tests.

fixes #735

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 23:06:09 +02:00

91 lines
3.0 KiB
C#

using System.ComponentModel;
using ErsatzTV.Application.FFmpegProfiles;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
namespace ErsatzTV.Controllers.Api.Requests;
public record CreateFFmpegProfileRequest(
string Name,
int ThreadCount,
bool NormalizeAudio,
bool NormalizeVideo,
HardwareAccelerationKind HardwareAcceleration,
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,
FilterMode PadMode,
FFmpegProfileVideoFormat VideoFormat,
string VideoProfile,
string VideoPreset,
bool AllowBFrames,
FFmpegProfileBitDepth BitDepth,
int VideoBitrate,
int VideoBufferSize,
FFmpegProfileTonemapAlgorithm TonemapAlgorithm,
FFmpegProfileAudioFormat AudioFormat,
int AudioBitrate,
int AudioBufferSize,
NormalizeLoudnessMode NormalizeLoudnessMode,
double? TargetLoudness,
int AudioChannels,
int AudioSampleRate,
bool NormalizeFramerate,
bool NormalizeColors,
bool DeinterlaceVideo,
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(
Name,
ThreadCount,
NormalizeAudio,
NormalizeVideo,
HardwareAcceleration,
VaapiDisplay,
VaapiDriver,
VaapiDevice,
QsvExtraHardwareFrames,
ResolutionId,
ScalingBehavior,
PadMode,
VideoFormat,
VideoProfile,
VideoPreset,
AllowBFrames,
BitDepth,
VideoBitrate,
VideoBufferSize,
TonemapAlgorithm,
AudioFormat,
AudioBitrate,
AudioBufferSize,
NormalizeLoudnessMode,
TargetLoudness,
AudioChannels,
AudioSampleRate,
NormalizeFramerate,
NormalizeColors,
DeinterlaceVideo,
QsvPreferNativeDecoder ?? true,
ReadRate,
ReadRateCatchup);
}