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);
|
||||
}
|
||||
|
||||
type SourceStream = {
|
||||
Type?: string;
|
||||
Codec?: string;
|
||||
Profile?: string;
|
||||
Level?: number;
|
||||
Width?: number;
|
||||
Height?: number;
|
||||
BitRate?: number;
|
||||
};
|
||||
|
||||
type MediaSource = {
|
||||
Id?: string;
|
||||
Container?: string;
|
||||
@@ -196,6 +206,7 @@ type MediaSource = {
|
||||
SupportsDirectStream?: boolean;
|
||||
DirectStreamUrl?: string;
|
||||
TranscodingUrl?: string;
|
||||
MediaStreams?: SourceStream[];
|
||||
};
|
||||
type PlaybackInfoResponse = { MediaSources?: MediaSource[]; PlaySessionId?: string; ErrorCode?: string | null };
|
||||
|
||||
@@ -216,6 +227,40 @@ function sanitizeUpstreamPath(raw: string): string {
|
||||
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.
|
||||
*
|
||||
@@ -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
|
||||
* 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.
|
||||
*
|
||||
* `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 {
|
||||
const quality = transcodeQuality(source);
|
||||
return `/Videos/${encodeURIComponent(id)}/stream.mp4${buildQuery({
|
||||
static: 'false',
|
||||
container: 'mp4',
|
||||
@@ -244,6 +296,8 @@ function progressivePath(id: string, source: MediaSource, playSessionId: string
|
||||
maxAudioChannels: '2',
|
||||
allowVideoStreamCopy: '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
|
||||
// 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.
|
||||
|
||||
Reference in New Issue
Block a user