Upgrade mode-3's 10-bit/HDR branch from "route to software libx264" (BUG 1's behaviour) to a full-GPU pipeline: VAAPI decode → OpenCL tonemap (hable, HDR10/HLG → bt709 SDR nv12) → h264_vaapi. Gen 9.5 (UHD 630) can't tonemap in the VAAPI VPP (tonemap_vaapi fails), so OpenCL is the only HW route — proven on the prod iGPU at ~4.2x realtime, ~1 CPU core. - stream.py: `_select_encode` returns (hw, tonemap); a 10-bit/HDR HW-decodable source picks the tonemap graph when the OpenCL runtime is present, else drops to software. `_opencl_present` gates on a registered ICD. `_venc_opts` / `_hwaccel_in_opts` gain the tonemap variant (named vaapi+opencl devices, `-filter_hw_device ocl`, hwmap→tonemap_opencl→hwmap reverse). `_want_hw` kept as the hw-half wrapper. The runtime HW→SW fallback still backstops a session that emits no segment. - Dockerfile: add the Intel NEO OpenCL runtime (intel-opencl-icd). It was removed from Debian trixie, so it's pulled from bookworm (22.43 still supports Gen 9.5; the .deb runs on the trixie base — verified live). Harmless without /dev/dri. - route + FE: thread `tonemap` through the session response; the stats overlay mode row reads "transcode · GPU · HDR→SDR" for the tonemap path. - tests: tonemap command branch, the (hw, tonemap) selection matrix (auto/on × 8-bit/10-bit × opencl), start_session tonemap wiring, and the fmtMode tag.
162 lines
6.9 KiB
TypeScript
162 lines
6.9 KiB
TypeScript
// Pure formatters + row builder for the Plex player "stats for nerds" overlay (E6 S2). Kept free of
|
||
// React/DOM so the number-crunching is unit-testable: the component gathers raw values off videoRef /
|
||
// hlsRef and hands them here; this returns i18n-keyed rows the overlay renders. No data pipeline — the
|
||
// HTML5 <video> + hls.js already expose everything (intrinsic dims, getVideoPlaybackQuality, levels).
|
||
|
||
export interface PlexStatsInput {
|
||
// The item's codec classification (backend `classify_playable`): the "active mode" a viewer cares
|
||
// about — direct copy, remux (stream-copy → HLS), or transcode (re-encode). Empty until detail loads.
|
||
playable: string; // "direct" | "remux" | "transcode" | ""
|
||
transport: "direct" | "hls"; // session transport (raw file vs HLS) — orthogonal to `playable`
|
||
// For a transcode session, which encoder actually ran: true = VAAPI hardware, false = libx264
|
||
// 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
|
||
totalFrames: number; // getVideoPlaybackQuality().totalVideoFrames
|
||
bufferAheadS: number; // seconds buffered ahead of the playhead
|
||
hlsLevel: HlsLevelInfo | null; // null for direct play / Safari-native HLS
|
||
sessionStartS: number; // media-clock offset the current session was opened at
|
||
abs: number; // current media time (absolute, session offset already folded in)
|
||
duration: number;
|
||
}
|
||
|
||
export interface HlsLevelInfo {
|
||
index: number; // active level index (-1 = auto/undetermined)
|
||
total: number; // number of renditions
|
||
width?: number;
|
||
height?: number;
|
||
bitrate?: number; // bits per second
|
||
videoCodec?: string;
|
||
audioCodec?: string;
|
||
}
|
||
|
||
export interface StatRow {
|
||
key: string; // stable react key
|
||
labelKey: string; // i18n key under plex.player.stats.*
|
||
value: string;
|
||
}
|
||
|
||
const DASH = "—";
|
||
|
||
// bits-per-second → "4.2 Mbps" / "820 kbps". Undefined/0 → dash (hls.js reports 0 before a level loads).
|
||
export function fmtBitrate(bps: number | undefined): string {
|
||
if (!bps || bps <= 0) return DASH;
|
||
if (bps >= 1_000_000) return `${(bps / 1_000_000).toFixed(1)} Mbps`;
|
||
return `${Math.round(bps / 1000)} kbps`;
|
||
}
|
||
|
||
// Seconds of buffer ahead of the playhead → "12.3 s".
|
||
export function fmtBufferSecs(s: number): string {
|
||
if (!Number.isFinite(s) || s < 0) return DASH;
|
||
return `${s.toFixed(1)} s`;
|
||
}
|
||
|
||
// Dropped / total decoded frames, with a drop percentage once any frames have been decoded.
|
||
export function fmtFrames(dropped: number, total: number): string {
|
||
if (total <= 0) return DASH;
|
||
const pct = (dropped / total) * 100;
|
||
return `${dropped} / ${total} (${pct.toFixed(pct < 1 ? 2 : 1)}%)`;
|
||
}
|
||
|
||
// Intrinsic decoded resolution. 0×0 means the browser hasn't decoded (or can't) — an honest dash,
|
||
// which is itself the tell for a not-yet-playable codec pre-transcode.
|
||
export function fmtRes(w: number, h: number): string {
|
||
if (w <= 0 || h <= 0) return DASH;
|
||
return `${w}×${h}`;
|
||
}
|
||
|
||
// Seconds → m:ss (offsets/positions are small; hours are folded into minutes deliberately).
|
||
export function fmtClock(s: number): string {
|
||
if (!Number.isFinite(s) || s < 0) return DASH;
|
||
const total = Math.floor(s);
|
||
const m = Math.floor(total / 60);
|
||
const sec = total % 60;
|
||
return `${m}:${sec.toString().padStart(2, "0")}`;
|
||
}
|
||
|
||
// hls.js active level → "2 / 4 · 1280×720" (1-based index for humans). Auto/unknown level → dash + count.
|
||
export function fmtLevel(l: HlsLevelInfo | null): string {
|
||
if (!l || l.total <= 0) return DASH;
|
||
const idx = l.index >= 0 ? `${l.index + 1} / ${l.total}` : `auto / ${l.total}`;
|
||
const res = l.width && l.height ? ` · ${l.width}×${l.height}` : "";
|
||
return `${idx}${res}`;
|
||
}
|
||
|
||
// Video/audio codec from the active hls.js level (only HLS exposes them; direct play → dash).
|
||
export function fmtCodec(video: string | undefined, audio: string | undefined): string {
|
||
const v = video?.split(".")[0] || null; // "avc1.640028" → "avc1"
|
||
const a = audio?.split(".")[0] || null;
|
||
if (!v && !a) return DASH;
|
||
return [v, a].filter(Boolean).join(" / ");
|
||
}
|
||
|
||
// 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 — 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;
|
||
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, s.tonemap),
|
||
},
|
||
{ key: "transport", labelKey: "plex.player.stats.transport", value: s.transport },
|
||
{
|
||
key: "res",
|
||
labelKey: "plex.player.stats.resolution",
|
||
value: fmtRes(s.videoWidth, s.videoHeight),
|
||
},
|
||
];
|
||
if (s.transport === "hls") {
|
||
rows.push(
|
||
{
|
||
key: "codec",
|
||
labelKey: "plex.player.stats.codec",
|
||
value: fmtCodec(s.hlsLevel?.videoCodec, s.hlsLevel?.audioCodec),
|
||
},
|
||
{
|
||
key: "bitrate",
|
||
labelKey: "plex.player.stats.bitrate",
|
||
value: fmtBitrate(s.hlsLevel?.bitrate),
|
||
},
|
||
{ key: "level", labelKey: "plex.player.stats.level", value: fmtLevel(s.hlsLevel) },
|
||
);
|
||
}
|
||
rows.push(
|
||
{
|
||
key: "frames",
|
||
labelKey: "plex.player.stats.frames",
|
||
value: fmtFrames(s.droppedFrames, s.totalFrames),
|
||
},
|
||
{ key: "buffer", labelKey: "plex.player.stats.buffer", value: fmtBufferSecs(s.bufferAheadS) },
|
||
{ key: "offset", labelKey: "plex.player.stats.offset", value: fmtClock(s.sessionStartS) },
|
||
{
|
||
key: "position",
|
||
labelKey: "plex.player.stats.position",
|
||
value: `${fmtClock(s.abs)} / ${fmtClock(s.duration)}`,
|
||
},
|
||
);
|
||
return rows;
|
||
}
|