merge main (0.78.2) into playback-class before release

# Conflicts:
#	backend/app/plex/stream.py
This commit is contained in:
2026-08-30 19:08:08 +02:00
19 changed files with 1053 additions and 199 deletions
+5
View File
@@ -101,6 +101,9 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// Which encoder the current transcode session ran: true=VAAPI hardware, false=libx264 software,
// null=not a transcode. Feeds the stats overlay's mode row ("transcode · GPU" / "· CPU").
const hwRef = useRef<boolean | null>(null);
// Whether the current HW transcode tone-mapped a 10-bit/HDR source on the GPU (OpenCL). Feeds the
// stats mode row's "· HDR→SDR" tag; null/false for an 8-bit HW, software, or non-transcode session.
const tonemapRef = useRef<boolean | null>(null);
const durationRef = useRef(0);
// ≥2 audio tracks → multi-rendition HLS: all tracks ship in one session and hls.js switches audio
// client-side (no restart). Ref so the stable loadSession/changeAudio callbacks read it fresh.
@@ -234,6 +237,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// re-encodes the video (hw = which encoder), and its first segment is genuinely slower than remux.
const isTranscode = sess.mode === "hls" && !!sess.transcode;
hwRef.current = isTranscode ? (sess.hw ?? false) : null;
tonemapRef.current = isTranscode ? (sess.tonemap ?? false) : null;
setSessionIsTranscode(isTranscode);
// hls.js plays our non-zero-start copyts stream ~1s BEHIND the media clock (see HLS_SUB_LAG);
// fold that into the session start so the seekbar clock, seeking AND the subtitle shift are all
@@ -1206,6 +1210,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
hlsRef={hlsRef}
playable={detail?.playable ?? ""}
hwRef={hwRef}
tonemapRef={tonemapRef}
sessionStartRef={sessionStartRef}
absRef={absRef}
durationRef={durationRef}
+4 -1
View File
@@ -13,6 +13,7 @@ export function PlexStatsOverlay({
hlsRef,
playable,
hwRef,
tonemapRef,
sessionStartRef,
absRef,
durationRef,
@@ -22,6 +23,7 @@ export function PlexStatsOverlay({
hlsRef: RefObject<Hls | null>;
playable: string;
hwRef: RefObject<boolean | null>; // active transcode encoder: true=GPU, false=CPU, null=not transcode
tonemapRef: RefObject<boolean | null>; // HW HDR→SDR tonemap engaged (adds a "· HDR→SDR" mode tag)
sessionStartRef: RefObject<number>;
absRef: RefObject<number>;
durationRef: RefObject<number>;
@@ -78,6 +80,7 @@ export function PlexStatsOverlay({
buildStatRows({
playable,
hwEncoder: hwRef.current,
tonemap: tonemapRef.current,
transport: hls ? "hls" : "direct",
videoWidth: v?.videoWidth ?? 0,
videoHeight: v?.videoHeight ?? 0,
@@ -94,7 +97,7 @@ export function PlexStatsOverlay({
sample();
const iv = setInterval(sample, 1000);
return () => clearInterval(iv);
}, [videoRef, hlsRef, playable, hwRef, sessionStartRef, absRef, durationRef]);
}, [videoRef, hlsRef, playable, hwRef, tonemapRef, sessionStartRef, absRef, durationRef]);
return (
<div
+1
View File
@@ -403,6 +403,7 @@ export interface PlexPlaySession {
start_s: number;
transcode?: boolean; // hls only: video re-encoded (non-h264 source) vs stream-copied (remux)
hw?: boolean; // hls transcode only: ran on the VAAPI hardware encoder (else libx264 software)
tonemap?: boolean; // hls HW transcode only: 10-bit/HDR source tone-mapped HDR→SDR on the GPU (OpenCL)
}
// Discover surface (E5 S4): the consolidated rail payload. Every rail is a plain PlexCard list the
+9
View File
@@ -118,6 +118,11 @@ describe("buildStatRows", () => {
expect(cpu.find((r) => r.key === "mode")?.value).toBe("transcode · CPU");
});
it("tags the mode row with HDR→SDR for a HW tonemap session", () => {
const rows = buildStatRows({ ...base, playable: "transcode", hwEncoder: true, tonemap: true });
expect(rows.find((r) => r.key === "mode")?.value).toBe("transcode · GPU · HDR→SDR");
});
it("shows an empty classification honestly as a dash", () => {
expect(buildStatRows({ ...base, playable: "" }).find((r) => r.key === "mode")?.value).toBe(
DASH,
@@ -133,5 +138,9 @@ describe("fmtMode", () => {
expect(fmtMode("transcode")).toBe("transcode");
expect(fmtMode("remux", true)).toBe("remux"); // not a transcode → no encoder suffix
expect(fmtMode("")).toBe(DASH);
// The HDR→SDR tag rides on the GPU encoder token for a tonemap session.
expect(fmtMode("transcode", true, true)).toBe("transcode · GPU · HDR→SDR");
expect(fmtMode("transcode", true, false)).toBe("transcode · GPU");
expect(fmtMode("transcode", null, true)).toBe("transcode"); // no encoder → no tag either
});
});
+19 -6
View File
@@ -12,6 +12,9 @@ export interface PlexStatsInput {
// software, null/undefined = not a transcode (or unknown) — so the mode row can read "transcode · GPU"
// vs "transcode · CPU", the visible confirmation of which S1 path engaged.
hwEncoder?: boolean | null;
// For a HW transcode of a 10-bit/HDR source: true = the GPU OpenCL tonemap path (HDR→SDR) engaged, so
// the mode row can read "transcode · GPU · HDR→SDR". Absent/false for an 8-bit HW or software session.
tonemap?: boolean | null;
videoWidth: number; // intrinsic decoded dims (0 while the browser can't decode / pre-metadata)
videoHeight: number;
droppedFrames: number; // getVideoPlaybackQuality().droppedVideoFrames
@@ -94,20 +97,30 @@ export function fmtCodec(video: string | undefined, audio: string | undefined):
}
// Active mode, with the transcode encoder appended when known: "transcode · GPU" (VAAPI hardware) or
// "transcode · CPU" (libx264 software) — the at-a-glance confirmation of which S1 path engaged. Non-
// transcode modes (direct/remux) and an unknown encoder read as the bare mode. Raw tokens (not i18n'd),
// consistent with the other technical values in this overlay (e.g. transport "hls").
export function fmtMode(playable: string, hwEncoder?: boolean | null): string {
// "transcode · CPU" (libx264 software) — the at-a-glance confirmation of which S1 path engaged — and a
// "· HDR→SDR" tag when the GPU OpenCL tonemap path handled a 10-bit/HDR source ("transcode · GPU ·
// HDR→SDR"). Non-transcode modes (direct/remux) and an unknown encoder read as the bare mode. Raw tokens
// (not i18n'd), consistent with the other technical values in this overlay (e.g. transport "hls").
export function fmtMode(
playable: string,
hwEncoder?: boolean | null,
tonemap?: boolean | null,
): string {
const mode = playable || DASH;
if (playable !== "transcode" || hwEncoder == null) return mode;
return `${mode} · ${hwEncoder ? "GPU" : "CPU"}`;
const base = `${mode} · ${hwEncoder ? "GPU" : "CPU"}`;
return tonemap ? `${base} · HDR→SDR` : base;
}
// Build the ordered, i18n-keyed rows the overlay renders. Rows whose data is absent still show a dash
// (honest "not available" beats a missing row) — except HLS-only rows, dropped entirely for direct play.
export function buildStatRows(s: PlexStatsInput): StatRow[] {
const rows: StatRow[] = [
{ key: "mode", labelKey: "plex.player.stats.mode", value: fmtMode(s.playable, s.hwEncoder) },
{
key: "mode",
labelKey: "plex.player.stats.mode",
value: fmtMode(s.playable, s.hwEncoder, s.tonemap),
},
{ key: "transport", labelKey: "plex.player.stats.transport", value: s.transport },
{
key: "res",
+27
View File
@@ -14,6 +14,33 @@ export interface ReleaseEntry {
}
export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.78.2",
date: "2026-08-30",
summary:
"HDR and 10-bit videos in the Plex player now transcode on the server GPU instead of falling back to the CPU. HDR is tone-mapped to SDR on the GPU so the picture looks right in the browser, at a fraction of the CPU cost.",
features: [
"A 10-bit / HDR video (HDR10, HLG — common on 4K and modern HEVC files) that needs transcoding now runs on the Intel GPU, tone-mapping HDR→SDR on-device so colours look right in the browser and the CPU is barely touched. Previously these fell back to a heavy software encode; now the GPU does the work. If the GPU path can't start for any reason, playback still falls back to software automatically, so a video never fails to play.",
],
fixes: [
"A 10-bit video that is NOT HDR (standard-range 10-bit, common in anime and some HEVC encodes) is no longer run through the HDR tone-curve — it gets a plain down-convert, so its colours are preserved exactly instead of being subtly darkened.",
],
chores: [
"The transcode pipeline now decides hardware vs software (and HDR tone-mapping) from a real probe of the source, and the GPU HDR path uses the Intel OpenCL runtime baked into the image. The whole change went through several multi-agent deep-review rounds — including an adversarially-verified fix to a start/seek race in the session fallback — re-reviewed until a round came back clean.",
],
},
{
version: "0.78.1",
date: "2026-08-30",
summary:
"A fix for how downloads and uploads are recognised: an audio file with embedded cover art (or a still image) is no longer mistaken for a video, so it gets the right player and the right controls.",
fixes: [
"A downloaded audio track that carries album art — and some files from sites that report a video stream that isn't really there — were sometimes classified as video, giving a Play button that opened a broken, picture-less player. Siftlode now decides what a finished file actually is by inspecting the file itself, so audio stays audio, images stay files, and only real videos get the video player.",
],
chores: [
"The media-kind classifier now trusts the on-disk probe over the source site's metadata whenever the probe could read the file, so stale metadata can't resurrect a video track the file doesn't have. Went through multi-agent deep-review, findings fixed and re-reviewed until clean.",
],
},
{
version: "0.78.0",
date: "2026-08-30",