Merge pull request 'fix(529): floor QSV extra hardware frames so an unthrottled read can't exhaust the pool' (#530) from fix/529-qsv-extra-hw-frames-floor into main
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / CI image pin matches docker/ci (push) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions lifecycle (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 34s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 35s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 36s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled

This commit was merged in pull request #530.
This commit is contained in:
2026-07-21 14:41:15 +00:00
9 changed files with 273 additions and 31 deletions
@@ -2,6 +2,7 @@ using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.FFmpeg;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@@ -67,7 +68,11 @@ public class CreateFFmpegProfileHandler :
HardwareAcceleration = hwAccel,
VaapiDriver = request.VaapiDriver,
VaapiDevice = request.VaapiDevice,
QsvExtraHardwareFrames = request.QsvExtraHardwareFrames,
// 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,
ResolutionId = resolutionId,
ScalingBehavior = request.ScalingBehavior,
@@ -3,6 +3,7 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.FFmpeg;
using ErsatzTV.FFmpeg.Preset;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
@@ -54,7 +55,11 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFa
p.VaapiDisplay = update.VaapiDisplay;
p.VaapiDriver = update.VaapiDriver;
p.VaapiDevice = update.VaapiDevice;
p.QsvExtraHardwareFrames = update.QsvExtraHardwareFrames;
// 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;
p.ResolutionId = update.ResolutionId;
p.ScalingBehavior = update.ScalingBehavior;
p.PadMode = update.PadMode;
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using ErsatzTV.FFmpeg.Capabilities;
using ErsatzTV.FFmpeg.Format;
using ErsatzTV.FFmpeg.OutputFormat;
@@ -55,26 +56,7 @@ public class QsvPipelineBuilderTests
[Test]
public void Qsv_PreferNativeDecoder_Interlaced_Should_Hwupload_Before_Deinterlace_Qsv()
{
(VideoInputFile videoInputFile, AudioInputFile audioInputFile, FFmpegState ffmpegState, FrameState desiredState) =
BuildQsvH264Pipeline(preferNativeDecoder: true, scanKind: ScanKind.Interlaced, deinterlace: true);
var builder = new QsvPipelineBuilder(
new DefaultFFmpegCapabilities(),
new DefaultHardwareCapabilities(),
HardwareAccelerationMode.Qsv,
videoInputFile,
audioInputFile,
None,
None,
None,
Option<GraphicsEngineInput>.None,
"",
"",
_logger);
FFmpegPipeline result = builder.Build(ffmpegState, desiredState);
string command = PrintCommand(videoInputFile, audioInputFile, None, None, None, result);
string command = BuildInterlacedAndPrint();
// VA-API decode, software frames
command.ShouldContain("-hwaccel vaapi");
@@ -90,10 +72,73 @@ public class QsvPipelineBuilderTests
command.ShouldContain("h264_qsv");
}
private string BuildAndPrint(bool preferNativeDecoder)
// ersatztv#529: a stored qsvExtraHardwareFrames of 0 produced hwupload=extra_hw_frames=0, which
// leaves the QSV pool no headroom. Measured on the deployed FFmpeg 8.1.2: with 0 the filter graph
// fails with -12 and writes zero segments as soon as the input is not throttled (a work-ahead
// start, or #350's cold-start burst); with 64 the same command writes segments either way.
[TestCase(-1)]
[TestCase(0)]
[TestCase(1)]
[TestCase(63)]
public void Qsv_Should_Never_Upload_With_Less_Than_Minimum_Extra_Hardware_Frames(int configured)
{
string command = BuildAndPrint(preferNativeDecoder: true, maybeExtraHardwareFrames: configured);
// pinned to the literal value measured against the deployed FFmpeg, not to the constant, so
// that lowering the floor in code cannot quietly satisfy this test
command.ShouldContain("hwupload=extra_hw_frames=64");
// every upload site in the whole command, not just the one this pipeline happens to emit
ShouldNeverUploadBelowMinimum(command);
}
[Test]
public void Qsv_Interlaced_Should_Never_Deinterlace_Upload_Below_Minimum_Extra_Hardware_Frames()
{
// the deinterlace upload is a separate formatter fed from QsvPipelineBuilder, so it needs
// its own guard (ersatztv#529)
string command = BuildInterlacedAndPrint(maybeExtraHardwareFrames: 0);
command.ShouldContain("hwupload=extra_hw_frames=64,deinterlace_qsv");
ShouldNeverUploadBelowMinimum(command);
}
private static void ShouldNeverUploadBelowMinimum(string command)
{
MatchCollection matches = Regex.Matches(command, @"extra_hw_frames=(-?\d+)");
matches.Count.ShouldBeGreaterThan(0, "expected the pipeline to upload to QSV at all");
foreach (Match match in matches)
{
int.Parse(match.Groups[1].Value)
.ShouldBeGreaterThanOrEqualTo(FFmpegState.MinimumQsvExtraHardwareFrames, command);
}
}
[Test]
public void Qsv_Should_Honor_Extra_Hardware_Frames_Above_The_Minimum()
{
string command = BuildAndPrint(preferNativeDecoder: true, maybeExtraHardwareFrames: 128);
command.ShouldContain("hwupload=extra_hw_frames=128");
}
private string BuildInterlacedAndPrint(Option<int> maybeExtraHardwareFrames = default) =>
BuildAndPrint(
preferNativeDecoder: true,
maybeExtraHardwareFrames,
ScanKind.Interlaced,
deinterlace: true);
private string BuildAndPrint(
bool preferNativeDecoder,
Option<int> maybeExtraHardwareFrames = default,
ScanKind scanKind = ScanKind.Progressive,
bool deinterlace = false)
{
(VideoInputFile videoInputFile, AudioInputFile audioInputFile, FFmpegState ffmpegState, FrameState desiredState) =
BuildQsvH264Pipeline(preferNativeDecoder, ScanKind.Progressive, false);
BuildQsvH264Pipeline(preferNativeDecoder, scanKind, deinterlace);
ffmpegState = ffmpegState with { MaybeQsvExtraHardwareFrames = maybeExtraHardwareFrames };
var builder = new QsvPipelineBuilder(
new DefaultFFmpegCapabilities(),
+11 -1
View File
@@ -30,7 +30,17 @@ public record FFmpegState(
bool IsTroubleshooting,
bool QsvPreferNativeDecoder = false)
{
public int QsvExtraHardwareFrames => MaybeQsvExtraHardwareFrames.IfNone(64);
// the QSV upload pool needs headroom for the frames in flight through the filter graph.
// extra_hw_frames=0 leaves none, so any input that is not throttled exhausts it: the graph
// fails with -12 (Cannot allocate memory), h264_qsv reports "Could not open encoder before
// EOF", and the output file gets no packets at all. Input throttling was the only thing
// hiding it — a work-ahead start (no -readrate) and #350's cold-start burst both remove that
// throttle, so the channel simply dies. A stored 0 is therefore treated as "no pool
// configured" rather than honored literally (ersatztv#529)
public const int MinimumQsvExtraHardwareFrames = 64;
public int QsvExtraHardwareFrames =>
Math.Max(MaybeQsvExtraHardwareFrames.IfNone(MinimumQsvExtraHardwareFrames), MinimumQsvExtraHardwareFrames);
public static FFmpegState Concat(bool saveReport, string channelName) =>
new(
@@ -62,6 +62,21 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder
PipelineContext context,
ICollection<IPipelineStep> pipelineSteps)
{
// surface the floor rather than applying it silently: a profile that deliberately asked for
// a smaller pool now gets a larger one, which costs additional surfaces (ersatztv#529)
foreach (int configured in ffmpegState.MaybeQsvExtraHardwareFrames
.Filter(f => f < FFmpegState.MinimumQsvExtraHardwareFrames))
{
// this fires per pipeline build, before we know whether this particular pipeline uploads
// at all — a fully-hardware path may consume neither value — so word it conditionally
_logger.LogWarning(
"QSV extra hardware frames is configured as {Configured}, which leaves the upload pool too "
+ "little headroom and fails transcoding on any unthrottled read; will use {Applied} "
+ "wherever frames are uploaded",
configured,
FFmpegState.MinimumQsvExtraHardwareFrames);
}
FFmpegCapability decodeCapability = _hardwareCapabilities.CanDecode(
videoStream.Codec,
videoStream.Profile,
@@ -134,6 +134,68 @@ 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)
{
await SeedResolution(1);
var handler = new CreateFFmpegProfileHandler(_db.Factory, _searchTargets);
Either<BaseError, CreateFFmpegProfileResult> result = await handler.Handle(
MakeCreate(1, qsvExtraHardwareFrames: configured),
CancellationToken.None);
CreateFFmpegProfileResult created = RightOf(result);
await using TvContext context = _db.CreateContext();
FFmpegProfile persisted = await context.FFmpegProfiles.FindAsync(created.FFmpegProfileId);
persisted.QsvExtraHardwareFrames.ShouldBe(expected);
}
[TestCase(0, 64)]
[TestCase(-8, 64)]
[TestCase(128, 128)]
public async Task Update_Should_Floor_QsvExtraHardwareFrames(int configured, int expected)
{
await SeedProfile(1);
await SeedResolution(1);
var handler = new UpdateFFmpegProfileHandler(_db.Factory, _searchTargets);
Either<BaseError, UpdateFFmpegProfileResult> result = await handler.Handle(
MakeUpdate(1, qsvExtraHardwareFrames: configured),
CancellationToken.None);
RightOf(result);
await using TvContext context = _db.CreateContext();
FFmpegProfile persisted = await context.FFmpegProfiles.FindAsync(1);
persisted.QsvExtraHardwareFrames.ShouldBe(expected);
}
// null means "unconfigured" and FFmpegState already resolves it to the same 64; it must stay
// null rather than being silently materialized into a stored value
[Test]
public async Task Create_Should_Leave_Null_QsvExtraHardwareFrames_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.QsvExtraHardwareFrames.ShouldBeNull();
}
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);
@@ -184,7 +246,10 @@ public class FFmpegProfileHandlerTests
await context.SaveChangesAsync();
}
private static CreateFFmpegProfile MakeCreate(int resolutionId, bool qsvPreferNativeDecoder = true) =>
private static CreateFFmpegProfile MakeCreate(
int resolutionId,
bool qsvPreferNativeDecoder = true,
int? qsvExtraHardwareFrames = null) =>
new(
"Default",
1,
@@ -194,7 +259,7 @@ public class FFmpegProfileHandlerTests
"drm",
VaapiDriver.Default,
"/dev/dri/renderD128",
null,
qsvExtraHardwareFrames,
resolutionId,
ScalingBehavior.ScaleAndPad,
FilterMode.Software,
@@ -221,7 +286,8 @@ public class FFmpegProfileHandlerTests
private static UpdateFFmpegProfile MakeUpdate(
int id,
int resolutionId = 1,
bool qsvPreferNativeDecoder = true) =>
bool qsvPreferNativeDecoder = true,
int? qsvExtraHardwareFrames = null) =>
new(
id,
"Default",
@@ -232,7 +298,7 @@ public class FFmpegProfileHandlerTests
"drm",
VaapiDriver.Default,
"/dev/dri/renderD128",
null,
qsvExtraHardwareFrames,
resolutionId,
ScalingBehavior.ScaleAndPad,
FilterMode.Software,
+75
View File
@@ -2844,6 +2844,16 @@ again — the duplication is what let the defect exist in triplicate. Covered by
**Signals:** HLS cold start, readrate, work_ahead_limit, HlsSessionWorker, FFmpegKnownOption capability gate · paths: `HlsSessionWorker`, `SetRealtimeInput`, `FFmpegPlaybackSettingsCalculator`, `FFmpegKnownOption`/`HasOption` · issues: #350
**Mechanics:** `SetRealtimeInput` readrate-burst option; `FFmpegKnownOption.HasOption` version-capability gate
> **Correction (2026-07-21, #529):** the claim below that the burst is "bounded" is true only in
> *seconds of input* — it is **not** a bound on memory or hardware surfaces. `-readrate` was also
> incidentally bounding how fast decoded frames enter the filter graph, and removing that on a QSV
> pipeline whose profile stores `qsvExtraHardwareFrames: 0` exhausts the upload pool: the graph fails
> with `-12 (Cannot allocate memory)`, `h264_qsv` never opens, and zero segments are written. The
> burst is **not** the root cause (a work-ahead start takes no `-readrate` at all and was already
> failing the same way in production), but it removed the throttle on every realtime session and so
> made the failure near-deterministic. See `ffmpeg.qsv-extra-hw-frames-floor`; the decision recorded
> here still stands, with that floor in place.
- **`-readrate` throttles from the first read, so it sets a floor on time-to-first-segment.** The
realtime playback path pins input reading to 1.05× wall clock so a channel behaves like live TV.
Since `OutputFormatHls.SegmentSeconds` is 4 and the segmenter serves the playlist only once the
@@ -3207,3 +3217,68 @@ single trailing `SaveChangesAsync`).
**Accepted residual:** the SPA can render a not-yet-migrated external-URL logo as an `<img>` preview
that looks working while the server-side bug won't resolve until the row is re-saved/migrated — a
narrow transitional-state cosmetic mismatch, since the startup migration eagerly converts old rows.
## 2026-07-21 — QSV hardware-frame headroom is a floor, not an operator preference (#529)
`key: ffmpeg.qsv-extra-hw-frames-floor` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
**Rule:** a QSV upload never emits `extra_hw_frames` below `FFmpegState.MinimumQsvExtraHardwareFrames` (64); a stored `0` or negative value is treated as "no pool configured" rather than honored literally, because with no headroom any unthrottled read exhausts the pool and the transcode writes nothing at all.
**Signals:** QSV, extra_hw_frames, hwupload, hardware frame pool, ENOMEM, "Could not open encoder before EOF", readrate as an incidental allocation bound · paths: `FFmpegState`, `QsvPipelineBuilder`, `HardwareUploadFilter`, `ScaleQsvFilter`, `DeinterlaceQsvFilter` · issues: #529, #350, #516, #519
**Mechanics:** `FFmpegState.MinimumQsvExtraHardwareFrames`; `FFmpegState.QsvExtraHardwareFrames`; `QsvPipelineBuilder` logs once when a configured value is raised
- **`extra_hw_frames=0` is not a valid pool size; it is a dead channel waiting for an unthrottled
read.** `FFmpegState.QsvExtraHardwareFrames` honored a stored `0` literally, so
`hwupload=extra_hw_frames=0` reached FFmpeg with no headroom for frames in flight through the
filter graph. Measured against the deployed FFmpeg 8.1.2 on one real logged command (software
mpeg4 decode → `hwupload` → `vpp_qsv` → `h264_qsv`): the graph fails with `-12 (Cannot allocate
memory)`, `h264_qsv` reports "Could not open encoder before EOF", and **zero segments are
written**. It now clamps to `MinimumQsvExtraHardwareFrames` (64), which is also the value
`IfNone` already used for an unset profile and the seeded profile default.
- **Input throttling was the only thing hiding it, which is why this looked like a #350
regression.** Truth table, same command, same binary, only the marked tokens differing:
| readrate | extra_hw_frames | result |
|---|---|---|
| `1.05`, no burst | 0 | 14 segments, exit 0 |
| `1.05` + burst 2 / 4 / 8 | 0 | **ENOMEM, 0 segments** |
| no readrate at all | 0 | **ENOMEM, 0 segments** |
| `1.05` + burst 8 | 64 | 14 segments, exit 0 |
| no readrate at all | 64 | 14 segments, exit 0 |
So the defect predates #350's burst: any work-ahead start (which takes no `-readrate`) on a
pipeline that uploads to QSV was already failing on a profile with `0`. The burst did not
introduce it — it removed the throttle on *every* realtime session, converting an intermittent
failure into a near-deterministic one, which is how it finally got noticed.
- **`-readrate` was doing load-bearing work nobody had written down.** Its stated job is live-TV
pacing; it was *also* incidentally bounding how fast decoded frames enter the filter graph. This
is why #350's FFmpeg-level benchmark and #516's argument-generation tests were both green and
neither could see it: the burst is bounded in **seconds of input**, which is not a bound on
**memory or hardware surfaces**. Corrects the #350 entry above, which records the burst as
bounded and safe and does not mention hardware frame pools.
- **A floor, not a clamp-to-default — and the floor is wider than the evidence.** Values above 64
are honored unchanged; values below it are raised. We measured only `0` (fails) and `64` (works),
so `1..63` are **untested, not known-bad**: we raise them rather than trust them, because the
failure they risk is a channel that serves nothing at all. That is a deliberate over-reach, and
it is not free — `extra_hw_frames` allocates *additional* surfaces (64 NV12 1080p surfaces ≈
190 MiB, ≈760 MiB at 4K), so an operator who deliberately set a small pool on a memory-constrained
iGPU silently gets a larger one. `QsvPipelineBuilder` therefore **logs a warning** naming both the
configured and the applied value, so the override is discoverable rather than silent. If a smaller
pool is ever measured safe, lower the floor rather than removing it.
- **Fixed at `FFmpegState.QsvExtraHardwareFrames`, the single point every QSV upload site reads.**
`HardwareUploadFilter`, `HardwareUploadQsvFilter` and `WatermarkHardwareUploadFilter` read it
directly; `ScaleQsvFilter` and `DeinterlaceQsvFilter` take values `QsvPipelineBuilder` passes down
from it. One guard covers them all rather than five call sites that can drift apart. (A sixth
formatter, `SubtitleScaleQsvFilter`, also emits `extra_hw_frames` but is currently dead code — no
construction site exists in the solution — so it is *not* covered by this guard and would need the
same value threading if it is ever revived.)
**Accepted residual:** the floor is applied at render time only, so a stored `0` keeps displaying as
`0` in the SPA and over `GET /api/v1/ffmpeg/profiles/{id}` while FFmpeg receives 64 — the config no
longer literally describes the behavior. Render-time was chosen deliberately: it fixes every existing
deployment with no DB edit and no migration, which matters because this bug is already failing
transcodes in production. New and updated profiles are normalized on save so stored rows converge on
the truth, and the warning log closes the discoverability gap for rows that predate it; a backfill
migration for old rows was judged not worth the dual-provider cost. Two consequences to know about:
the save-time normalization is **unconditional on `hardwareAcceleration`**, so a non-QSV profile's
stored value moves too (harmless — only the QSV path ever reads it — but it is a stored-state change
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.
+1
View File
@@ -50,6 +50,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
| `ffmpeg.external-logo-graphics-engine` | External-URL channel logos pass through to the graphics engine like any other watermark source; `WatermarkSelector` must never gate them on `File.Exists` (always false for a URL) and never route them through the ffmpeg-native overlay shortcut. | 2026-07-20 | [link](../decisions.md#2026-07-20--external-url-channel-logos-pass-through-to-the-graphics-engine-never-fileexists-gated-never-ffmpeg-native-502) |
| `ffmpeg.hls-cold-start-burst` | HLS cold-start latency is fixed with a bounded `-readrate_initial_burst` (gated on FFmpeg ≥6.1 capability detection), not by raising `work_ahead_limit`, which would remove the concurrency guarantee it exists for. | 2026-07-20 | [link](../decisions.md#2026-07-20--hls-cold-start-is-fixed-with--readrate_initial_burst-not-by-raising-the-work-ahead-limit-350) |
| `ffmpeg.qsv-decode-encode-split` | QSV decode is decoupled from QSV encode via a single `FFmpegProfile.QsvPreferNativeDecoder` bool (default ON, Linux-only), so a QSV encode profile can decode with the more tolerant native VA-API decoder instead of the QSV decoder, mirroring Jellyfin's hybrid decode/encode toggle instead of a general decode-family enum. | 2026-07-20 | [link](../decisions.md#2026-07-20-498--qsv-decode-is-split-from-qsv-encode-via-a-single-qsvprefernativedecoder-bool) |
| `ffmpeg.qsv-extra-hw-frames-floor` | a QSV upload never emits `extra_hw_frames` below `FFmpegState.MinimumQsvExtraHardwareFrames` (64); a stored `0` or negative value is treated as "no pool configured" rather than honored literally, because with no headroom any unthrottled read exhausts the pool and the transcode writes nothing at all. | 2026-07-21 | [link](../decisions.md#2026-07-21--qsv-hardware-frame-headroom-is-a-floor-not-an-operator-preference-529) |
| `ffmpeg.remote-image-fetcher-bounded` | remote graphics-engine images are fetched through `IRemoteImageFetcher` with a pooled `HttpClientFactory` client, a body-covering deadline, a wire-transfer size cap, and a decoder-enforced `DecoderOptions.MaxFrames` bound re-verified post-decode — never cached, re-fetched per element init. | 2026-07-20 | [link](../decisions.md#2026-07-20--remote-graphics-engine-images-are-fetched-through-a-bounded-pooled-iremoteimagefetcher-re-fetched-per-element-init-not-cached-511) |
| `graphics.channel-logo-caching` | An external `http(s)` channel-logo URL is fetched, decode-budget-validated, and stored in the image cache under a content-hash name at SAVE time — becoming byte-identical to an uploaded logo — so the render path never fetches a logo over HTTP; a bad URL fails the save with a 422 (BaseError → ValidationProblemDetails). | 2026-07-21 | [link](../decisions.md#2026-07-21--external-channel-logo-urls-are-downloaded-and-cached-at-save-time-the-render-path-never-fetches-a-logo-525) |
| `iptv.base-url` | An optional advertised base URL (`iptv.base_url`) is resolved centrally via a pure Core helper (`AdvertisedBaseUrl`) inside the two IPTV generation handlers (M3U + XMLTV); unset/malformed values fall back byte-identical to the request-derived host, and it's a new `iptv` settings group distinct from `ETV_BASE_URL` and out of scope for HDHomeRun. | 2026-07-16 | [link](../decisions.md#2026-07-16--optional-advertised-iptv-base-url-iptvbase_url-resolved-centrally-in-the-two-generators-340) |
+22 -2
View File
@@ -20,6 +20,10 @@ 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;
type Draft = CreateFFmpegProfileRequest;
/* ---------- enum option lists (mirror ErsatzTV/Pages/FFmpegEditor.razor) ---------- */
@@ -186,6 +190,16 @@ function validate(draft: Draft): null | string {
}
}
// 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;
}
@@ -207,7 +221,7 @@ function defaultDraft(resolutionId: number): Draft {
normalizeLoudnessMode: 'Off',
normalizeVideo: true,
padMode: 'Software',
qsvExtraHardwareFrames: 64,
qsvExtraHardwareFrames: MINIMUM_QSV_EXTRA_HARDWARE_FRAMES,
qsvPreferNativeDecoder: true,
resolutionId,
scalingBehavior: 'ScaleAndPad',
@@ -291,11 +305,13 @@ function Row({
function NumberField({
disabled,
min,
onChange,
unit,
value
}: {
disabled?: boolean;
min?: number;
onChange: (next: number) => void;
unit?: string;
value: number;
@@ -303,6 +319,7 @@ function NumberField({
return (
<Input
disabled={disabled}
min={min}
onChange={(event) => onChange(Number(event.target.value))}
size="sm"
trailing={unit}
@@ -769,9 +786,12 @@ function ProfileEditor({ mode }: { mode: { kind: 'add'; from: null | number } |
)}
{draft.hardwareAcceleration === 'Qsv' ? (
<Row control={200} 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) */}
<NumberField
min={MINIMUM_QSV_EXTRA_HARDWARE_FRAMES}
onChange={(qsvExtraHardwareFrames) => set({ qsvExtraHardwareFrames })}
value={draft.qsvExtraHardwareFrames ?? 0}
value={draft.qsvExtraHardwareFrames ?? MINIMUM_QSV_EXTRA_HARDWARE_FRAMES}
/>
</Row>
) : (