Files
mokosh/.planning/phases/01-stabilize-video-pipeline/01-08-PLAN.md
Mark 2e499d7387 docs(01): add Plans 01-08 / 01-09 / 01-10 (amended Phase 1 charter)
Plans cover the post-D-13 architecture and the auto-start UX charter
expansion that landed during 2026-05-16 UAT:

- Plan 01-08 — WebM remux via ts-ebml@3.0.2 + webm-muxer@5.1.4. Replaces
  the broken file-concat in mergeVideoSegments with a real single-EBML
  remux. Drives the 2 RED tests in tests/offscreen/webm-playback.test.ts
  to GREEN. Regenerates the canonical fixture against the remuxer.
  5 tasks (4 TDD + 1 operator empirical checkpoint), wave 1.

- Plan 01-09 — Whole-desktop constraint (displaySurface:'monitor',
  cursor:'always') + post-grant validation, chrome.action.onClicked
  direct toolbar invocation, chrome.action badge state machine
  (REC/OFF/ERROR), chrome.runtime.onStartup notification + recovery
  notification on onUserStoppedSharing, popup scoped to SAVE-only.
  17 new test assertions across 4 test files. smoke.sh updated to
  auto-select an entire screen. 5 tasks (4 TDD + 1 operator empirical
  checkpoint), wave 2 (depends on 01-08).

- Plan 01-10 — chrome.runtime.onInstalled welcome tab on first install
  via chrome.storage.local guard; vanilla welcome.html/ts/css bundle
  with single "Начать запись" button consuming install-time activation.
  Uses centralized Logger pattern. 4 tasks (3 TDD + 1 operator empirical
  checkpoint), wave 3 (depends on 01-09).

CONTEXT.md amendment block appended with 4 disambiguated decisions:
- D-14-remux: WebM remux supersedes D-13 file-concat
- D-15-display-surface: whole-desktop + cursor visibility lifted from
  Phase 5 deferral
- D-16-toolbar: toolbar onClicked + popup SAVE-only + badge state
  machine + onStartup/recovery notifications
- D-17-onboarding: welcome tab on first install (distinct from
  D-17-port-lifecycle from Option C)
The earlier D-17 port-lifecycle heading also renamed to hyphenated
form for cross-ref consistency.

Plan-check loop: 3 iterations (initial + 2 revisions). Iteration 1
surfaced 11 findings (2 BLOCKER + 6 WARNING + 3 INFO); all addressed
via revision iter 1 with checker-recommended fixes. Iteration 2
surfaced 3 derivative regressions (literal-string grep anchors from
the iter-1 fixes did not match live CONTEXT.md); all addressed in iter
2 with empirically-validated literals. Iteration 3 PASSED clean.

Validation: gsd-sdk frontmatter.validate + verify.plan-structure both
return valid=True for all 3 plans. Plan 01-08 Task 4 verify-chain
grep tested end-to-end against live CONTEXT.md (exit 0).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 09:19:22 +02:00

