jellyfin: ask for a bitrate, or get 416 pixels

a transcode url without `videoBitrate` is not "let the server choose".
jellyfin resolves the missing value to 0 and runs ResolutionNormalizer
over it, which maps a bitrate onto a resolution — 0 lands on the bottom
rung. verified here: a 3840x1600 hdr source came back through
scale=...min(max(iw,ih*a),416)... with -maxrate 0.

the number has to be picked per source, because the two cases pull
opposite ways: a cap is a reason for jellyfin to REFUSE a stream copy,
and the absence of one is what makes a cpu-only 4k encode hopeless. so
copyable h264 asks for a ceiling nothing hits and no width, and anything
being genuinely re-encoded asks for 1080p at 12 mbit.

verified both branches against the ffmpeg command jellyfin logs: h264 mkv
→ -codec:v:0 copy, hevc 10-bit hdr → libx264 at 1920 wide, tonemapped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 18:34:27 +00:00
co-authored by Claude Opus 5
parent a894d64cb3
commit 9990c04008
+54
View File
@@ -188,6 +188,16 @@ async function item(cfg: UpstreamConfig, id: string): Promise<Response> {
return relayJson(res); return relayJson(res);
} }
type SourceStream = {
Type?: string;
Codec?: string;
Profile?: string;
Level?: number;
Width?: number;
Height?: number;
BitRate?: number;
};
type MediaSource = { type MediaSource = {
Id?: string; Id?: string;
Container?: string; Container?: string;
@@ -196,6 +206,7 @@ type MediaSource = {
SupportsDirectStream?: boolean; SupportsDirectStream?: boolean;
DirectStreamUrl?: string; DirectStreamUrl?: string;
TranscodingUrl?: string; TranscodingUrl?: string;
MediaStreams?: SourceStream[];
}; };
type PlaybackInfoResponse = { MediaSources?: MediaSource[]; PlaySessionId?: string; ErrorCode?: string | null }; type PlaybackInfoResponse = { MediaSources?: MediaSource[]; PlaySessionId?: string; ErrorCode?: string | null };
@@ -216,6 +227,40 @@ function sanitizeUpstreamPath(raw: string): string {
return `${path}${qs ? `?${qs}` : ''}`; return `${path}${qs ? `?${qs}` : ''}`;
} }
/**
* A ceiling high enough to never be the binding constraint on a stream copy. It matches the profile's
* `MaxStreamingBitrate`: the request has to name a number, and for a copy the right number is "more than
* whatever this file is", since Jellyfin refuses to copy a stream that exceeds the requested bitrate.
*/
const COPY_CEILING_BITRATE = 120_000_000;
/** What a real CPU re-encode targets. 1080p at 12 Mbit is visually generous for h264 at the crf Jellyfin uses. */
const ENCODE_BITRATE = 12_000_000;
const ENCODE_MAX_WIDTH = 1920;
/**
* Decide the bitrate and resolution cap to ask for, from what is actually in the file.
*
* The two cases pull in opposite directions, which is why this is not one constant:
*
* video can be copied → any cap is a reason for Jellyfin to REFUSE the copy (it will not copy a stream
* above the requested bitrate, or wider than a requested `maxWidth`), so ask for a
* ceiling nothing hits and no width at all. The bytes are passed through untouched.
* video is re-encoded → a cap is the only thing keeping ffmpeg tractable. There is no /dev/dri in the
* Jellyfin container, so 4K HDR HEVC is being decoded, tonemapped and encoded on the
* CPU; downscaling to 1080p is what makes that keep up with playback.
*
* The copy test mirrors the profile's own conditions rather than a new opinion: h264, not 10-bit, level ≤ 5.2.
*/
function transcodeQuality(source: MediaSource): { videoBitrate: number; maxWidth?: number } {
const video = source.MediaStreams?.find((stream) => stream.Type === 'Video');
const copyable =
video?.Codec?.toLowerCase() === 'h264' && video.Profile?.toLowerCase() !== 'high 10' && (video.Level ?? 0) <= 52;
if (copyable) return { videoBitrate: COPY_CEILING_BITRATE };
return { videoBitrate: ENCODE_BITRATE, maxWidth: ENCODE_MAX_WIDTH };
}
/** /**
* Build the PROGRESSIVE transcode URL — one continuous mp4 the `<video>` element can play with no library. * Build the PROGRESSIVE transcode URL — one continuous mp4 the `<video>` element can play with no library.
* *
@@ -231,8 +276,15 @@ function sanitizeUpstreamPath(raw: string): string {
* `allowVideoStreamCopy` is what keeps this cheap for the common case. An mkv whose video is already h264 * `allowVideoStreamCopy` is what keeps this cheap for the common case. An mkv whose video is already h264
* gets REMUXED, not re-encoded: the container changes, the video bytes are copied. That matters on this * gets REMUXED, not re-encoded: the container changes, the video bytes are copied. That matters on this
* machine, where the Jellyfin container has no /dev/dri and every real encode is on the CPU. * machine, where the Jellyfin container has no /dev/dri and every real encode is on the CPU.
*
* `videoBitrate` IS NOT OPTIONAL, and omitting it is not "let the server choose". Jellyfin resolves a missing
* bitrate to 0 and then runs `ResolutionNormalizer` over it, which maps a bitrate to a resolution — 0 lands on
* the bottom rung. Verified on this machine: without it, a 3840x1600 source transcoded through
* `scale=…min(max(iw,ih*a),416)…` with `-maxrate 0`, i.e. 416 pixels wide. That is the whole of the "why does
* it look so bad" question, and it is a silent, plausible-looking picture rather than an error.
*/ */
function progressivePath(id: string, source: MediaSource, playSessionId: string | null, startSeconds: number): string { function progressivePath(id: string, source: MediaSource, playSessionId: string | null, startSeconds: number): string {
const quality = transcodeQuality(source);
return `/Videos/${encodeURIComponent(id)}/stream.mp4${buildQuery({ return `/Videos/${encodeURIComponent(id)}/stream.mp4${buildQuery({
static: 'false', static: 'false',
container: 'mp4', container: 'mp4',
@@ -244,6 +296,8 @@ function progressivePath(id: string, source: MediaSource, playSessionId: string
maxAudioChannels: '2', maxAudioChannels: '2',
allowVideoStreamCopy: 'true', allowVideoStreamCopy: 'true',
allowAudioStreamCopy: 'true', allowAudioStreamCopy: 'true',
videoBitrate: String(quality.videoBitrate),
maxWidth: quality.maxWidth ? String(quality.maxWidth) : undefined,
// No `subtitleMethod=Encode` here on purpose: burning subtitles in forces a full video re-encode, which // No `subtitleMethod=Encode` here on purpose: burning subtitles in forces a full video re-encode, which
// throws away the stream copy above and pins the CPU on a file that needed nothing but a remux. Subtitles // throws away the stream copy above and pins the CPU on a file that needed nothing but a remux. Subtitles
// are a deliberate later feature, chosen per-item, not a default that quietly costs that much. // are a deliberate later feature, chosen per-item, not a default that quietly costs that much.