// tests/offscreen/segment-keyframes.test.ts // // RED-gate test for debug session webm-playback-freeze. // // Algorithmic / unit-level companion to webm-playback.test.ts. Where that // test runs ffmpeg over the committed fixture (empirical, requires ffmpeg in // PATH, requires the fixture to be regenerated after the fix), THIS test // works against a pure-data model of the recorder behaviour and runs in any // vitest environment without external tooling. // // Model // ----- // // We simulate a 30 fps capture in which Chrome emits a VP9 keyframe every // `KF_PERIOD_S = 3` seconds (kf_max_dist=100 ≈ 3.33 s; we round down for a // conservative test). The recorder is configured with // `MediaRecorder.start(TIMESLICE_MS)`, so chunks fire every 2 s — NOT aligned // to keyframes. We classify each emitted chunk by whether it contains a // keyframe ("kf-bearing") or only P-frames ("p-only"). // // Failure mode (D-09..D-11 — current behaviour) // --------------------------------------------- // // `addChunk` from src/offscreen/recorder.ts pins the FIRST chunk (which holds // the WebM header + an initial keyframe) and then ages out chunks older than // 30 s. After ~30 s of recording, the kept set is: // // [chunk_0 (header, kf)] + [chunks emitted in the last 30 s] // // The last-30-s tail contains chunks that may have started mid-GOP. When the // SW concatenates `chunk_0` with the tail, the tail's first P-frames // reference keyframes that lived in trimmed-out middle chunks. Result: // decoder error ~1 s past `chunk_0`'s end. // // Fix (D-13 restart-segments) // --------------------------- // // Stop + restart the MediaRecorder every SEGMENT_MS = 10 s on the same // MediaStream. Each restart forces a new WebM header AND a new keyframe at // the segment's start (since the encoder is freshly initialized). Keep the // last `MAX_SEGMENTS = 3` segments (= 30 s). Each segment in the kept window // is self-contained — its first chunk is kf-bearing. // // Test structure // -------------- // // block 1 — "RED — D-09..D-11 leaks P-only chunks past trim": // Pure-simulation tests that document the current bug. Pass today; // they encode the failure mode as executable evidence. (They will keep // passing post-fix; their purpose is documentation + regression guard // against re-introducing single-continuous-recorder semantics.) // // block 2 — "GREEN-pinning — D-13 contract for restart-segments": // Pure-simulation tests that pin the segment-based fix's contract. // Pass today; their purpose is to give the fix's reviewer an // algorithmic spec to check against before reading code. // // block 3 — "production recorder must expose segment-aware buffer (RED)": // Imports src/offscreen/recorder.ts and asserts a `getSegments` API // exists with the D-13 shape. GOES RED TODAY because the production // code only exposes `getBuffer()` (chunk-level). FLIPS GREEN when the // D-13 skeleton at src/offscreen/recorder.ts:298-316 is activated and // a `getSegments` export is added. This is the genuine TDD anchor. import { describe, it, expect, beforeEach } from 'vitest'; // ─── Recorder model parameters ────────────────────────────────────────── const TIMESLICE_MS = 2_000; // matches src/offscreen/recorder.ts TIMESLICE_MS const VIDEO_BUFFER_DURATION_MS = 30_000; // matches VIDEO_BUFFER_DURATION_MS const KF_PERIOD_MS = 3_000; // Chrome VP9 default kf_max_dist=100 ≈ 3 s @ 30 fps const SEGMENT_MS = 10_000; // D-13 design — see CONTEXT.md const MAX_SEGMENTS = 3; // D-13 design — keep last 3 segments (30 s) interface SimChunk { index: number; emittedAtMs: number; hasKeyframe: boolean; isFirstEmitted: boolean; } interface SimSegment { startMs: number; endMs: number; chunks: SimChunk[]; } // ─── Simulation: single continuous MediaRecorder (D-09..D-11) ────────── function simulateContinuousRecorder(totalDurationMs: number): SimChunk[] { const chunks: SimChunk[] = []; const totalChunks = Math.floor(totalDurationMs / TIMESLICE_MS); for (let i = 0; i < totalChunks; i++) { const emittedAt = (i + 1) * TIMESLICE_MS; // A chunk covers [emittedAt - TIMESLICE_MS, emittedAt]. It contains a // keyframe iff a keyframe boundary falls strictly inside that interval. const intervalStart = emittedAt - TIMESLICE_MS; // Index of the first keyframe at-or-after intervalStart. const firstKfIdx = Math.ceil(intervalStart / KF_PERIOD_MS); const firstKfMs = firstKfIdx * KF_PERIOD_MS; const hasKf = firstKfMs >= intervalStart && firstKfMs < emittedAt; chunks.push({ index: i, emittedAtMs: emittedAt, hasKeyframe: hasKf, isFirstEmitted: i === 0, }); } return chunks; } // Mirrors trimAged() from src/offscreen/recorder.ts: pin the first-flagged // chunk, drop everything else older than VIDEO_BUFFER_DURATION_MS. function trimContinuousBuffer(chunks: SimChunk[], nowMs: number): SimChunk[] { const cutoff = nowMs - VIDEO_BUFFER_DURATION_MS; return chunks.filter((c) => c.isFirstEmitted || c.emittedAtMs >= cutoff); } // ─── Simulation: restart-segments (D-13) ────────────────────────────── function simulateSegmentRecorder(totalDurationMs: number): SimSegment[] { const segments: SimSegment[] = []; const totalSegments = Math.floor(totalDurationMs / SEGMENT_MS); for (let s = 0; s < totalSegments; s++) { const segStart = s * SEGMENT_MS; const segEnd = segStart + SEGMENT_MS; const chunks: SimChunk[] = []; // Each segment's first chunk is always kf-bearing because the MediaRecorder // is freshly constructed on segment rotation — the encoder always emits // an initial keyframe. const chunksPerSegment = Math.floor(SEGMENT_MS / TIMESLICE_MS); for (let i = 0; i < chunksPerSegment; i++) { const emittedAt = segStart + (i + 1) * TIMESLICE_MS; chunks.push({ index: i, emittedAtMs: emittedAt, hasKeyframe: i === 0, // the fresh recorder always seeds a keyframe isFirstEmitted: i === 0, }); } segments.push({ startMs: segStart, endMs: segEnd, chunks }); } return segments; } function keepLastSegments(segments: SimSegment[]): SimSegment[] { return segments.slice(-MAX_SEGMENTS); } // ─── Tests ────────────────────────────────────────────────────────────── describe('segment keyframes (documentation — D-09..D-11 leaks P-only chunks past trim)', () => { it('continuous-recorder model has chunks with no keyframe (proves the gap exists)', () => { // Sanity check the model: with TIMESLICE_MS=2000 and KF_PERIOD_MS=3000, // a 2-s chunk window can sometimes contain no keyframe at all. const chunks = simulateContinuousRecorder(60_000); const pOnly = chunks.filter((c) => !c.hasKeyframe); expect(pOnly.length).toBeGreaterThan(0); // And the count is meaningful — significantly more than just the // boundary between two 3-s GOPs. Model integrity check. expect(pOnly.length / chunks.length).toBeGreaterThan(0.25); }); it('after 60 s, trimming to 30 s leaves the pinned first chunk + P-only tail chunks orphaned from their keyframes', () => { const allChunks = simulateContinuousRecorder(60_000); const kept = trimContinuousBuffer(allChunks, 60_000); // The pinned first chunk is still there. expect(kept[0].isFirstEmitted).toBe(true); expect(kept[0].hasKeyframe).toBe(true); // The tail (everything after the pinned first chunk) contains AT LEAST // one P-only chunk that immediately follows the pinned header, with // no kf-bearing chunk in between to anchor it. THIS is the freeze // mechanism: the decoder accepts the pinned header + its keyframe, // then hits the tail's first P-frame whose reference keyframe lived // in a trimmed-out chunk. const tail = kept.slice(1); const firstTailChunkIsPOnly = tail.length > 0 && !tail[0].hasKeyframe; // Pin the failure: the tail does start with a P-only chunk, and the // gap between pinned-kf and the next kf-bearing chunk in the tail is // greater than what a single GOP can survive. expect(firstTailChunkIsPOnly).toBe(true); // The gap between pinned chunk's keyframe and the next kf-bearing // chunk in the tail is the time the decoder will play before freezing. const pinnedKfMs = kept[0].emittedAtMs; const firstTailKfChunk = tail.find((c) => c.hasKeyframe); expect(firstTailKfChunk).toBeDefined(); // The decoder needs every P-frame's reference keyframe present. // Between pinnedKfMs and firstTailKfChunk.emittedAtMs there are // P-only chunks whose references were trimmed → freeze. const orphanGapMs = firstTailKfChunk!.emittedAtMs - pinnedKfMs; expect(orphanGapMs).toBeGreaterThan(KF_PERIOD_MS); }); }); describe('segment keyframes (GREEN-pinning — D-13 contract for restart-segments)', () => { it('each retained segment starts with a keyframe', () => { const allSegments = simulateSegmentRecorder(60_000); const kept = keepLastSegments(allSegments); expect(kept).toHaveLength(MAX_SEGMENTS); for (const seg of kept) { expect(seg.chunks.length).toBeGreaterThan(0); expect( seg.chunks[0].hasKeyframe, `Segment starting at ${seg.startMs}ms is missing a keyframe in its first chunk. ` + `Under D-13 the MediaRecorder must be freshly constructed on each rotation so ` + `the encoder seeds a keyframe at segment t=0.`, ).toBe(true); } }); it('kept window spans exactly MAX_SEGMENTS * SEGMENT_MS = 30 s', () => { const allSegments = simulateSegmentRecorder(60_000); const kept = keepLastSegments(allSegments); const spanMs = kept[kept.length - 1].endMs - kept[0].startMs; expect(spanMs).toBe(MAX_SEGMENTS * SEGMENT_MS); expect(spanMs).toBe(VIDEO_BUFFER_DURATION_MS); }); it('concatenating retained segments yields a fully decodable timeline (no orphan P-frames)', () => { // Decodability invariant: every chunk in the concatenated stream either // IS kf-bearing or is preceded (within the SAME segment) by a kf-bearing // chunk. Under D-13 this is satisfied trivially because each segment's // first chunk is kf-bearing and the segment is self-contained. const allSegments = simulateSegmentRecorder(60_000); const kept = keepLastSegments(allSegments); for (const seg of kept) { let lastKfBearingInSegment = -1; for (let i = 0; i < seg.chunks.length; i++) { if (seg.chunks[i].hasKeyframe) { lastKfBearingInSegment = i; } // Every chunk must have a kf-bearing predecessor (or itself) inside // the segment. If lastKfBearingInSegment is still -1 we've found a // P-only chunk with no anchoring keyframe — the freeze condition. expect( lastKfBearingInSegment, `Chunk ${i} of segment ${seg.startMs}ms has no preceding keyframe in its segment.`, ).toBeGreaterThanOrEqual(0); } } }); it('a continuous-recorder buffer that trims out middle chunks DOES exhibit the orphan-keyframe gap (the bug, restated as code)', () => { // This is the mirror image of the D-13 invariant test above: prove that // the D-09..D-11 approach explicitly exhibits the orphan-keyframe gap. // That empirically lock-steps the test pair: GREEN on D-13 ⇔ orphan-gap on D-09..D-11. const allChunks = simulateContinuousRecorder(60_000); const kept = trimContinuousBuffer(allChunks, 60_000); // Note: under D-09..D-11 the pinned first chunk IS kf-bearing, so a naive // "every chunk has a preceding kf in the kept buffer" check passes. The // real bug is that the tail's P-frames reference KEYFRAMES THAT WERE // TRIMMED FROM THE MIDDLE OF THE TIMELINE — those keyframes are not in // `kept` because they came from chunks evicted by the age trim. We // assert this via the gap evidence: there is a stretch in the kept // timeline where no kf-bearing chunk appears between the pinned header // and the recent tail. const pinnedKfMs = kept[0].emittedAtMs; const firstTailKfChunk = kept.slice(1).find((c) => c.hasKeyframe); expect(firstTailKfChunk).toBeDefined(); const orphanGapMs = firstTailKfChunk!.emittedAtMs - pinnedKfMs; // The decoder will freeze for orphanGapMs - KF_PERIOD_MS worth of frames // because their reference keyframes were in trimmed chunks. We require // the gap to be much larger than KF_PERIOD_MS — i.e. trimmed material // contained keyframes that the kept material depends on. expect(orphanGapMs).toBeGreaterThan(KF_PERIOD_MS * 2); }); }); describe('production recorder must expose segment-aware buffer (RED — pins D-13)', () => { // This block is the genuine TDD anchor. It drives an import of the real // src/offscreen/recorder.ts and asserts that a `getSegments` export exists // with a shape consistent with the D-13 contract. // // Today this is RED: the module exports `getBuffer()` (chunk-level), not // `getSegments()` (segment-level). The activation of the D-13 skeleton at // src/offscreen/recorder.ts:298-316 must: // 1. Maintain a `segments: Blob[]` array (each entry = one finalized // ~10 s self-contained WebM). // 2. Rotate segments via stop+restart-on-same-MediaStream every // SEGMENT_MS, keeping at most MAX_SEGMENTS. // 3. Export a `getSegments(): Blob[]` function. (The wire format on the // port stays base64-per-segment per D-12.) // // We use vitest's beforeEach + vi.resetModules pattern from // codec-check.test.ts so the module's bootstrap side-effects don't poison // the test environment. interface ChromeStub { runtime: { sendMessage?: (msg: unknown) => void; onMessage?: { addListener?: (cb: unknown) => void }; connect?: () => unknown; id?: string; }; } interface GlobalWithChrome { chrome?: ChromeStub; MediaRecorder?: { isTypeSupported: (mime: string) => boolean }; } beforeEach(async () => { const { vi } = await import('vitest'); vi.resetModules(); (globalThis as unknown as GlobalWithChrome).chrome = { runtime: { id: 'test', sendMessage: () => {} }, }; }); it('src/offscreen/recorder exports a getSegments function', async () => { const mod = (await import('../../src/offscreen/recorder')) as Record< string, unknown >; // RED today — recorder.ts only exports getBuffer/addChunk/trimAged/etc. // GREEN when D-13 lands and getSegments is added. expect( typeof mod.getSegments, 'src/offscreen/recorder.ts must export `getSegments(): Blob[]` once ' + 'the D-13 restart-segments skeleton is activated. Today it only ' + 'exports the chunk-level `getBuffer()`, which is the API responsible ' + 'for the orphan-keyframe gap in tests/fixtures/last_30sec.webm. See ' + '.planning/debug/webm-playback-freeze.md and the commented skeleton ' + 'at src/offscreen/recorder.ts:298-316.', ).toBe('function'); }); it('getSegments returns at most MAX_SEGMENTS=3 Blobs', async () => { const mod = (await import('../../src/offscreen/recorder')) as { getSegments?: () => Blob[]; }; if (typeof mod.getSegments !== 'function') { // Skip the body — the structural test above is the one that drives // the fix. We still want this assertion documented as a contract. expect.fail( 'getSegments not exported yet; see the previous test in this block ' + 'for the activation instructions.', ); return; } const segments = mod.getSegments(); expect(Array.isArray(segments)).toBe(true); expect(segments.length).toBeLessThanOrEqual(MAX_SEGMENTS); }); });