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:
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user