43 KiB
Raw Blame History

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, tags, must_haves
phase plan type wave depends_on files_modified autonomous requirements tags must_haves
01-stabilize-video-pipeline 08 tdd 1
package.json
package-lock.json
src/background/index.ts
src/background/webm-remux.ts
tests/background/webm-remux.test.ts
tests/fixtures/last_30sec.webm
.planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md
false
REQ-video-ring-buffer
webm
remux
ts-ebml
webm-muxer
vp9
matroska
truths artifacts key_links
tests/offscreen/webm-playback.test.ts container-level format=duration assertion (>= 25_000 ms) is GREEN against the regenerated tests/fixtures/last_30sec.webm fixture.
tests/offscreen/webm-playback.test.ts ffmpeg full-decode timeline assertion (>= 25_000 ms) is GREEN against the regenerated fixture.
mergeVideoSegments() no longer exists in src/background/index.ts; its callers go through the new remuxSegments() in src/background/webm-remux.ts.
The remux produces a single-EBML-header WebM (verified by byte-level probe asserting EBML header occurrence count === 1) covering all VP9 frames from every input segment with monotonically increasing timestamps.
The 53 baseline GREEN tests across 11 files plus the 4 webm-playback tests all pass (`npx vitest run` exit 0).
Operator running ./smoke.sh + opening the produced last_30sec.webm in Chrome AND mpv sees ~30 s of continuous playback (NOT ~9 s).
path provides min_lines exports
src/background/webm-remux.ts remuxSegments(segments: VideoSegment[]): Promise<Blob> — parses each input segment via ts-ebml, extracts VP9 SimpleBlocks + cluster timecodes, re-mixes monotonic timestamps via webm-muxer into a single-EBML-headered WebM. 120
remuxSegments
path provides min_lines
tests/background/webm-remux.test.ts Unit-level RED→GREEN tests for the remux helper: single-EBML invariant, monotonic-timestamp invariant, frame-count preservation across input segments, keyframe-flag preservation. 100
path provides contains
src/background/index.ts createArchive() calls remuxSegments() (await — async) instead of synchronous mergeVideoSegments(); EmptyVideoBufferError surface preserved. remuxSegments
path provides
tests/fixtures/last_30sec.webm Regenerated fixture captured against the post-remux build; ffprobe container duration >= 25_000 ms; ffmpeg full-decode time >= 25_000 ms.
path provides contains
package.json Adds `ts-ebml` ^3.0.2 and `webm-muxer` ^5.1.4 to dependencies (both MIT, both SW-compatible per the d13 debug session library survey). ts-ebml
path provides contains
.planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md AMENDMENT block appending D-14-remux (ts-ebml + webm-muxer remux) supersedes D-13's file-concat merge; D-13's recorder-side restart-segments lifecycle preserved. D-14-remux
from to via pattern
src/background/index.ts:createArchive src/background/webm-remux.ts:remuxSegments await remuxSegments(videoBufferResponse.segments) remuxSegments(
from to via pattern
src/background/webm-remux.ts ts-ebml package import { Decoder } from 'ts-ebml' from ['"]ts-ebml['"]
from to via pattern
src/background/webm-remux.ts webm-muxer package import { Muxer, ArrayBufferTarget } from 'webm-muxer' from ['"]webm-muxer['"]
from to via pattern
tests/offscreen/webm-playback.test.ts tests/fixtures/last_30sec.webm ffprobe + ffmpeg drive the regenerated fixture; the 2 RED assertions (lines 232-316) flip GREEN. MIN_PLAYABLE_DURATION_MS
Replace `src/background/index.ts:mergeVideoSegments()` file-concat (D-13) with a real single-EBML-headered WebM **remux** via `ts-ebml` (parse) + `webm-muxer` (write). Each input segment carries its own EBML header + Segment + Cluster structure; the output is one EBML + one Segment whose clusters contain every VP9 SimpleBlock from every input segment, with timestamps adjusted to be monotonic against a single global timeline. D-13's recorder-side restart-segments lifecycle (the part that fixed orphan-P-frame freezes) is preserved; only the merge step is replaced.

Purpose: satisfy SPEC §10 #7 (last_30sec.webm plays back in a browser) for real. The 2026-05-15 closure shipped a multi-EBML concat that mpv / Chrome / ffprobe all play as ~9.94 s (first segment's Info.Duration only) instead of ~30 s. The two RED tests already on disk in tests/offscreen/webm-playback.test.ts (lines 231-316) pin the actual contract; this plan drives them GREEN.

Output:

  • src/background/webm-remux.ts — new module exporting remuxSegments(segments: VideoSegment[]): Promise<Blob>.
  • src/background/index.tsmergeVideoSegments() deleted; createArchive() calls await remuxSegments(...) (becomes async-on-the-merge-path; saveArchive already awaits createArchive).
  • tests/background/webm-remux.test.ts — RED→GREEN unit tests pinning the single-EBML / monotonic-timestamp / frame-count / keyframe-flag invariants without requiring a real MediaRecorder.
  • Regenerated tests/fixtures/last_30sec.webm from a fresh ./smoke.sh against the post-remux build, validated by the 2 new playback-duration assertions.
  • package.json + package-lock.json — adds ts-ebml ^3.0.2 + webm-muxer ^5.1.4.
  • CONTEXT.md amendment block (D-14-remux supersedes D-13's file-concat; D-13's recorder lifecycle preserved). The amendment block was authored at plan-creation time by the orchestrator; Task 4 step 9 verifies it remains intact via grep checks (no new file mutation expected).

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/REQUIREMENTS.md @.planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md @.planning/phases/01-stabilize-video-pipeline/01-RESEARCH.md @.planning/phases/01-stabilize-video-pipeline/01-VERIFICATION.md @.planning/phases/01-stabilize-video-pipeline/01-UAT.md @.planning/debug/d13-multi-ebml-concat-unplayable.md @tests/offscreen/webm-playback.test.ts @src/background/index.ts @src/shared/types.ts @src/shared/binary.ts

From src/shared/types.ts:

export interface VideoSegment {
  data: Blob;          // a self-contained ~10 s WebM blob (D-13 segment)
  timestamp: number;
}

export interface VideoBufferResponse {
  segments: VideoSegment[];
}

From src/background/index.ts (current call site to be replaced, lines 395-421 + 449):

// CURRENT (D-13 file-concat — TO BE DELETED):
function mergeVideoSegments(segments: VideoSegment[]): Blob {
  const sortedSegments = [...segments].sort((a, b) => a.timestamp - b.timestamp);
  const blobs: Blob[] = sortedSegments.map((segment) => segment.data);
  return new Blob(blobs, { type: 'video/webm' });
}

// Called at line 449 (inside createArchive):
const videoBlob = mergeVideoSegments(videoBufferResponse.segments);

From the debug session research (.planning/debug/d13-multi-ebml-concat-unplayable.md lines 380-410), the remux algorithm:

1. For each segment blob, parse via ts-ebml Decoder → walk EBML tree →
   for each Cluster's SimpleBlock children, extract:
     • VP9 frame bytes (the SimpleBlock payload after the per-block header)
     • keyframe flag (Matroska SimpleBlock first-flag-byte bit 7)
     • cluster Timestamp (Cluster.Timecode element, ms)
     • block-local timestamp offset (relative to its enclosing cluster)
2. Compute monotonic adjusted timestamp:
     globalUs = (segmentBaseMs + clusterTsMs + blockOffsetMs) * 1000
   where segmentBaseMs accumulates the prior segment's total content
   duration (the last block's local timestamp + 1 frame worth, ~33 ms).
3. Stream all adjusted frames into a single webm-muxer Muxer with
   addVideoChunkRaw(frameData, isKey ? 'key' : 'delta', globalUs).
4. muxer.finalize() → target.buffer (ArrayBuffer) → wrap in Blob.

From webm-muxer 5.1.4 API surface (from npm registry; see https://github.com/Vanilagy/webm-muxer):

import { Muxer, ArrayBufferTarget } from 'webm-muxer';

const target = new ArrayBufferTarget();
const muxer = new Muxer({
  target,
  video: {
    codec: 'V_VP9',                  // Matroska codec id for VP9
    width: <px>, height: <px>,       // probe from first segment's Video element
    frameRate: 30,                   // approximate — not strict
  },
  // No `audio` block — Phase 1 SPEC §9 / CAP-01 explicitly excludes audio.
  type: 'webm',                      // emit a WebM (Matroska subset) container
  firstTimestampBehavior: 'offset',  // tolerate a non-zero first frame ts
});

// timestamp is MICROSECONDS, integer.
muxer.addVideoChunkRaw(data: Uint8Array, type: 'key' | 'delta', timestamp: number, meta?: { decoderConfig?: { description?: Uint8Array } });

muxer.finalize();
// target.buffer is now an ArrayBuffer holding the single-EBML WebM.

From ts-ebml 3.0.2 API surface (from npm registry; see https://github.com/legokichi/ts-ebml):

import { Decoder, tools } from 'ts-ebml';

const decoder = new Decoder();
const elements = decoder.decode(arrayBuffer);   // EBMLElementDetail[]
// Walk elements; each has { name, type, dataSize, value, isEnd, ... }.
// Element names of interest (Matroska element table):
//   'EBML', 'Segment', 'Tracks', 'TrackEntry', 'Video', 'PixelWidth',
//   'PixelHeight', 'CodecPrivate', 'Cluster', 'Timecode', 'SimpleBlock'.
// SimpleBlock payload is the raw block: variable-length track number prefix,
// 2 bytes signed timestamp delta, 1 flags byte (bit 7 = keyframe), then
// VP9 frame bytes.

Existing test infrastructure (reusable patterns from tests/background/request-id-protocol.test.ts and tests/offscreen/codec-check.test.ts):

  • vi.resetModules() + dynamic await import('../../src/background/...') per test (fresh module state).
  • No chrome stub needed for webm-remux.ts — it's a pure async function.
  • import.meta.url + node:fs for fixture reads.
Task 1: Install ts-ebml + webm-muxer; verify SW-compat by import-only test (Wave 0 contract). - package.json (current dependency list — confirm neither library is already installed) - vite.config.ts (confirm crxjs picks up new imports automatically; no Rollup external config needed) - .planning/debug/d13-multi-ebml-concat-unplayable.md, Evidence/library-survey section (lines 380-410) — confirms both libraries are SW-compatible (no `window`/`document` references) - tests/offscreen/codec-check.test.ts (test scaffold pattern: vi.resetModules + dynamic import) package.json, package-lock.json, tests/background/webm-remux-deps.test.ts - Test 1: `tests/background/webm-remux-deps.test.ts` imports `webm-muxer` and `ts-ebml` and asserts both exports surface (`Muxer`, `ArrayBufferTarget` from webm-muxer; `Decoder` from ts-ebml). RED if either is missing from `node_modules`; GREEN after `npm install` lands. - Test 2: same file asserts that loading both modules under Node's default globals (no jsdom shim) does not throw any `window is not defined`/`document is not defined` errors. RED if either dist accidentally hits a DOM global; GREEN by virtue of both libraries' published SW-compatibility (per debug session lines 380-410). 1. RED: - Create `tests/background/webm-remux-deps.test.ts` with two `it` blocks: (a) `it('exports Muxer + ArrayBufferTarget + Decoder', async () => {...})` — dynamic-import both, expect named exports defined. (b) `it('loads under default Node globals without DOM-global ReferenceErrors', async () => {...})` — assert that `delete (globalThis as any).window; delete (globalThis as any).document;` followed by re-import does not throw. - Run `npx vitest run tests/background/webm-remux-deps.test.ts`; expect both tests RED with `Cannot find module` errors (libraries not installed yet). 2. GREEN: - `npm install --save ts-ebml@^3.0.2 webm-muxer@^5.1.4` (versions verified live on npm registry 2026-05-16: ts-ebml 3.0.2 + webm-muxer 5.1.4; both MIT; both active). - Commit `package.json` + `package-lock.json` changes; do NOT touch other files. - Re-run the test; both flip GREEN. 3. Re-run the full suite (`npx vitest run`) to confirm no collateral regression (53 baseline GREEN + 2 new GREEN = 55; the 2 existing RED tests in webm-playback.test.ts stay RED — they're Task 4's GREEN target). Use absolute imports (`import { Muxer } from 'webm-muxer'`, `import { Decoder } from 'ts-ebml'`) per the user's global style guide. No `as any`; no `@ts-ignore`. If TypeScript complains about missing types, install `@types/...` packages if available OR add a minimal `.d.ts` shim under `src/shared/` (whichever is canonical for the library's published shape — check the library's repo first, do NOT hand-roll types if the library ships its own). npx vitest run tests/background/webm-remux-deps.test.ts && npx tsc --noEmit - `package.json` dependencies include `ts-ebml` and `webm-muxer` at the pinned versions. - `tests/background/webm-remux-deps.test.ts` exists with the 2 tests above and both PASS. - `npx tsc --noEmit` exits 0 (no type errors from either library import). - `npx vitest run` shows 11 files / 53 GREEN + 2 new GREEN; the 2 RED in webm-playback.test.ts remain RED (this task does not touch the production code path yet). Both libraries installed with pinned versions, the import-surface test pinning both library names is committed and GREEN, tsc clean, baseline suite preserved. Task 2: Add unit-level RED tests for remuxSegments() invariants (single-EBML, monotonic ts, frame-count, keyframe-flag). - tests/offscreen/segment-rotation.test.ts (existing test pattern for D-13; reuse the `Blob`-fixture-building style — uses node `Buffer`) - tests/offscreen/webm-playback.test.ts lines 1-100 (FIXTURE_PATH idiom + ffprobe/ffmpeg helpers; we'll reuse the ffprobe binary path for any container-introspection assertions in this test file) - .planning/debug/d13-multi-ebml-concat-unplayable.md, Evidence/H4 byte-level EBML probe section (lines 311-373) — describes the exact EBML element IDs to scan for: EBML header `0x1A 0x45 0xDF 0xA3`, Segment `0x18 0x53 0x80 0x67`, Cluster `0x1F 0x43 0xB6 0x75`. - src/shared/types.ts (VideoSegment shape). - tests/background/request-id-protocol.test.ts lines 53-85 (vi.fn / port stub pattern — but webm-remux.ts needs no chrome stub). tests/background/webm-remux.test.ts - Test 1 (RED → GREEN by Task 3): given the canonical 3-segment fixture `tests/fixtures/last_30sec.webm` sliced into 3 `Blob` segments (via the byte-offset probe in the debug session: seg1 = bytes [0, 509038), seg2 = bytes [509038, 970967), seg3 = bytes [970967, EOF)), `remuxSegments([seg1, seg2, seg3])` returns a `Blob` whose bytes contain EXACTLY 1 occurrence of the EBML header magic `[0x1A, 0x45, 0xDF, 0xA3]` and EXACTLY 1 occurrence of the Segment magic `[0x18, 0x53, 0x80, 0x67]`. - Test 2: the same output blob's byte size is within [0.7×, 1.3×] of the sum of input segment sizes — sanity check that we're not silently dropping content NOR ballooning it 10x. - Test 3: `ffprobe -v error -show_entries format=duration -of csv=p=0` against the output blob (write to tmpfile first) reports a duration >= 25_000 ms (skip-if-missing per webm-playback.test.ts pattern — use `existsSync(FFPROBE_BIN)` gate). - Test 4: `ffprobe -count_frames -show_entries stream=nb_read_frames` against the output blob reports a frame count `n` where `905 <= n <= 912` (the per-segment counts from the debug session probe were `301+300+311 = 912`; the muxer may drop at most one partial frame per segment boundary, so 3 boundaries × max 1 partial frame each = ±3 frames absolute tolerance). I-01 fix (2026-05-16 checker pass): tightened from the prior ±20% band (which would accept catastrophic loss like 730 frames) to ±1% / ±3 absolute frames, sized to the documented muxer boundary partial-frame drop only. - Test 5: empty-input → `remuxSegments([])` returns a `Promise` whose blob has `.size === 0`. (Defense-in-depth; saveArchive's EmptyVideoBufferError throw guards this path upstream, but the helper should be safe on its own.) 1. Create `tests/background/webm-remux.test.ts` with the 5 `it` blocks described above. 2. Reuse the `FFPROBE_BIN` constant + `ffprobeAvailable()` skip-gate from `tests/offscreen/webm-playback.test.ts` (copy them inline — the test file lives in a different directory so a direct import is awkward; keep both copies trivially in sync per the existing webm-playback.test.ts pattern). 3. Use `node:fs` `readFileSync` + `Buffer.subarray(start, end)` to slice the fixture into 3 segment Blobs (use `new Blob([buffer])` to wrap each slice). Add a `splitFixtureIntoSegments(): Blob[]` helper at the top of the test file documenting the byte offsets from the debug session probe. 4. To find EBML element occurrences, write a tiny `countMagic(bytes: Uint8Array, magic: Uint8Array): number` helper that does a simple byte-window scan (no library needed — 4-byte patterns, no false-positive risk at fixture scale). The Cluster magic should appear >= 3 times in the output (one Cluster per ~1-3 s); but ASSERT only that EBML headers and Segments are EXACTLY 1 each. 5. Use `await import('../../src/background/webm-remux')` (dynamic import) so the import error in this task (the file doesn't exist yet) is the test failure signal rather than a Vitest collection error. Wrap each `it` body in try/catch + `expect.fail()` if the import itself throws — gives a readable RED message rather than `Cannot find module`. 6. Run `npx vitest run tests/background/webm-remux.test.ts`; expect ALL 5 tests RED (the module doesn't exist yet). 7. DO NOT IMPLEMENT THE MODULE YET — Task 3 is the GREEN side of this RED gate. npx vitest run tests/background/webm-remux.test.ts 2>&1 | grep -E "Tests.*[0-9]+ failed" - `tests/background/webm-remux.test.ts` exists with 5 tests. - All 5 tests are RED (production module not yet implemented). - Baseline 55 tests (53 + 2 from Task 1) still GREEN; the 2 webm-playback duration RED still RED; 5 new RED here. Total: 11 files / 60 tests / 7 failed | 53 passed. tsc still exit 0 (the test file is type-clean — uses VideoSegment from shared types). - No file under `src/` is modified by this task. 5 RED tests committed pinning the remux invariants at the unit level; baseline preserved. Task 3: Implement remuxSegments() in src/background/webm-remux.ts — drives all 5 RED tests in webm-remux.test.ts to GREEN. - tests/background/webm-remux.test.ts (the contract — produced by Task 2) - .planning/debug/d13-multi-ebml-concat-unplayable.md, Evidence/library-survey + the algorithm description on lines 380-410 (the remux pipeline this implementation realizes) - src/shared/types.ts (VideoSegment + VideoBufferResponse) - src/shared/logger.ts (Logger class shape — same prefix-based instantiation used in SW; pick `Logger('Remux')` or equivalent) - The `ts-ebml` and `webm-muxer` README files in node_modules/ (installed by Task 1; READ the published library docs there rather than guessing API surface — the interfaces block in this plan is a sketch, the dist source is authoritative). src/background/webm-remux.ts Implements `export async function remuxSegments(segments: VideoSegment[]): Promise` satisfying all 5 tests from Task 2. See behavior list there. 1. Create `src/background/webm-remux.ts` with extensive JSDoc per project style (the user's global guide mandates extensive docstrings; mirror the existing src/background/index.ts comment density). Header comment cites D-14-remux (this plan's CONTEXT amendment, disambiguated from the historical "D-14: Not applicable" tab-switch entry per B-02 fix), references the d13 debug session, and explains the parse → adjust-timestamps → re-emit pipeline. 2. Imports: - `import { Decoder } from 'ts-ebml';` - `import { Muxer, ArrayBufferTarget } from 'webm-muxer';` - `import type { VideoSegment } from '../shared/types';` - `import { Logger } from '../shared/logger';` Use absolute-style (no `from '..'` ascending more than one level except shared/) — matches existing src/background/index.ts. 3. Helper functions (small + named — no `continue`, prefer if-else chains per project style): - `async function blobToArrayBuffer(b: Blob): Promise` — uses `b.arrayBuffer()` (web standard; available in Chrome SW). - `interface ExtractedFrame { data: Uint8Array; isKey: boolean; timestampMs: number; }` - `function extractFramesFromSegment(buffer: ArrayBuffer, segmentBaseMs: number): { frames: ExtractedFrame[]; segmentDurationMs: number; trackInfo: { width: number; height: number; codecPrivate?: Uint8Array; } | null }` — walks `Decoder.decode(buffer)`, tracks current Cluster Timecode, processes each SimpleBlock by parsing its first-byte-after-track-number flags (bit 7 = keyframe). Returns adjusted-by-segmentBaseMs timestamps. - SimpleBlock parsing: read VINT track number (Matroska VINT decode is library-trivial; copy the routine from the Matroska spec's "Variable Size Integer" section if ts-ebml does not surface it directly), then 2 bytes int16 BE timestamp delta, then 1 byte flags. Frame data = remaining bytes. - `function pickTrackInfoFromSegment(elements: EBMLElementDetail[]): { width, height, codecPrivate? } | null` — walks for Tracks → TrackEntry → Video → PixelWidth + PixelHeight; CodecPrivate if present. 4. Main flow: a) Guard empty input: `if (segments.length === 0) return new Blob([], { type: 'video/webm' });` (satisfies Test 5). b) Sort by `timestamp` ascending (matches the SW-side ordering pattern in the deleted mergeVideoSegments). c) Parse first segment to derive `trackInfo` (width/height/CodecPrivate) — needed for the muxer's video config. d) Build a `Muxer` with: ``` codec: 'V_VP9', width, height, frameRate: 30, type: 'webm', firstTimestampBehavior: 'offset', ``` If `CodecPrivate` is present, pass it as `decoderConfig.description` (Uint8Array) via the per-chunk meta. (Most VP9 streams from MediaRecorder do not need CodecPrivate, but pass it through defensively.) e) Loop segments with an accumulating `segmentBaseMs`: - `extractFramesFromSegment(buffer, segmentBaseMs)` → frames + `segmentDurationMs` - For each frame: `muxer.addVideoChunkRaw(frame.data, frame.isKey ? 'key' : 'delta', frame.timestampMs * 1000)` (the muxer wants microseconds). - `segmentBaseMs += segmentDurationMs` (the next segment's first frame slots in just after the prior segment's last frame). f) `muxer.finalize()`, then `return new Blob([target.buffer], { type: 'video/webm' })`. 5. Log at INFO: input segment count + sizes; per-segment extracted-frame count + duration; final blob size. 6. NO `as any`; NO `@ts-ignore`. If a type narrowing is genuinely needed, use a typed predicate (`function isSimpleBlock(el: EBMLElementDetail): el is SimpleBlockElement {...}`). 7. Run `npx vitest run tests/background/webm-remux.test.ts` — all 5 must flip GREEN. 8. Run `npx tsc --noEmit` — must be 0. 9. Run full suite — 11 files / 60 tests / 2 failed | 58 passed (the only remaining failures are the 2 webm-playback duration RED; those drive Task 4 GREEN once the call site swaps). DESIGN NOTE per project style: prefer if-else chains over early returns is RELAXED for guard clauses (the empty-input and null-trackInfo guards are clearer as `if (cond) return ...`). Document this exception in the file's header comment. npx vitest run tests/background/webm-remux.test.ts && npx tsc --noEmit - `src/background/webm-remux.ts` exists, exports `remuxSegments`, has extensive JSDoc per project style (block comment header + per-function docstrings). - All 5 tests in `tests/background/webm-remux.test.ts` GREEN. - `npx tsc --noEmit` exit 0. - Full suite: only the 2 webm-playback duration tests RED (they stay RED until the call site swap + fixture regeneration in Tasks 4-5). - No `as any`, no `@ts-ignore`, no `console.log` — uses `new Logger('Remux')` for diagnostics. - File size 120-300 LOC inclusive of comments (per debug session's estimate; if implementation balloons past 350 LOC, consider extracting EBML walk to a separate `webm-ebml-parser.ts` — but a single-file implementation is acceptable). Pure helper module landed; unit invariants pinned GREEN; full-suite delta = 5 new GREEN; tsc clean. Task 4: Swap mergeVideoSegments → remuxSegments at call site in src/background/index.ts; await the now-async merge. - src/background/index.ts lines 1-50 (imports + EmptyVideoBufferError class) - src/background/index.ts lines 385-505 (mergeVideoSegments definition + createArchive body) - src/background/index.ts lines 534-623 (saveArchive — confirms it already awaits createArchive) - src/background/webm-remux.ts (the new helper from Task 3) - tests/background/request-id-protocol.test.ts (asserts createArchive→saveArchive error-surface contract; the swap MUST preserve EmptyVideoBufferError throw on empty input) src/background/index.ts - `createArchive` calls `await remuxSegments(videoBufferResponse.segments)` instead of synchronous `mergeVideoSegments(...)`. - `mergeVideoSegments` function declaration is removed from the file (grep returns 0 hits). - `EmptyVideoBufferError` throw paths preserved on (a) zero segments AND (b) zero-byte merged blob. - All existing tests in tests/background/* still GREEN (the request-id-protocol contract tests don't care whether the merge is sync or async — they stub saveArchive at a higher level). 1. RED step: rerun `npx vitest run` against current state — confirm the 2 RED webm-playback tests are still RED + remux unit tests GREEN. (No new failing test to write here; the gate is the existing webm-playback RED + the absence-of-mergeVideoSegments grep.) 2. Add `import { remuxSegments } from './webm-remux';` near the top of `src/background/index.ts` (alphabetized with existing imports per project style). 3. Replace the body of `createArchive`'s video-merge block (currently lines 444-456) with: ```typescript if (videoBufferResponse.segments.length === 0) { throw new EmptyVideoBufferError( 'no video segments available — buffer fetch returned empty (port replacement timed out, or recorder never started)', ); } const videoBlob = await remuxSegments(videoBufferResponse.segments); if (videoBlob.size === 0) { throw new EmptyVideoBufferError( `remuxed video blob is zero bytes (segment count=${videoBufferResponse.segments.length})`, ); } zip.file('video/last_30sec.webm', videoBlob); logger.log(`✓ Added video (remuxed): ${videoBlob.size} bytes`); ``` **W-01 fix (2026-05-16 checker pass): error message string change.** The EmptyVideoBufferError detail string changes from 'merged video blob is zero bytes' → 'remuxed video blob is zero bytes'. Before committing, pre-flight grep `src/` and `tests/` for the literal 'merged video blob is zero bytes' to confirm no downstream consumer matches on that string: ``` grep -RIn 'merged video blob is zero bytes' src/ tests/ || true ``` Empirical pre-check (executed 2026-05-16 from current tree): only `src/background/index.ts:452` carries the literal. `tests/background/request-id-protocol.test.ts` asserts on `error.code` ('empty-video-buffer'), NOT on the free-text message — so the rename is safe. Document the empty grep result in the commit message body so the diff reviewer sees the safety check ran. 4. DELETE the entire `mergeVideoSegments` function declaration (currently lines 385-421 — including the JSDoc header explaining D-13 file-concat). Replace with a single comment line: ```typescript // mergeVideoSegments (D-13 file-concat) retired in Plan 01-08 (D-14-remux): // see src/background/webm-remux.ts for the single-EBML remux path. ``` 5. `createArchive` is already declared `async`; the new `await remuxSegments(...)` slots in without signature changes. Verify saveArchive's `const archiveBlob = await createArchive(...)` already awaits — no changes needed there. 6. Run `npx tsc --noEmit` — exit 0. 7. Run `npx vitest run`: - tests/background/webm-remux.test.ts: GREEN (Task 3) - tests/background/request-id-protocol.test.ts: GREEN (no contract change) - tests/background/port-lifecycle-continuous.test.ts: GREEN (no contract change) - tests/offscreen/webm-playback.test.ts: 2 of 4 still RED (the new duration assertions) — these stay RED until the FIXTURE is regenerated (Task 5). - All other tests: GREEN. 8. Run `grep -nc 'mergeVideoSegments' src/background/index.ts` → must report `0` (or `0` lines with the function declaration; the deletion comment may or may not name it, your call). 9. **B-01 fix (2026-05-16 checker pass): CONTEXT.md amendment provenance verification (folded in from retired Task 6).** The orchestrator already appended the D-14-remux amendment block to `.planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md` at plan creation time (alongside D-15-display-surface, D-16-toolbar, D-17-onboarding for Plans 01-09 and 01-10). This step is idempotent grep verification — no file mutation expected. Run these four greps; ALL must return exactly one match: (a) `grep -c 'D-14-remux: WebM remux via ts-ebml + webm-muxer' .planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md` → must be 1. (B-02 fix: anchor on the disambiguated `D-14-remux` marker, NOT bare `D-14` which would also match the historical `**D-14:** **Not applicable**` line in the original decisions block.) (b) `grep -c '^- \*\*D-13:\*\* \*\*Fallback if D-12 fails:\*\*' .planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md` → must be 1. (Confirms the original D-13 text is intact.) (c) `grep -c 'Amendment .Phase 01-stabilize-video-pipeline, 2026-05-16. — D-17-port-lifecycle' .planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md` → must be 1. (Confirms the prior D-17-port-lifecycle amendment block intact; note the disambiguated name per B-02.) (d) Run, verbatim (note: the literal contains backticks, so use single quotes around the -F argument exactly as shown — do NOT wrap this entire command in Markdown inline-code backticks when copy-pasting):
       grep -cF '`src/background/webm-remux.ts` replaces' .planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md

       Must report exactly `1`. Confirms D-14-remux cites the new module (full path `src/background/webm-remux.ts`) replacing the old `mergeVideoSegments`. Anchor empirically verified to match CONTEXT.md line 412 (full path prefix is required; the un-prefixed form `webm-remux.ts` replaces` does not appear as a contiguous substring in the live file).
   If ANY of (a)(d) returns zero, STOP and report to the orchestrator: the planner-time amendment was not committed or was clobbered downstream. Do NOT re-append a competing amendment block — debug the root cause first.
npx tsc --noEmit && npx vitest run --reporter=dot && test "$(grep -v '^\s*//' src/background/index.ts | grep -c 'mergeVideoSegments')" = "0" && grep -q 'D-14-remux: WebM remux via ts-ebml + webm-muxer' .planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md && grep -q '^- \*\*D-13:\*\* \*\*Fallback if D-12 fails:\*\*' .planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md && grep -q 'D-17-port-lifecycle' .planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md && grep -qF '`src/background/webm-remux.ts` replaces' .planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md - `src/background/index.ts` imports `remuxSegments` and awaits it in `createArchive`. - `mergeVideoSegments` is deleted from the file (grep excluding comments returns 0). - `EmptyVideoBufferError` throw paths preserved per the request-id-protocol contract tests. - `npx tsc --noEmit` exit 0. - `npx vitest run` shows the same 2 webm-playback duration RED still failing (because the FIXTURE on disk is the old multi-EBML one — Task 5 regenerates it); every other test GREEN. - `npm run build` exit 0 (verifies the new import resolves through crxjs/Vite into the SW bundle without externalizing ts-ebml or webm-muxer — both are pure JS without native deps so the bundle should swallow them cleanly). - **B-01 fix: CONTEXT.md provenance chain verified intact via grep** — all four checks in action step 9 pass (D-14-remux present; D-13 original preserved; D-17-port-lifecycle amendment preserved; webm-remux.ts cited in D-14-remux body). No file mutation by this verification step; if any grep fails, the executor STOPS and escalates rather than re-appending. Call site swapped to remux; legacy merge gone; saveArchive's empty-buffer error surface preserved; build clean; 2 RED tests now waiting only on the fixture regen; CONTEXT.md amendment provenance verified intact (D-14-remux / D-13 / D-17-port-lifecycle / webm-remux.ts citation all grep-confirmed). Task 5: Operator regenerates tests/fixtures/last_30sec.webm via ./smoke.sh against the post-remux build; confirms ~30 s Chrome + mpv playback. (operator-driven; no specific source file modified by this checkpoint) See below — operator-driven empirical check; the executor agent must not bypass this checkpoint by stubbing. echo "checkpoint:human-verify — see how-to-verify section; resume signal is the gate" Operator types "approved" after running the how-to-verify steps. See for the exact gate. Tasks 1-4 landed: ts-ebml + webm-muxer installed, `remuxSegments()` implemented in `src/background/webm-remux.ts`, `mergeVideoSegments` deleted from `src/background/index.ts`, and `createArchive` now produces a single-EBML-headered WebM. The 2 RED tests in `tests/offscreen/webm-playback.test.ts` (lines 232-316) still fail because they read the committed fixture `tests/fixtures/last_30sec.webm` which was captured against the OLD D-13 file-concat build. This checkpoint regenerates the fixture against the new build. 1. Build: `npm run build` (must exit 0; produces `dist/`). 2. Run smoke: `KEEP_PROFILE=0 ./smoke.sh`. 3. In the launched Chrome window: Load Unpacked → select `dist/` → click the extension toolbar icon → screen-share picker auto-accepts the "Mokosh Smoke Test" tab. 4. Wait at least 35 seconds (longer is fine — wait 5+ minutes if convenient to also re-validate the post-Option-C port lifecycle still holds across the 290 s mark which is now an irrelevant timeline anyway). 5. Click the extension icon → "Сохранить отчёт об ошибке". The script will detect the new `session_report_*.zip`, extract `video/last_30sec.webm`, and stage it to `/tmp/mokosh-last_30sec.webm`. 6. **Empirical playback check (the actual gate)** — open `/tmp/mokosh-last_30sec.webm` in: (a) Chrome (drag the file into a fresh tab) — observe playback duration in the controls; MUST be approximately 30 s (>= 25 s), NOT ~9 s. (b) mpv (`mpv /tmp/mokosh-last_30sec.webm`) — observe the duration shown in the title bar and the playback proceeds the full ~30 s without stopping early. 7. Run `ffprobe -v error -show_entries format=duration -of csv=p=0 /tmp/mokosh-last_30sec.webm` and confirm the reported duration in seconds is between 25.0 and 30.0. 8. If steps 6+7 PASS: copy `/tmp/mokosh-last_30sec.webm` over `tests/fixtures/last_30sec.webm` (`cp /tmp/mokosh-last_30sec.webm tests/fixtures/last_30sec.webm`), then run `npx vitest run tests/offscreen/webm-playback.test.ts` — all 4 tests MUST flip GREEN. 9. If any of steps 6/7 FAIL: do NOT replace the fixture. Report the failure mode (duration value, mpv error, ffmpeg stderr) so Tasks 3-4 can be revised. The most likely failure modes are (a) webm-muxer's `frameRate` config disagreeing with the actual VP9 frame cadence → may need to derive from cluster timestamps; (b) keyframe-flag parsing off-by-one → first frame of each segment must be 'key', subsequent 'delta'; (c) CodecPrivate omission causing Chrome to fail to initialize the VP9 decoder; (d) base64 decode path failure: confirm via SW console 'Decoded buffer segment N: blob size=...' lines from decodeBufferSegments fired BEFORE remuxSegments runs; if those lines are missing, segments arrived as base64-string blobs and remux will silently garbage out (the SimpleBlock walk reads non-sensical bytes); (e) ts-ebml parse failure on first segment: width/height derive from the first segment's track info; if `pickTrackInfoFromSegment` returns null, remux falls back to 1024x768 OR throws — check SW console for 'Remux: pickTrackInfoFromSegment returned null' diagnostic, which signals the first segment did not contain a Tracks → TrackEntry → Video element tree (segment shape changed from what the d13 probe documented). Type "approved" after step 8 lands (fixture committed and all 4 webm-playback tests GREEN). If step 9 hit, paste the failure diagnostic and the executor will iterate on Task 3.

<threat_model>

Trust Boundaries

Boundary Description
offscreen↔SW long-lived port (D-17-port-lifecycle architecture preserved) base64-encoded VideoSegment bytes cross here; the SW deserializes via base64ToBlob and now hands them to remuxSegments. No new trust boundary introduced by this plan.
third-party JS libraries (ts-ebml + webm-muxer) NEW: untrusted code from npm gains access to VP9 frame bytes. Both are MIT-licensed pure-JS libraries with no native deps. Per the d13 debug session library survey: ts-ebml has a single typeof window self-fallback (safe); webm-muxer is zero-DOM-refs. Verified by tests/background/webm-remux-deps.test.ts.
ffprobe + ffmpeg CLI invocations (existing — test-only) Tests Task 2 and Task 5 invoke /usr/bin/ffprobe and /usr/bin/ffmpeg. Trust-boundary unchanged from existing tests/offscreen/webm-playback.test.ts pattern; binaries are system-installed and read-only.

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-1-08-01 Tampering src/background/webm-remux.ts:remuxSegments consuming attacker-crafted WebM segments accept Defense in depth: the segments originate INSIDE the extension's own offscreen MediaRecorder; an attacker controlling them already controls the extension. Wrapping ts-ebml parse in try/catch and returning new Blob([]) on parse failure (which surfaces as EmptyVideoBufferError downstream) provides a clean failure mode without any new trust surface.
T-1-08-02 Denial of Service adversarial VideoSegment[] causing remuxSegments to allocate unbounded memory accept Buffer caller (saveArchive) already gates total input at 3 segments × ~800 KB = ~2.4 MB ceiling; ts-ebml + webm-muxer combined operate in pure-JS RAM without spawning sub-processes; SW heap headroom dominates the budget.
T-1-08-03 Information Disclosure npm supply-chain compromise of ts-ebml or webm-muxer mitigate package-lock.json pins exact resolved hashes. Both libraries vendored at release versions verified live on npm 2026-05-16 (ts-ebml@3.0.2, webm-muxer@5.1.4). No npm audit warnings at install time per Task 1 acceptance. Phase 5 hardening may add an automated SCA check; out of scope here.
T-1-08-04 Repudiation the regenerated fixture differing from operator's run-time recording shape mitigate Task 5 is the in-the-flesh empirical check by the operator (mpv + Chrome + ffprobe). The fixture commits ONLY after step 8 passes; if step 9 fails, the plan iterates rather than committing a misleading fixture.
</threat_model>
- `tests/offscreen/webm-playback.test.ts` shows 4 of 4 tests GREEN after Task 5 lands. - `tests/background/webm-remux.test.ts` shows 5 of 5 tests GREEN after Task 3. - `tests/background/webm-remux-deps.test.ts` shows 2 of 2 tests GREEN after Task 1. - `npx vitest run` final: 12 files / 60 tests / ALL GREEN. - `npx tsc --noEmit` exit 0. - `npm run build` exit 0. - `grep -c 'mergeVideoSegments' src/background/index.ts` excluding comments returns 0. - ffprobe on `tests/fixtures/last_30sec.webm` reports container duration >= 25_000 ms. - Operator empirical check: Chrome + mpv both play the fixture for ~30 s end-to-end. - CONTEXT.md ends with D-14-remux amendment (disambiguated marker per B-02 fix); original D-13 and D-17-port-lifecycle amendment blocks unmodified.

<success_criteria> SPEC §10 #7 (last_30sec.webm plays back in a browser) is functionally satisfied at the codebase level when:

  1. The regenerated tests/fixtures/last_30sec.webm plays for >= 25 s in Chrome AND mpv (operator-verified Task 5).
  2. The 4 webm-playback.test.ts assertions (zero decoder errors, no ended-prematurely, container duration >= 25 s, ffmpeg decode timeline >= 25 s) all GREEN.
  3. The remux helper unit tests (5) all GREEN against fixture-derived inputs.
  4. No file-concat merge path remains in the SW.
  5. The 53 baseline GREEN tests (Plan 01-07 closure suite + Option C tests) remain GREEN — no collateral regression.
  6. CONTEXT.md amendment block landed (D-14-remux disambiguated marker; verified by Task 4 step 9 grep checks). </success_criteria>
After completion, create `.planning/phases/01-stabilize-video-pipeline/01-08-SUMMARY.md` using the standard template. Cite: the 2 RED tests that flipped GREEN, the deleted mergeVideoSegments + new remuxSegments file, the regenerated fixture size + ffprobe duration, the CONTEXT amendment landing, npm dep additions with pinned versions, the total bundle size delta (`npm run build` output before vs after).