REQ-video-ring-buffer flipped from [x] back to [ ]. ROADMAP.md Phase 1 row reverted from [x] Closed 2026-05-15 to [ ] reopened 2026-05-16. STATE.md status flipped phase_complete → phase_reopened with full historical narrative preserved. Root cause (confirmed at byte level by gsd-debugger 2026-05-16): D-13's concat-of-self-contained-WebM-segments architecture produces a 3-EBML-header WebM that standards-compliant Matroska parsers (mpv, ffmpeg, Chrome HTMLMediaElement) play only as the first segment (~9.94 s) and silently drop the remaining 2 segments. Confirmed via operator mpv drag-drop test of BOTH the canonical 2026-05-15 closure fixture and the 2026-05-16 UAT-produced fixture — both exhibit the same broken playback. The 2026-05-15 "operator-confirmed clean Chrome playback" assessment was insufficient: it verified the file plays without freezing but did not measure total duration. Phase 1's primary deliverable (REQ-video-ring-buffer / SPEC §10 #7) is therefore NOT satisfied. Fix path chosen by user: ts-ebml (parse) + webm-muxer (write) to replace mergeVideoSegments file-concat with real single-EBML remux. Will land as Plan 01-08 via fresh /gsd-plan-phase ceremony. RED test landed in tests/offscreen/webm-playback.test.ts (2 new assertions on container-format-duration + ffmpeg-full-decode-duration). 2 failures, 53 baseline tests still GREEN. Option C port-lifecycle refactor (debug session empty-archive-port-race, commits 674c415..f0871c0) DID land cleanly and is retained — that fix was orthogonal and correctly resolved the silent-empty-archive symptom that previously masked this deeper bug. Debug session: .planning/debug/d13-multi-ebml-concat-unplayable.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
26 KiB
slug, status, trigger, created, updated, phase, related_uat, related_review_fix, prior_resolved_sessions, architectural_impact
| slug | status | trigger | created | updated | phase | related_uat | related_review_fix | prior_resolved_sessions | architectural_impact | |||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| d13-multi-ebml-concat-unplayable | investigating | Phase 1 UAT Test 3 re-attempt post-Option-C produced a structurally-correct 3-segment WebM (SW logs confirm: "Merging 3 segments / Adding segment 0 size: 672159 / 1 size: 507559 / 2 size: 496181 / Final video blob size: 1675899 bytes, total segments merged: 3") but the resulting file plays ONLY ~9 s in Chrome AND in mpv. Cross-checking the canonical fixture committed at Phase 1 closure on 2026-05-15 (`tests/fixtures/last_30sec.webm`, 1633459 bytes, 3 segments per architecture) reveals it ALSO plays only ~9 s in mpv. Operator confirmed both via mpv playback test. This means D-13's "concat of self-contained WebM segments → playable 30 s WebM" architecture is fundamentally broken. The 2026-05-15 Phase 1 closure was certified on an insufficient "operator-confirmed clean Chrome playback" check that did not actually verify 30 s duration — both the closure fixture and today's UAT-produced fixture exhibit the same first-segment-only-plays behavior. Phase 1's primary deliverable (REQ-video-ring-buffer) does not actually produce a playable 30 s WebM. SPEC §10 #7 (`last_30sec.webm plays back in a browser`) is NOT satisfied by the current architecture even though it was marked Complete in REQUIREMENTS.md/ROADMAP.md/STATE.md on 2026-05-15. | 2026-05-16T16:56:41Z | 2026-05-16T17:25:00Z | 01-stabilize-video-pipeline | .planning/phases/01-stabilize-video-pipeline/01-UAT.md | .planning/phases/01-stabilize-video-pipeline/01-REVIEW-FIX.md |
|
This is NOT a code-level bug; it's a wrong-architecture finding. D-09..D-11 (single-continuous + age-trim + first-chunk-pin) was retired in favor of D-13 (restart-segments + concat) on 2026-05-15 because D-09..D-11 caused orphan-P-frame freezes (debug session webm-playback-freeze). D-13 was supposed to fix that by making each segment self-contained with its own EBML header + seed keyframe. But D-13 only solved the freeze symptom — it did NOT solve the underlying problem of producing a single playable 30 s WebM. Players see the first EBML header, read its duration metadata (~9.94 s), and stop there. Most Matroska/WebM players (ffmpeg/mpv/probably Chrome) do not implement the multi-segment Matroska feature; the spec permits it but doesn't mandate it. The fix requires real WebM REMUX: extract the VP9 frames + cluster timestamps from each of the 3 segments and rewrite them into a single EBML-headered WebM with adjusted timestamps. This is significantly more work than D-13 (~500-1000 LOC for a JS remuxer) but architecturally necessary. |
Debug: D-13 multi-EBML-concat produces unplayable WebM (Phase 1 architecture failure)
Symptoms
Expected behavior:
When the operator clicks save, the produced video/last_30sec.webm plays
for ~30 s in a browser (SPEC §10 #7) covering the most recent 30 s of
captured screen.
Actual behavior:
- WebM file is structurally valid (3 segments concatenated per D-13 design)
- All 3 segments arrive at SW per logs: [SW:Main] Video buffer: 3 segments [SW:Main] Merging 3 segments [SW:Main] Adding segment 0, size: 672159 bytes [SW:Main] Adding segment 1, size: 507559 bytes [SW:Main] Adding segment 2, size: 496181 bytes [SW:Main] Final video blob size: 1675899 bytes, total segments merged: 3
- Resulting file (1675899 bytes) plays only ~9 s in Chrome
- Same file plays only ~9 s in mpv
- The canonical Phase 1 closure fixture from 2026-05-15
(
tests/fixtures/last_30sec.webm, 1633459 bytes) ALSO plays only ~9 s in mpv — operator verified by drag-drop test
Error messages: None at the runtime layer. Recording is healthy, SW merge is healthy, download is healthy. The bug is in the PRODUCED FILE'S COMPATIBILITY with downstream players.
ffprobe reports duration=9.94 s on both files — the first EBML
header's reported duration. ffmpeg dry-run produces 299 muxer warnings
(non-monotonic DTS at segment join boundaries) for both files — that's
the segment boundary noise from concatenation, not playback failure.
Timeline:
- Bug introduced: commit
6a1a034(Plan 01-07-debug-a3, 2026-05-15 "feat(fix-a3): activate D-13 restart-segments in src/offscreen/recorder.ts"- commit
5530292"feat(fix-a3): retire ring-buffer first-chunk pin tests, add segment-rotation contract")
- commit
- Operator-validated incorrectly: commit
cd61cbc(2026-05-15 "test(01-07): commit regenerated last_30sec.webm fixture against D-13 recorder") + commit7df72aa(2026-05-15 "feat(01-07): close Phase 1 — REQ-video-ring-buffer complete, SPEC §10 #7 satisfied"). The "operator confirmed clean Chrome playback" assessment was insufficient — it checked that the file played but did not measure the total playback duration. - Discovered: 2026-05-16 UAT Test 3 re-attempt after Option C debug
session (
.planning/debug/resolved/empty-archive-port-race.md) fixed the silent-empty-video archive bug. With the empty-video symptom retired, the underlying broken-playback issue surfaced cleanly.
Reproduction:
npm run buildKEEP_PROFILE=0 ./smoke.sh- Load extension, click icon, wait 5+ minutes, click save
- Extract
video/last_30sec.webmfrom the produced zip - Open in mpv or Chrome — playback stops at ~9 s instead of ~30 s
- Verify the file structurally contains 3 segments via:
ffmpeg -v warning -i FILE -f null -(produces ~299 muxer warnings = 3 segment join boundaries) - OR verify against committed fixture: same behavior
(
/tmp/mokosh-test-committed-3seg.webmand/tmp/mokosh-test-uat-3seg.webmboth play 9 s in mpv per operator)
Current Focus
hypothesis: |
H4 confirmed by byte-level EBML probe (2026-05-16T17:10Z, see
Evidence/H4 below): D-13's "concat of self-contained WebM
segments → produce playable 30 s WebM" architecture does not work
because standards-compliant Matroska parsers (mpv, mkvtoolnix,
Chrome's HTMLMediaElement, ffprobe's format=duration path) honor
the FIRST Segment element's Info.Duration EBML (~9_934 ms for the
fixture) and stop there. Even ffmpeg's matroska DEMUXER — which is
unusually liberal and reads through the second segment's EBML
header — collapses segments 2..N onto seg1's local timestamp axis
(verified empirically: 601 packets decoded from segs 1+2, ZERO
packets from seg3, output time=00:00:09.96). Multi-segment
Matroska is technically permitted by the spec but in practice
consumer-grade players do not implement it.
H3 confirmed by operator empirical test: The 2026-05-15 Phase 1 closure's "operator-confirmed clean Chrome playback" check was insufficient. The check did not measure total playback duration. Both the canonical committed fixture and today's UAT-produced fixture exhibit the same first-segment-only-plays behavior; the bug has existed since D-13 was activated on 2026-05-15.
Fix direction: replace the file-concat merge with a real WebM REMUX. Parse each segment's EBML structure, extract VP9 frames + cluster boundaries + keyframe positions, write a SINGLE-EBML-header WebM whose clusters carry adjusted (monotonic) timestamps. This produces a file that any player can read end-to-end as one continuous ~30 s stream.
Candidate implementations (researched 2026-05-16, see Evidence/library-survey below for full table):
webm-muxer5.1.4 (Vanilagy, MIT, last release 2025-07-02, gzipped ~12 KB, pure ESM/CJS no DOM globals).addVideoChunkRaw(data, type:'key'|'delta', timestamp, meta?)accepts already-encoded VP9 frames — exactly the shape produced by a stream of existing WebM segments. SW-compatible. PRIMARY CANDIDATE for the write half.ts-ebml3.0.2 (legokichi, MIT, last release 2025-09-28, gzipped ~87 KB, UMD has a singletypeof windowcheck with self-fallback so SW-compatible). Decoder+Encoder API. Needed for the parse half (extract VP9 SimpleBlock payloads + cluster timecodes + keyframe flags from each segment).ebml3.0.0 (node-ebml, MIT, last release 2018-09-06 — dead upstream). Smaller but unmaintained.mp4-muxer5.2.2 (sibling of webm-muxer; not applicable — we need WebM container output).- Custom EBML parser (full control, ~500-1000 LOC, no dep weight)
- Alternative path: MediaRecorder timeslice with cluster-aware trim: revisit retired D-09..D-11 architecture but trim ONLY on keyframe boundaries (preserving every cluster from the most recent keyframe onwards). See Evidence/cluster-aware-trim below — the DETERMINISTIC floor on retained-content duration is much less than 30 s (worst-case: keyframe just emitted → retain only the post-keyframe sliver) because VP9 kf_max_dist under Chrome's MediaRecorder is irregular (3-5 s typical, 26 s observed in the prior debug session). This path produces a NON-DETERMINISTIC content window; rejected as architecturally weaker than remux.
- Alternative path: WebCodecs API (VideoEncoder + Muxer.js or similar): full control over container framing. Significant rewrite (~1000-2000 LOC). Most flexible but heaviest. WebCodecs is available in MV3 service workers per Chrome 94+ — viable but over-engineered for the current need (we already have VP9 frames, we just need to RE-CONTAIN them).
The recommendation (TIEBREAKER only — the user makes the call):
ts-ebml (parse) + webm-muxer (write) is the smallest fix that
matches the actual problem shape. Combined ~100 KB gzipped, both
MIT, both actively maintained, both verified SW-compatible. Net
source-edit LOC ~150-300 in src/background/index.ts
mergeVideoSegments() — we don't decode/re-encode VP9 frames, we
just parse them out of segments and re-emit with monotonic
timestamps. Preserves D-13's recorder-side lifecycle (which DID
fix the orphan-P-frame freeze) and adds a single new SW-side
remux pass on the save path.
test: |
RED test LANDED at tests/offscreen/webm-playback.test.ts. Two new
assertions in the new describe('webm playable duration (RED — confirms d13-multi-ebml-concat-unplayable bug)') block:
-
container-level format=duration on last_30sec.webm exceeds 25 s— uses ffprobe to readformat=duration. Asserts>= MIN_PLAYABLE_DURATION_MS = 25_000. RED today (actual: 9_934 ms). -
ffmpeg full decode of last_30sec.webm reaches at least 25 s of timeline— parses the lasttime=HH:MM:SS.MSfromffmpeg -stats -f null -output. Asserts>= 25_000 ms. RED today (actual: 9_960 ms).
Both gate behind it.skipIf(!ffprobeAvailable()) /
it.skipIf(!ffmpegAvailable()) so CI environments without those
binaries auto-skip rather than hard-fail (matches the existing
webm-playback.test.ts skip discipline). The existing two structural-
validity tests in the same file (...zero decoder packet errors and
...does not end prematurely) remain GREEN and untouched.
expecting: |
RED test fails on current code (both fixture and freshly-recorded
output should fail the duration assertion). Debugger then implements
the chosen fix path (webm-muxer + ts-ebml remux most likely) and
re-asserts GREEN. RED confirmed 2026-05-16T17:20Z: 11 test files,
53 passed + 2 failed (the two new assertions). All pre-existing
tests still GREEN; tsc clean (exit 0).
next_action: CHECKPOINT to orchestrator — root cause confirmed, RED test landed, fix-strategy options surfaced; awaiting user's chosen path via orchestrator routing.
reasoning_checkpoint: |
Why CHECKPOINT here rather than execute: the choice between
ts-ebml + webm-muxer vs custom EBML parser vs cluster-aware trim revisit of D-09..D-11 vs WebCodecs rewrite is architecturally
significant (it determines whether Phase 1's deliverable stays in
the debug-session hotfix lane OR escalates to a fresh Plan 01-08,
and whether the project gains two new runtime deps). Per the
feedback memory feedback-no-unilateral-scope-reduction.md the
debugger does not narrow this for the user — surface options and
let the user pick.
tdd_checkpoint: |
RED gate honored. Two new failing assertions in
tests/offscreen/webm-playback.test.ts pin the playable-duration
contract that the 2026-05-15 closure check missed. Existing
structural-validity tests remain GREEN. tsc clean. Full vitest run
reports Test Files 1 failed | 10 passed (11) / Tests 2 failed | 53 passed (55) — exactly the expected RED-on-new shape, no collateral
regression.
Constraints
- TDD mode is ON (workflow.tdd_mode: true). RED test MUST land before GREEN fix.
- Auto-loaded memories:
feedback-gsd-ceremony-for-fixes.md(no hot-edits; route through proper GSD ceremony) andfeedback-no-unilateral-scope-reduction.md(no scope narrowing). - This fix may RETIRE the D-13 decision entirely OR keep D-13's rotation lifecycle but replace the concat-merge with real remux. CONTEXT.md will need amendment regardless.
- This fix may invalidate the existing committed fixture
tests/fixtures/last_30sec.webm— once the architecture changes, a fresh fixture will be needed. - The Phase 1 closure markers (REQUIREMENTS.md, ROADMAP.md, STATE.md) marked REQ-video-ring-buffer complete on 2026-05-15; with this finding they need to be REVERTED to in-progress until the fix lands. That's a DOCUMENTATION change the orchestrator handles, NOT a debugger action.
- Phase 1 architecture amendment is large enough that this debug session may need to escalate to a fresh Plan 01-08 (e.g. "WebM remux for playable ring-buffer") rather than landing as a hotfix in the debug session itself. The debugger should CHECKPOINT to the orchestrator after root-cause confirmation + fix-strategy options, before executing.
Files of Interest (preliminary)
- src/offscreen/recorder.ts:
- 80-110: getSegments + segment array management
- 250-360: D-13 restart-segments rotation lifecycle
- 522-650: encodeAndSendBuffer (sends segments to SW)
- src/background/index.ts:
- 129-150: decodeBufferSegments (base64 -> Blob)
- 395-420: mergeVideoSegments (the concat point — likely replaced by remux)
- 444-460: createArchive (calls mergeVideoSegments)
- tests/offscreen/webm-playback.test.ts (existing — uses ffmpeg dry-run to check decoder errors but does NOT check total playable duration)
- tests/fixtures/last_30sec.webm (canonical fixture; needs regen post-fix)
- .planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md (D-13 decision; needs amendment or retirement)
- .planning/REQUIREMENTS.md (REQ-video-ring-buffer; needs status flip from [x] back to [ ])
Evidence
(populated by debugger; initial evidence below)
Operator empirical observations (2026-05-16)
/tmp/mokosh-test-uat-3seg.webm(today's UAT output, 1.68 MB, 3 segments): played ~9 s in mpv/tmp/mokosh-test-committed-3seg.webm(2026-05-15 closure fixture, 1.63 MB, 3 segments): played ~9 s in mpv- Earlier today operator confirmed Chrome playback of the UAT output was also ~9 s, not ~30 s
SW log evidence (today's UAT run, 16:48:52)
- 3 segments arrived at SW
- Mergeed correctly: 672159 + 507559 + 496181 = 1675899 bytes (matches archive WebM size)
- No errors anywhere in delivery path
ffmpeg dry-run signature
- Both files produce ~299 warning lines (segment join boundary noise)
- Both files report
duration=9.94 svia ffprobe -show_entries format=duration - Decoder errors: zero (segments are individually valid)
H4 byte-level EBML probe (2026-05-16T17:10Z) — confirms multi-EBML-concat is the root cause
Probe target: tests/fixtures/last_30sec.webm (1_633_459 bytes, committed
fixture from Phase 1 closure 2026-05-15).
EBML structural scan (raw byte search for element IDs per Matroska spec):
| EBML element | ID (hex) | Occurrences in file | Byte offsets |
|---|---|---|---|
| EBML header | 1A 45 DF A3 |
3 | [0, 509038, 970967] |
| Segment | 18 53 80 67 |
3 | [36, 509074, 971003] |
| Cluster | 1F 43 B6 75 |
13 | spread across all 3 segments |
The file is THREE concatenated WebM files, each with its own EBML header
- Segment element. mkvinfo (without
--all-elements) reports only the FIRST segment + its EBML header — two top-level elements visible — confirming standards-compliant parsers stop at the first segment.
Per-segment isolated probes (sliced via Python at the EBML offsets
above into /tmp/d13-seg{1,2,3}.webm):
| Segment | Bytes | format=duration | -count_frames |
|---|---|---|---|
| seg1 | 509_038 | 9.934 s | 301 frames |
| seg2 | 461_929 | 9.963 s | 300 frames |
| seg3 | 662_492 | 9.958 s | 311 frames |
| TOTAL | 1_633_459 | (29.86 s of real content) | 912 frames |
Each segment is individually a valid, complete ~10 s WebM. The underlying VP9 stream is intact across all three. The bug is purely the multi-segment topology of the concatenated container.
Concatenated file probe (the actual fixture):
| Probe command | Reported value |
|---|---|
ffprobe -show_entries format=duration |
9.934024 s (first segment's Info.Duration metadata only) |
ffprobe -count_frames |
601 frames (= 301 + 300 = segs 1+2 only) |
ffmpeg -f null - decoder |
frame=601 time=00:00:09.96 + 299 non-monotonic-DTS warnings |
Packets read from byte range pos<509038 (seg1) |
301 |
Packets read from byte range 509038 ≤ pos < 970967 (seg2) |
300 |
Packets read from byte range pos ≥ 970967 (seg3) |
0 |
| mkvinfo top-level elements visible | 2 (EBML head + Segment) — seg2 + seg3 invisible |
Two observations both fatal to D-13:
- ffmpeg's matroska demuxer is the most-permissive parser in common
use and even IT silently drops segment 3 (zero packets from
pos ≥ 970967). - Even when ffmpeg DOES read segments 1+2 it does not offset seg2's
local timestamps onto the global timeline. The 299 non-monotonic-DTS
warnings are seg2's local timestamps (
tt < 9934 ms) colliding with seg1's end timestamp (9934 ms). Outputtime=00:00:09.96because the muxer cannot grow the timeline past the maximum monotonic DTS it has accepted.
Conclusion: H4 confirmed at the byte level. The file is structurally valid as a "concatenated archive of three WebMs" but is NOT a single 30-second playable WebM. To produce a 30-second playable WebM the segments must be REMUXED (parse VP9 frames + keyframe flags + cluster timestamps from each segment, then re-emit them inside a single EBML-headered container with monotonically-adjusted timestamps).
Library survey (2026-05-16T17:15Z) — candidate JS WebM remux libraries
All sizes are the bundled dist (no source-map, no tests, no docs).
Gzipped values measured locally via gzip -c. SW-compat verdict is
based on grep of dist for window/document/navigator/XMLHttpRequest
followed by manual inspection of any hits.
| Lib | Version | License | Last release | Dist size | Gzipped | SW-compat | API shape | Verdict |
|---|---|---|---|---|---|---|---|---|
webm-muxer |
5.1.4 | MIT | 2025-07-02 | 69 KB | ~12 KB | YES (zero DOM refs) | addVideoChunkRaw(data, type:'key'|'delta', ts, meta?) accepts encoded VP9 frames |
PRIMARY — write half |
ts-ebml |
3.0.2 | MIT | 2025-09-28 | 356 KB | ~87 KB | YES (typeof window with self fallback in UMD wrapper) |
Decoder.decode(ArrayBuffer) → EBMLElementDetail[] ; Encoder.encode(elms) → ArrayBuffer |
PRIMARY — parse half |
ebml |
3.0.0 | MIT | 2018-09-06 | 7.7 MB unpacked | n/a | uncertain | older streaming parser API | DEAD UPSTREAM — avoid |
mp4-muxer |
5.2.2 | MIT | (active) | 70 KB | ~13 KB | YES | analogous to webm-muxer but MP4 | n/a — wrong container |
| Custom EBML parser | n/a | n/a | n/a | 0 KB | 0 KB | YES | hand-rolled per Matroska spec | ~500-1000 LOC, full ownership |
Important note on the webm-muxer API: addVideoChunkRaw() takes
already-encoded VP9 frame bytes + a keyframe flag + a timestamp. We
do NOT need to decode/re-encode the VP9 stream — the existing
segments already contain valid VP9 frame payloads inside their
Cluster/SimpleBlock elements. The remux path is:
- For each segment blob, parse via
ts-ebml.Decoder→ walk the EBML tree → for each Cluster's SimpleBlock children, extract the VP9 frame bytes + keyframe flag (Matroska SimpleBlock bit 7 of the first flag byte = "keyframe" per spec)- cluster Timestamp + local block offset.
- Compute monotonic adjusted timestamp:
globalTs = segmentBaseMs + clusterTsMs + blockOffsetMswheresegmentBaseMsaccumulates the prior segment's total content duration. - Stream all adjusted frames into a single
webm-muxer.MuxerwithaddVideoChunkRaw(frameData, isKey ? 'key' : 'delta', globalUs). muxer.finalize()→ArrayBufferTarget.buffer→ single-EBML WebM Blob.
Combined dep weight: ~100 KB gzipped (webm-muxer ~12 KB + ts-ebml
~87 KB). Combined source edit estimate at mergeVideoSegments():
~150-300 LOC including type defs.
Cluster-aware-trim alternative path (D-09..D-11 revisit, 2026-05-16T17:18Z)
Path summary: keep MediaRecorder running continuously (the retired D-09 lifecycle) but, on each periodic trim pass, scan the chunk buffer for the OLDEST keyframe whose position would keep total duration ≤ 30 s, then drop everything strictly before that keyframe. Preserves header chunk + a contiguous run of keyframe-anchored clusters.
Why this is architecturally weaker than remux:
-
Non-deterministic content window. MediaRecorder/VP9 keyframe cadence under Chrome's default
kf_max_dist=100is irregular — the priorwebm-playback-freezedebug session observed a 26-second keyframe gap empirically. If the latest keyframe was emitted 2 s ago, cluster-aware trim retains only 2 s of content. The user'slast_30sec.webmwould be anywhere in[~few seconds .. ~30 s]depending on when SAVE landed in the keyframe cycle. That breaks SPEC §10 #7's implicit "≥ 30 s of recent context" requirement. -
Still need EBML parsing. To find keyframe boundaries inside the chunk buffer we still need to parse the WebM container for SimpleBlock keyframe flags. So the dep weight is similar (
ts-ebmlat minimum) but the output is worse. -
Re-introduces the freeze-risk surface area. The prior debug session retired D-09..D-11 precisely because age-trim repeatedly produced orphan-P-frame freezes. A "keyframe-aware" variant still has to delete content; one bug in the keyframe-detection path and the freeze returns. The risk surface is wider than the remux path, which never deletes — it only re-containers what already exists.
LOC estimate: ~200-400 LOC for keyframe parsing + buffer mutation + tests. Net: similar dep weight, worse playable-duration guarantee, re-opens the freeze regression surface. REJECTED as inferior to remux. Documenting here only because the orchestrator brief explicitly requested the comparison.
WebCodecs API path (2026-05-16T17:19Z)
WebCodecs (VideoEncoder + VideoDecoder) is available in MV3 service
workers from Chrome 94+. The path would be: feed each segment's
clusters → VideoDecoder → emit VideoFrame objects → feed back into
VideoEncoder (re-encode VP9) → wrap output via webm-muxer.
This works but adds a re-encode pass that:
- doubles CPU cost during the save flow
- introduces an additional quality loss (re-encoding lossy VP9)
- adds 500-1000 LOC of encoder/decoder lifecycle management
- requires Chrome 94+ exclusively (we already require modern Chrome, so OK, but it tightens the version floor)
There is no benefit over the ts-ebml + webm-muxer path for this
specific shape of problem — we already have encoded VP9 frames and
just need to put them in a different container. Re-encoding is
unnecessary work. REJECTED as over-engineered.
RED test landing evidence (2026-05-16T17:20Z)
File edited: tests/offscreen/webm-playback.test.ts (preserved
existing 2 GREEN tests; appended new describe block with 2 new
assertions + supporting helpers).
Test run scoped to file:
$ npx vitest run tests/offscreen/webm-playback.test.ts
Test Files 1 failed (1)
Tests 2 failed | 2 passed (4)
Failures:
container-level format=duration on last_30sec.webm exceeds 25 s—expected 9934 to be greater than or equal to 25000ffmpeg full decode of last_30sec.webm reaches at least 25 s of timeline—expected 9960 to be greater than or equal to 25000
Full suite (proves zero collateral regression):
$ npx vitest run
Test Files 1 failed | 10 passed (11)
Tests 2 failed | 53 passed (55)
All 53 pre-existing tests still GREEN. tsc:
$ npx tsc --noEmit; echo exit=$?
exit=0
Eliminated
(populated by debugger as hypotheses are ruled out)
- H1 (Chrome version regression): unlikely given mpv exhibits same behavior and mpv uses ffmpeg internally — not Chrome
- H2 (today's encoding differs subtly from 2026-05-15): ruled out — committed fixture also plays ~9 s in mpv, so it's been broken since D-13 activation
- (H5: defective committed fixture in storage): ruled out — file size matches expected (1.63 MB matches what was committed on 2026-05-15; not bit-rot)
- H6 (cluster-aware-trim revisit of D-09..D-11): rejected on architectural weakness — non-deterministic content window (depends on keyframe cadence), still needs EBML parsing, re-opens freeze-regression surface area. See Evidence/cluster-aware-trim section.
- H7 (WebCodecs re-encode path): rejected as over-engineered — re-encodes VP9 frames we already have. ~500-1000 LOC for zero quality/playability benefit. See Evidence/WebCodecs section.
Resolution
root_cause: "" fix: "" verification: "" files_changed: []