Files
siftlode/frontend/src/lib/plexStats.ts
T
peter 4827aebb7f feat(plex): HW HDR→SDR tonemap for 10-bit/HDR transcode (OpenCL on the iGPU)
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.
2026-08-30 06:02:45 +02:00

162 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
}