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>
| M | portal/ | 73 | ++++++++++++++++++--- |
| M | portal/ | 105 | +++++++++++++++++++++++++++++- |
@@ -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 }@@ -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);