cat / goq
clone

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

fix(portal): make JPEG fallback latest-frame-wins

Dami committed a57de27
Bound the compatibility presenter to one current async image, invalidate stale callbacks across replacement and teardown, and attribute presentation telemetry only to the accepted newest frame. Add deterministic out-of-order load, error, and reset regression coverage.
commit
a57de273beda3658070338713a18994ba7d97ccb
parent
f53de5e47c6a2cadc2e3f951d89281f1ef61adef
author
Dami <dami.etoile@gmail.com>
2 files changed, +165 -13
Mportal/video-pipeline.mjs73++++++++++++++++++---
Mportal/video-pipeline.test.mjs105+++++++++++++++++++++++++++++-
M portal/video-pipeline.mjs +64 -9
@@ -58,6 +58,8 @@ export function createVideoPipelineSession({
5858 let presentedFrames = 0;5959 let presentationDroppedFrames = 0;6060 let lastVideoDrawCompletedAtMs = null;61+ let jpegFrameSequence = 0;62+ let pendingJpegFrame = null;6163 6264 const frontendDeliveryRate = new RollingRateWindow();6365 const decoderInputRate = new RollingRateWindow();
@@ -113,8 +115,29 @@ export function createVideoPipelineSession({
113115 resetAudioSync(...args);114116 }115117 118+ function releaseJpegFrame(frame) {119+ if (!frame || frame.released) return;120+ frame.released = true;121+ frame.image.onload = null;122+ frame.image.onerror = null;123+ revokeObjectUrl(frame.url);124+ }125+126+ function discardPendingJpegFrame({ overwritten = false } = {}) {127+ const frame = pendingJpegFrame;128+ if (!frame) return;129+ pendingJpegFrame = null;130+ if (overwritten) presentationDroppedFrames++;131+ releaseJpegFrame(frame);132+ }133+134+ function isCurrentJpegFrame(frame) {135+ return pendingJpegFrame === frame && frame.sequence === jpegFrameSequence;136+ }137+116138 function teardown() {117139 resetAvSync();140+ discardPendingJpegFrame();118141 framePresenter.clear();119142 decodeTimings.clear();120143 if (videoDecoder) {
@@ -384,20 +407,52 @@ export function createVideoPipelineSession({
384407 epoch: activeVideoFormatEpoch,385408 });386409 }410+ // Receipt order is authoritative. Invalidate the older async decode411+ // before starting this one so a late load can never repaint the past.412+ discardPendingJpegFrame({ overwritten: true });413+ const sequence = ++jpegFrameSequence;387414 const bytes = decodeBase64(data);388415 const blob = createBlob([bytes], { type: 'image/jpeg' });389416 const url = createObjectUrl(blob);390- const image = createImage();417+ let image;418+ try {419+ image = createImage();420+ } catch (error) {421+ revokeObjectUrl(url);422+ throw error;423+ }424+ const jpegFrame = {425+ sequence,426+ image,427+ url,428+ receivedAt,429+ released: false,430+ };431+ pendingJpegFrame = jpegFrame;391432 image.onload = () => {433+ if (!isCurrentJpegFrame(jpegFrame)) return;434+ pendingJpegFrame = null;392435 const drawStartedAt = now();393- context.drawImage(image, 0, 0, canvas.width, canvas.height);394- revokeObjectUrl(url);395- const presentedAt = now();396- presentedFrames++;397- presentationRate.record(presentedAt);398- presentationCadence.record(presentedAt);399- drawLatency.record(presentedAt - drawStartedAt, presentedAt);400- clientPresentationLatency.record(presentedAt - receivedAt, presentedAt);436+ try {437+ context.drawImage(image, 0, 0, canvas.width, canvas.height);438+ const presentedAt = now();439+ presentedFrames++;440+ presentationRate.record(presentedAt);441+ presentationCadence.record(presentedAt);442+ drawLatency.record(presentedAt - drawStartedAt, presentedAt);443+ clientPresentationLatency.record(444+ presentedAt - jpegFrame.receivedAt,445+ presentedAt,446+ );447+ } finally {448+ releaseJpegFrame(jpegFrame);449+ }450+ };451+ image.onerror = () => {452+ if (!isCurrentJpegFrame(jpegFrame)) return;453+ pendingJpegFrame = null;454+ droppedFrames++;455+ releaseJpegFrame(jpegFrame);401456 };402457 image.src = url;403458 }
M portal/video-pipeline.test.mjs +101 -4
@@ -28,6 +28,7 @@ function harness({ hasWebCodecs = true } = {}) {
2828 const draws = [];2929 const revokedUrls = [];3030 const images = [];31+ let nextObjectUrl = 0;3132 const canvas = { width: 0, height: 0 };3233 const context = {3334 drawImage: (...args) => draws.push(args),
@@ -65,10 +66,10 @@ function harness({ hasWebCodecs = true } = {}) {
6566 },6667 decodeBase64: () => new Uint8Array([1, 2, 3]),6768 createBlob: (parts, options) => ({ parts, options }),68- createObjectUrl: () => 'blob:test-frame',69+ createObjectUrl: () => `blob:test-frame-${++nextObjectUrl}`,6970 revokeObjectUrl: (url) => revokedUrls.push(url),7071 createImage: () => {71- const image = { onload: null, src: null };72+ const image = { onload: null, onerror: null, src: null };7273 images.push(image);7374 return image;7475 },
@@ -218,17 +219,113 @@ test('JPEG fallback keeps its asynchronous base64 draw behavior', () => {
218219 assert.deepEqual(subject.formats, [subject.pipeline.format]);219220 assert.equal(subject.canvas.width, 640);220221 assert.equal(subject.canvas.height, 400);221- assert.equal(subject.images[0].src, 'blob:test-frame');222+ assert.equal(subject.images[0].src, 'blob:test-frame-1');222223 assert.deepEqual(subject.draws, []);223224 assert.deepEqual(subject.revokedUrls, []);224225 225226 subject.advance();226227 subject.images[0].onload();227228 assert.equal(subject.draws.length, 1);228- assert.deepEqual(subject.revokedUrls, ['blob:test-frame']);229+ assert.deepEqual(subject.revokedUrls, ['blob:test-frame-1']);229230 assert.equal(subject.pipeline.snapshot().presentedFrames, 1);230231 });231232 233+test('JPEG fallback presents only the newest frame when loads complete out of order', () => {234+ const subject = harness({ hasWebCodecs: false });235+ const payload = {236+ width: 640,237+ height: 400,238+ data: 'AQID',239+ keyframe: true,240+ };241+ subject.pipeline.processFramePayload(payload);242+ const staleLoad = subject.images[0].onload;243+ const staleError = subject.images[0].onerror;244+245+ subject.advance(10);246+ subject.pipeline.processFramePayload(payload);247+ const newestLoad = subject.images[1].onload;248+ assert.deepEqual(subject.revokedUrls, ['blob:test-frame-1']);249+ assert.equal(subject.pipeline.snapshot().presentationDroppedFrames, 1);250+251+ subject.advance(5);252+ newestLoad();253+ const afterNewest = subject.pipeline.snapshot();254+ assert.deepEqual(subject.draws.map(([image]) => image), [subject.images[1]]);255+ assert.equal(afterNewest.presentedFrames, 1);256+ assert.equal(afterNewest.presentPercentiles.count, 1);257+ assert.equal(afterNewest.presentPercentiles.p50, 5);258+ assert.equal(afterNewest.presentationDroppedFrames, 1);259+260+ subject.advance(100);261+ staleLoad();262+ staleError(new Error('stale JPEG failure'));263+ const afterStaleCallbacks = subject.pipeline.snapshot();264+ assert.deepEqual(subject.draws.map(([image]) => image), [subject.images[1]]);265+ assert.equal(afterStaleCallbacks.presentedFrames, 1);266+ assert.equal(afterStaleCallbacks.presentPercentiles.count, 1);267+ assert.equal(afterStaleCallbacks.presentPercentiles.p50, 5);268+ assert.equal(afterStaleCallbacks.droppedFrames, 0);269+ assert.deepEqual(subject.revokedUrls, [270+ 'blob:test-frame-1',271+ 'blob:test-frame-2',272+ ]);273+});274+275+test('JPEG fallback error callbacks affect only the current pending frame', () => {276+ const subject = harness({ hasWebCodecs: false });277+ const payload = {278+ width: 640,279+ height: 400,280+ data: 'AQID',281+ keyframe: true,282+ };283+ subject.pipeline.processFramePayload(payload);284+ const staleError = subject.images[0].onerror;285+286+ subject.pipeline.processFramePayload(payload);287+ const failedLoad = subject.images[1].onload;288+ const currentError = subject.images[1].onerror;289+ staleError(new Error('stale JPEG failure'));290+ assert.equal(subject.pipeline.snapshot().droppedFrames, 0);291+292+ currentError(new Error('current JPEG failure'));293+ failedLoad();294+ const afterFailure = subject.pipeline.snapshot();295+ assert.equal(afterFailure.droppedFrames, 1);296+ assert.equal(afterFailure.presentedFrames, 0);297+ assert.deepEqual(subject.draws, []);298+ assert.deepEqual(subject.revokedUrls, [299+ 'blob:test-frame-1',300+ 'blob:test-frame-2',301+ ]);302+});303+304+test('JPEG fallback callbacks cannot survive a session reset', () => {305+ const subject = harness({ hasWebCodecs: false });306+ subject.pipeline.processFramePayload({307+ width: 640,308+ height: 400,309+ data: 'AQID',310+ keyframe: true,311+ });312+ const staleLoad = subject.images[0].onload;313+ const staleError = subject.images[0].onerror;314+315+ subject.pipeline.reset();316+ subject.advance();317+ staleLoad();318+ staleError(new Error('reset JPEG failure'));319+320+ const stats = subject.pipeline.snapshot();321+ assert.equal(stats.receivedFrames, 0);322+ assert.equal(stats.presentedFrames, 0);323+ assert.equal(stats.droppedFrames, 0);324+ assert.equal(stats.presentPercentiles.count, 0);325+ assert.deepEqual(subject.draws, []);326+ assert.deepEqual(subject.revokedUrls, ['blob:test-frame-1']);327+});328+232329 test('reset closes decoding state but preserves the last dimensions and codec quirk', () => {233330 const subject = harness();234331 processH264Keyframe(subject);