cat / goq
clone

turns a linux box into a private steam streaming appliance: plug in, tap your key, play from another computer

fix(portal): probe H.264 decoder support (#102)

FelineStateMachine committed 2cb5380
Await WebCodecs H.264 support before publishing the delivery mode to Rust. Fail closed to the existing JPEG path when support is absent, rejected, or cannot be published.
commit
2cb5380110896da0b8f085a01b0d337a051f4c90
parent
4f77397944a19a8f3853296095a173126b368a4d
author
FelineStateMachine <dami.etoile@gmail.com>
3 files changed, +146 -5
Mportal/main.js10++--
Aportal/video-capability.mjs47+++++++++++++++
Aportal/video-capability.test.mjs94++++++++++++++++++++++++++++++
M portal/main.js +5 -5
A portal/video-capability.mjs +47 -0
@@ -0,0 +1,47 @@
1+const H264_PROBE_CONFIG = Object.freeze({2+ // Sigil's proven encoder path currently emits High Profile, Level 3.1 AVC.3+ // Deliberately omit coded dimensions: this tests the emitted codec contract4+ // without pinning Portal's capability decision to one host resolution.5+ codec: 'avc1.64001f',6+ optimizeForLatency: true,7+});8+9+/**10+ * Report WebCodecs only when this webview can decode Sigil's current H.26411+ * format. API presence alone is insufficient on codec-stripped webviews.12+ */13+export async function probeH264WebCodecsSupport({14+ VideoDecoder = globalThis.VideoDecoder,15+ logger = console,16+} = {}) {17+ if (typeof VideoDecoder !== 'function'18+ || typeof VideoDecoder.isConfigSupported !== 'function') return false;19+20+ try {21+ const support = await VideoDecoder.isConfigSupported({ ...H264_PROBE_CONFIG });22+ return support?.supported === true;23+ } catch (error) {24+ logger.warn('WebCodecs H.264 capability probe failed:', error);25+ return false;26+ }27+}28+29+/** Publish one delivery mode to both halves of Portal before a connection. */30+export async function detectAndPublishVideoDeliveryMode({31+ invokeCommand,32+ VideoDecoder = globalThis.VideoDecoder,33+ logger = console,34+} = {}) {35+ if (typeof invokeCommand !== 'function') {36+ throw new TypeError('invokeCommand must be a function');37+ }38+39+ const available = await probeH264WebCodecsSupport({ VideoDecoder, logger });40+ try {41+ await invokeCommand('set_webcodecs_available', { available });42+ return available;43+ } catch (error) {44+ logger.error('could not publish WebCodecs capability; using JPEG fallback:', error);45+ return false;46+ }47+}
A portal/video-capability.test.mjs +94 -0
@@ -0,0 +1,94 @@
1+import assert from 'node:assert/strict';2+import test from 'node:test';3+4+import {5+ detectAndPublishVideoDeliveryMode,6+ probeH264WebCodecsSupport,7+} from './video-capability.mjs';8+9+test('requires VideoDecoder and its asynchronous support probe', async () => {10+ assert.equal(await probeH264WebCodecsSupport({ VideoDecoder: undefined }), false);11+ assert.equal(await probeH264WebCodecsSupport({ VideoDecoder: class {} }), false);12+});13+14+test('reports only explicit support for Sigil current H.264 format', async () => {15+ let probedConfig = null;16+ class SupportedDecoder {17+ static async isConfigSupported(config) {18+ probedConfig = config;19+ return { supported: true, config };20+ }21+ }22+23+ assert.equal(await probeH264WebCodecsSupport({ VideoDecoder: SupportedDecoder }), true);24+ assert.deepEqual(probedConfig, {25+ codec: 'avc1.64001f',26+ optimizeForLatency: true,27+ });28+29+ class UnsupportedDecoder {30+ static async isConfigSupported(config) {31+ return { supported: false, config };32+ }33+ }34+ assert.equal(await probeH264WebCodecsSupport({ VideoDecoder: UnsupportedDecoder }), false);35+});36+37+test('fails closed when the webview rejects its H.264 support probe', async () => {38+ const warnings = [];39+ class RejectingDecoder {40+ static async isConfigSupported() {41+ throw new Error('codec support unavailable');42+ }43+ }44+45+ assert.equal(await probeH264WebCodecsSupport({46+ VideoDecoder: RejectingDecoder,47+ logger: { warn: (...args) => warnings.push(args) },48+ }), false);49+ assert.equal(warnings.length, 1);50+ assert.match(warnings[0][0], /capability probe failed/);51+});52+53+test('awaits the probe before publishing the exact raw delivery mode', async () => {54+ const calls = [];55+ let finishProbe;56+ class DelayedDecoder {57+ static isConfigSupported() {58+ calls.push('probe');59+ return new Promise((resolve) => { finishProbe = resolve; });60+ }61+ }62+ const pending = detectAndPublishVideoDeliveryMode({63+ VideoDecoder: DelayedDecoder,64+ invokeCommand: async (...args) => { calls.push(args); },65+ });66+67+ await Promise.resolve();68+ assert.deepEqual(calls, ['probe']);69+ finishProbe({ supported: true });70+ assert.equal(await pending, true);71+ assert.deepEqual(calls, [72+ 'probe',73+ ['set_webcodecs_available', { available: true }],74+ ]);75+});76+77+test('keeps frontend on JPEG when capability publication fails', async () => {78+ const errors = [];79+ class SupportedDecoder {80+ static async isConfigSupported() { return { supported: true }; }81+ }82+83+ assert.equal(await detectAndPublishVideoDeliveryMode({84+ VideoDecoder: SupportedDecoder,85+ invokeCommand: async () => { throw new Error('native command failed'); },86+ logger: { warn() {}, error: (...args) => errors.push(args) },87+ }), false);88+ assert.equal(errors.length, 1);89+ assert.match(errors[0][0], /using JPEG fallback/);90+});91+92+test('rejects a missing native publication boundary', async () => {93+ await assert.rejects(detectAndPublishVideoDeliveryMode(), TypeError);94+});