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

498 lines
43 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
phase: 01-stabilize-video-pipeline
plan: 08
type: tdd
wave: 1
depends_on: []
files_modified:
- 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
autonomous: false
requirements:
- REQ-video-ring-buffer
tags:
- webm
- remux
- ts-ebml
- webm-muxer
- vp9
- matroska
must_haves:
truths:
- "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)."
artifacts:
- path: "src/background/webm-remux.ts"
provides: "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."
min_lines: 120
exports:
- "remuxSegments"
- path: "tests/background/webm-remux.test.ts"
provides: "Unit-level RED→GREEN tests for the remux helper: single-EBML invariant, monotonic-timestamp invariant, frame-count preservation across input segments, keyframe-flag preservation."
min_lines: 100
- path: "src/background/index.ts"
provides: "createArchive() calls remuxSegments() (await — async) instead of synchronous mergeVideoSegments(); EmptyVideoBufferError surface preserved."
contains: "remuxSegments"
- path: "tests/fixtures/last_30sec.webm"
provides: "Regenerated fixture captured against the post-remux build; ffprobe container duration >= 25_000 ms; ffmpeg full-decode time >= 25_000 ms."
- path: "package.json"
provides: "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)."
contains: "ts-ebml"
- path: ".planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md"
provides: "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."
contains: "D-14-remux"
key_links:
- from: "src/background/index.ts:createArchive"
to: "src/background/webm-remux.ts:remuxSegments"
via: "await remuxSegments(videoBufferResponse.segments)"
pattern: "remuxSegments\\("
- from: "src/background/webm-remux.ts"
to: "ts-ebml package"
via: "import { Decoder } from 'ts-ebml'"
pattern: "from ['\"]ts-ebml['\"]"
- from: "src/background/webm-remux.ts"
to: "webm-muxer package"
via: "import { Muxer, ArrayBufferTarget } from 'webm-muxer'"
pattern: "from ['\"]webm-muxer['\"]"
- from: "tests/offscreen/webm-playback.test.ts"
to: "tests/fixtures/last_30sec.webm"
via: "ffprobe + ffmpeg drive the regenerated fixture; the 2 RED assertions (lines 232-316) flip GREEN."
pattern: "MIN_PLAYABLE_DURATION_MS"
---
<objective>
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.ts``mergeVideoSegments()` 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).
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<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
<interfaces>
<!-- Key types and contracts the executor will use. Extracted from codebase. -->
<!-- Executor should use these directly — no codebase exploration needed for them. -->
From `src/shared/types.ts`:
```typescript
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):
```typescript
// 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):
```typescript
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):
```typescript
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.
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Install ts-ebml + webm-muxer; verify SW-compat by import-only test (Wave 0 contract).</name>
<read_first>
- 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)
</read_first>
<files>package.json, package-lock.json, tests/background/webm-remux-deps.test.ts</files>
<behavior>
- 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).
</behavior>
<action>
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).
</action>
<verify>
<automated>npx vitest run tests/background/webm-remux-deps.test.ts &amp;&amp; npx tsc --noEmit</automated>
</verify>
<acceptance_criteria>
- `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).
</acceptance_criteria>
<done>Both libraries installed with pinned versions, the import-surface test pinning both library names is committed and GREEN, tsc clean, baseline suite preserved.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Add unit-level RED tests for remuxSegments() invariants (single-EBML, monotonic ts, frame-count, keyframe-flag).</name>
<read_first>
- 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).
</read_first>
<files>tests/background/webm-remux.test.ts</files>
<behavior>
- 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<Blob>` 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.)
</behavior>
<action>
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.
</action>
<verify>
<automated>npx vitest run tests/background/webm-remux.test.ts 2>&amp;1 | grep -E "Tests.*[0-9]+ failed"</automated>
</verify>
<acceptance_criteria>
- `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.
</acceptance_criteria>
<done>5 RED tests committed pinning the remux invariants at the unit level; baseline preserved.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Implement remuxSegments() in src/background/webm-remux.ts — drives all 5 RED tests in webm-remux.test.ts to GREEN.</name>
<read_first>
- 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).
</read_first>
<files>src/background/webm-remux.ts</files>
<behavior>
Implements `export async function remuxSegments(segments: VideoSegment[]): Promise<Blob>` satisfying all 5 tests from Task 2. See behavior list there.
</behavior>
<action>
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<ArrayBuffer>` — 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<ArrayBufferTarget>` 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.
</action>
<verify>
<automated>npx vitest run tests/background/webm-remux.test.ts &amp;&amp; npx tsc --noEmit</automated>
</verify>
<acceptance_criteria>
- `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).
</acceptance_criteria>
<done>Pure helper module landed; unit invariants pinned GREEN; full-suite delta = 5 new GREEN; tsc clean.</done>
</task>
<task type="auto" tdd="true">
<name>Task 4: Swap mergeVideoSegments → remuxSegments at call site in src/background/index.ts; await the now-async merge.</name>
<read_first>
- 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)
</read_first>
<files>src/background/index.ts</files>
<behavior>
- `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).
</behavior>
<action>
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.
</action>
<verify>
<automated>npx tsc --noEmit &amp;&amp; npx vitest run --reporter=dot &amp;&amp; test "$(grep -v '^\s*//' src/background/index.ts | grep -c 'mergeVideoSegments')" = "0" &amp;&amp; grep -q 'D-14-remux: WebM remux via ts-ebml + webm-muxer' .planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md &amp;&amp; grep -q '^- \*\*D-13:\*\* \*\*Fallback if D-12 fails:\*\*' .planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md &amp;&amp; grep -q 'D-17-port-lifecycle' .planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md &amp;&amp; grep -qF '`src/background/webm-remux.ts` replaces' .planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md</automated>
</verify>
<acceptance_criteria>
- `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.
</acceptance_criteria>
<done>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).</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 5: Operator regenerates tests/fixtures/last_30sec.webm via ./smoke.sh against the post-remux build; confirms ~30 s Chrome + mpv playback.</name>
<files>(operator-driven; no specific source file modified by this checkpoint)</files>
<action>See <how-to-verify> below — operator-driven empirical check; the executor agent must not bypass this checkpoint by stubbing.</action>
<verify>
<automated>echo "checkpoint:human-verify — see how-to-verify section; resume signal is the gate"</automated>
</verify>
<done>Operator types "approved" after running the how-to-verify steps. See <resume-signal> for the exact gate.</done>
<what-built>
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.
</what-built>
<how-to-verify>
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).
</how-to-verify>
<resume-signal>
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.
</resume-signal>
</task>
</tasks>
<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>
<verification>
- `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.
</verification>
<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>
<output>
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).
</output>