merge main (0.78.2) into playback-class before release
# Conflicts: # backend/app/plex/stream.py
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user