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.
147 lines
5.2 KiB
TypeScript
147 lines
5.2 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
||
import {
|
||
buildStatRows,
|
||
fmtBitrate,
|
||
fmtBufferSecs,
|
||
fmtClock,
|
||
fmtCodec,
|
||
fmtFrames,
|
||
fmtLevel,
|
||
fmtMode,
|
||
fmtRes,
|
||
type PlexStatsInput,
|
||
} from "./plexStats";
|
||
|
||
const DASH = "—";
|
||
|
||
describe("plexStats formatters", () => {
|
||
it("fmtBitrate scales kbps/Mbps and dashes zero/undefined", () => {
|
||
expect(fmtBitrate(undefined)).toBe(DASH);
|
||
expect(fmtBitrate(0)).toBe(DASH);
|
||
expect(fmtBitrate(820_000)).toBe("820 kbps");
|
||
expect(fmtBitrate(4_200_000)).toBe("4.2 Mbps");
|
||
expect(fmtBitrate(1_000_000)).toBe("1.0 Mbps");
|
||
});
|
||
|
||
it("fmtBufferSecs shows one decimal, dashes negatives/non-finite", () => {
|
||
expect(fmtBufferSecs(12.34)).toBe("12.3 s");
|
||
expect(fmtBufferSecs(0)).toBe("0.0 s");
|
||
expect(fmtBufferSecs(-1)).toBe(DASH);
|
||
expect(fmtBufferSecs(Infinity)).toBe(DASH);
|
||
});
|
||
|
||
it("fmtFrames shows ratio + drop %, dashes when nothing decoded", () => {
|
||
expect(fmtFrames(0, 0)).toBe(DASH);
|
||
expect(fmtFrames(0, 1000)).toBe("0 / 1000 (0.00%)");
|
||
expect(fmtFrames(5, 1000)).toBe("5 / 1000 (0.50%)");
|
||
expect(fmtFrames(30, 1000)).toBe("30 / 1000 (3.0%)");
|
||
});
|
||
|
||
it("fmtRes dashes an undecoded 0×0 frame", () => {
|
||
expect(fmtRes(1920, 1080)).toBe("1920×1080");
|
||
expect(fmtRes(0, 0)).toBe(DASH);
|
||
expect(fmtRes(1920, 0)).toBe(DASH);
|
||
});
|
||
|
||
it("fmtClock renders m:ss and dashes bad input", () => {
|
||
expect(fmtClock(0)).toBe("0:00");
|
||
expect(fmtClock(9)).toBe("0:09");
|
||
expect(fmtClock(75)).toBe("1:15");
|
||
expect(fmtClock(3661)).toBe("61:01");
|
||
expect(fmtClock(-1)).toBe(DASH);
|
||
});
|
||
|
||
it("fmtLevel is 1-based with resolution, handles auto and empty", () => {
|
||
expect(fmtLevel(null)).toBe(DASH);
|
||
expect(fmtLevel({ index: 1, total: 4, width: 1280, height: 720 })).toBe("2 / 4 · 1280×720");
|
||
expect(fmtLevel({ index: -1, total: 3 })).toBe("auto / 3");
|
||
expect(fmtLevel({ index: 0, total: 0 })).toBe(DASH);
|
||
});
|
||
|
||
it("fmtCodec strips profile suffixes and dashes when absent", () => {
|
||
expect(fmtCodec("avc1.640028", "mp4a.40.2")).toBe("avc1 / mp4a");
|
||
expect(fmtCodec("hvc1.1.6.L120", undefined)).toBe("hvc1");
|
||
expect(fmtCodec(undefined, undefined)).toBe(DASH);
|
||
});
|
||
});
|
||
|
||
describe("buildStatRows", () => {
|
||
const base: PlexStatsInput = {
|
||
playable: "remux",
|
||
transport: "hls",
|
||
videoWidth: 1280,
|
||
videoHeight: 720,
|
||
droppedFrames: 2,
|
||
totalFrames: 500,
|
||
bufferAheadS: 8.2,
|
||
hlsLevel: { index: 0, total: 1, width: 1280, height: 720, bitrate: 3_000_000 },
|
||
sessionStartS: 65,
|
||
abs: 130,
|
||
duration: 5214,
|
||
};
|
||
|
||
it("includes the HLS-only rows (codec/bitrate/level) for hls transport", () => {
|
||
const keys = buildStatRows(base).map((r) => r.key);
|
||
expect(keys).toEqual([
|
||
"mode",
|
||
"transport",
|
||
"res",
|
||
"codec",
|
||
"bitrate",
|
||
"level",
|
||
"frames",
|
||
"buffer",
|
||
"offset",
|
||
"position",
|
||
]);
|
||
});
|
||
|
||
it("drops the HLS-only rows for direct play", () => {
|
||
const keys = buildStatRows({ ...base, transport: "direct", hlsLevel: null }).map((r) => r.key);
|
||
expect(keys).not.toContain("codec");
|
||
expect(keys).not.toContain("bitrate");
|
||
expect(keys).not.toContain("level");
|
||
expect(keys).toContain("res");
|
||
});
|
||
|
||
it("surfaces the classification as the mode value and formats position", () => {
|
||
const rows = buildStatRows({ ...base, playable: "transcode" });
|
||
expect(rows.find((r) => r.key === "mode")?.value).toBe("transcode");
|
||
expect(rows.find((r) => r.key === "offset")?.value).toBe("1:05");
|
||
expect(rows.find((r) => r.key === "position")?.value).toBe("2:10 / 86:54");
|
||
});
|
||
|
||
it("appends the transcode encoder to the mode row when known", () => {
|
||
const gpu = buildStatRows({ ...base, playable: "transcode", hwEncoder: true });
|
||
expect(gpu.find((r) => r.key === "mode")?.value).toBe("transcode · GPU");
|
||
const cpu = buildStatRows({ ...base, playable: "transcode", hwEncoder: false });
|
||
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,
|
||
);
|
||
});
|
||
});
|
||
|
||
describe("fmtMode", () => {
|
||
it("appends the encoder only for a transcode with a known encoder", () => {
|
||
expect(fmtMode("transcode", true)).toBe("transcode · GPU");
|
||
expect(fmtMode("transcode", false)).toBe("transcode · CPU");
|
||
expect(fmtMode("transcode", null)).toBe("transcode"); // encoder unknown → bare mode
|
||
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
|
||
});
|
||
});
|