677 lines
31 KiB
Markdown
677 lines
31 KiB
Markdown
---
|
|
phase: 01-stabilize-video-pipeline
|
|
plan: 03
|
|
type: tdd
|
|
wave: 1
|
|
depends_on: ["02"]
|
|
files_modified:
|
|
- src/offscreen/recorder.ts
|
|
- src/offscreen/index.html
|
|
- src/shared/logger.ts
|
|
- src/shared/types.ts
|
|
autonomous: true
|
|
requirements:
|
|
- REQ-video-ring-buffer
|
|
requirements_addressed:
|
|
- REQ-video-ring-buffer
|
|
|
|
must_haves:
|
|
truths:
|
|
- "`src/offscreen/recorder.ts` exists and exports the symbols Plan 02 tests against: addChunk, trimAged, getBuffer, resetBuffer, assertCodecSupported, VIDEO_BUFFER_DURATION_MS"
|
|
- "Running `npx vitest run tests/offscreen/ring-buffer.test.ts tests/offscreen/codec-check.test.ts` exits 0 with all tests green"
|
|
- "Buffer holds at most: 1 header chunk + every chunk with arrival timestamp newer than now-30_000ms"
|
|
- "Codec strictly bound to `video/webm;codecs=vp9` at 400 000 bps with `MediaRecorder.isTypeSupported` gate; no fallback chain (D-20)"
|
|
- "`MediaRecorder.start(2000)` is called on session start (timeslice = 2000 ms per SPEC §4.1)"
|
|
- "On `MediaStreamTrack.onended`, the buffer is cleared and a `RECORDING_ERROR` of `'user-stopped-sharing'` is emitted to SW"
|
|
- "Restart-segments fallback (D-13) is pre-staged as a commented-out skeleton at the bottom of recorder.ts so Plan 07's fallback path doesn't require a re-plan"
|
|
- "`src/offscreen/index.html` exists at the source path and references `./recorder.ts`"
|
|
- "`src/shared/logger.ts` has an `OffscreenLogger` class with `[OS:...]` prefix"
|
|
- "`src/shared/types.ts` carries the new `PortMessageType` and `PortMessage` types; `VIDEO_CHUNK` and `VIDEO_CHUNK_SAVED` removed"
|
|
artifacts:
|
|
- path: "src/offscreen/recorder.ts"
|
|
provides: "Ring buffer + getDisplayMedia + MediaRecorder + codec strict-mode + track-ended handler"
|
|
min_lines: 150
|
|
exports: ["addChunk", "trimAged", "getBuffer", "resetBuffer", "assertCodecSupported", "VIDEO_BUFFER_DURATION_MS"]
|
|
- path: "src/offscreen/index.html"
|
|
provides: "crxjs-managed offscreen entry point"
|
|
contains: "./recorder.ts"
|
|
- path: "src/shared/logger.ts"
|
|
provides: "OffscreenLogger added alongside existing Logger / ContentLogger"
|
|
contains: "export class OffscreenLogger"
|
|
- path: "src/shared/types.ts"
|
|
provides: "Port message types added; deleted VIDEO_CHUNK / VIDEO_CHUNK_SAVED variants"
|
|
contains: "PortMessage"
|
|
key_links:
|
|
- from: "src/offscreen/recorder.ts"
|
|
to: "src/shared/types.ts"
|
|
via: "import type { Message, VideoChunk, PortMessage } from '../shared/types'"
|
|
pattern: "from '../shared/types'"
|
|
- from: "src/offscreen/recorder.ts"
|
|
to: "src/shared/logger.ts"
|
|
via: "import { OffscreenLogger } from '../shared/logger'"
|
|
pattern: "from '../shared/logger'"
|
|
- from: "src/offscreen/index.html"
|
|
to: "src/offscreen/recorder.ts"
|
|
via: "<script type=\"module\" src=\"./recorder.ts\">"
|
|
pattern: "./recorder.ts"
|
|
---
|
|
|
|
<objective>
|
|
GREEN-implement the offscreen recorder module that owns:
|
|
- `getDisplayMedia` capture (CONTEXT.md D-01..D-03)
|
|
- single continuous `MediaRecorder` at timeslice 2000 ms (D-09)
|
|
- in-memory ring buffer with header retention + 30 s age trim (D-10, D-11)
|
|
- codec strict-mode (D-20 / RESEARCH.md Pattern 6)
|
|
- `MediaStreamTrack.onended` cleanup (RESEARCH.md Example F)
|
|
- restart-segments fallback pre-staged as a documented skeleton (D-13)
|
|
|
|
This plan flips the 2 ring-buffer + codec test files (created RED in Plan 02)
|
|
to GREEN. It does NOT yet wire up the port-keepalive or the OFFSCREEN_READY
|
|
handshake — that is Plan 04. To keep this plan inside its context budget, the
|
|
port-side code in `recorder.ts` is left as a small import-time placeholder
|
|
that Plan 04 fills in. The Plan-02 port + handshake tests therefore remain
|
|
RED until Plan 04 lands, which is the intended choreography per the
|
|
wave-1 dependency graph (Plans 03 and 04 run in parallel; Plan 02 wrote both
|
|
sets of RED tests in advance).
|
|
|
|
Purpose: REQ-video-ring-buffer's load-bearing logic lives here. The ring
|
|
buffer is a pure function — exactly the TDD sweet spot. The remaining
|
|
non-pure side (getDisplayMedia + MediaRecorder construction + track.onended)
|
|
is wired in the same module because the buffer is private state owned by it.
|
|
Both test files attached to this plan run in pure Node and don't need a
|
|
browser.
|
|
|
|
Output: `src/offscreen/recorder.ts` (the new authoritative source-of-truth),
|
|
`src/offscreen/index.html` (crxjs entry point), `src/shared/logger.ts`
|
|
extended with `OffscreenLogger`, `src/shared/types.ts` cleaned up.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.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-PATTERNS.md
|
|
@.planning/phases/01-stabilize-video-pipeline/01-VALIDATION.md
|
|
@tests/offscreen/ring-buffer.test.ts
|
|
@tests/offscreen/codec-check.test.ts
|
|
@src/background/index.ts
|
|
@src/shared/types.ts
|
|
@src/shared/logger.ts
|
|
@offscreen/index.ts
|
|
</context>
|
|
|
|
<interfaces>
|
|
<!-- Plan 02 already wrote tests that pin this module's contracts.
|
|
This plan implements the module to satisfy those tests. -->
|
|
|
|
Required exports from `src/offscreen/recorder.ts` (verified against the RED tests):
|
|
|
|
```typescript
|
|
export const VIDEO_BUFFER_DURATION_MS: number;
|
|
export function addChunk(blob: Blob, timestamp: number): void;
|
|
export function trimAged(now: number): void;
|
|
export function getBuffer(): VideoChunk[];
|
|
export function resetBuffer(): void;
|
|
export function assertCodecSupported(): void; // throws Error on unsupported vp9
|
|
```
|
|
|
|
Required side-effects of importing the module (pinned by Plan 02 tests):
|
|
|
|
- `chrome.runtime.onMessage.addListener(...)` called exactly once
|
|
- `chrome.runtime.sendMessage({ type: 'OFFSCREEN_READY', ... })` called exactly once
|
|
- `chrome.runtime.connect({ name: 'video-keepalive' })` called exactly once
|
|
|
|
The OFFSCREEN_READY send AND the port connect are owned by Plan 04 — Plan 03
|
|
adds a small bootstrap stub that calls them in the right order so that
|
|
the Plan-02 ring-buffer + codec tests can stub `chrome.runtime` minimally
|
|
without crashing on the bootstrap. The full port reconnect + ping logic
|
|
lands in Plan 04.
|
|
|
|
VideoChunk type (from src/shared/types.ts:27-31, unchanged):
|
|
```typescript
|
|
export interface VideoChunk {
|
|
data: Blob;
|
|
timestamp: number;
|
|
isFirst?: boolean;
|
|
}
|
|
```
|
|
</interfaces>
|
|
|
|
<threat_model>
|
|
## Trust Boundaries
|
|
|
|
| Boundary | Description |
|
|
|----------|-------------|
|
|
| browser getDisplayMedia picker → offscreen `MediaStream` | The operator selects what to share; offscreen receives a track they chose |
|
|
| `MediaRecorder.isTypeSupported` → recorder.start path | A spoofed `MediaRecorder` could attempt to influence codec selection (only matters in test fixtures, but the strict-mode prevents production codec downgrade) |
|
|
|
|
## STRIDE Threat Register
|
|
|
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
|
|-----------|----------|-----------|-------------|-----------------|
|
|
| T-1-01 | Tampering — codec downgrade | `MediaRecorder` constructor | mitigate | `assertCodecSupported()` calls `MediaRecorder.isTypeSupported('video/webm;codecs=vp9')` BEFORE constructing the recorder; if it returns false, throws an Error and emits `RECORDING_ERROR` to SW. No vp8 / h264 / default fallback chain. The strict mode covers `MediaRecorder` itself being absent (the codec-check test mocks both cases). Grep gate: `grep -v '^#' src/offscreen/recorder.ts \| grep -cE 'codecs=(vp8\|h264)'` returns 0 (no fallback codec strings in the module). |
|
|
| T-1-03 | Information Disclosure — captured stream contains other apps' content | `getDisplayMedia` stream | accept | This is the documented residual risk per CONTEXT.md D-04. The Chrome "Sharing your screen" indicator is the user-facing signal; the operator chose to share. No code-level mitigation is possible (the API is supposed to capture the screen). Documented as accepted risk in 01-RESEARCH.md §"Security Domain". |
|
|
| T-1-NEW-03-01 | DoS — unbounded buffer growth on a stuck timestamp | `addChunk` + `trimAged` | mitigate | The trim function uses arrival timestamp; if a clock anomaly produces a negative or stuck `now`, the buffer is bounded above by SPEC §10 #9 (50 MB RAM ceiling) anyway. Defensive belt: the recorder also exposes `getBuffer().length` to SW so a healthchecker can observe growth. No active rate-limit needed for Phase 1. |
|
|
</threat_model>
|
|
|
|
<feature>
|
|
<name>Offscreen recorder module — ring buffer + getDisplayMedia + codec strict-mode + track-ended cleanup</name>
|
|
<files>src/offscreen/recorder.ts (new), src/offscreen/index.html (new), src/shared/logger.ts (modify), src/shared/types.ts (modify)</files>
|
|
<behavior>
|
|
RED already exists (Plan 02). GREEN must satisfy:
|
|
|
|
Ring buffer (from `tests/offscreen/ring-buffer.test.ts`):
|
|
- After `resetBuffer()`, `getBuffer()` returns `[]`
|
|
- First `addChunk` produces an entry with `isFirst: true`; the first-chunk flag is sticky for the FIRST add only and survives subsequent trims
|
|
- Second and later `addChunk` calls produce entries with `isFirst: false` (or undefined — `falsy`)
|
|
- `trimAged(now)` removes every entry whose `timestamp < now - VIDEO_BUFFER_DURATION_MS` AND whose `isFirst` is not `true`
|
|
- `VIDEO_BUFFER_DURATION_MS === 30_000`
|
|
- `trimAged` on empty buffer does not throw
|
|
|
|
Codec strict-mode (from `tests/offscreen/codec-check.test.ts`):
|
|
- `assertCodecSupported()` calls `MediaRecorder.isTypeSupported('video/webm;codecs=vp9')`
|
|
- If the return is falsy: throw a new `Error` whose message starts with `'vp9 unsupported'` AND call `chrome.runtime.sendMessage({ type: 'RECORDING_ERROR', error: <message> })` BEFORE throwing
|
|
- If the return is truthy: do not throw and do not emit RECORDING_ERROR
|
|
|
|
Bootstrap side-effects (pinned by Plan 02 handshake + port tests but Plan 03 only needs to STUB them — Plan 04 fills in):
|
|
- At module import time, register `chrome.runtime.onMessage.addListener(...)` BEFORE calling `chrome.runtime.sendMessage({ type: 'OFFSCREEN_READY' })` (handshake ordering — Plan 04 elaborates)
|
|
- Call `chrome.runtime.connect({ name: 'video-keepalive' })` once (Plan 04 wires the ping loop and reconnect)
|
|
|
|
</behavior>
|
|
<implementation>
|
|
See task list — three tasks: RED-verify, GREEN-implement, then types + logger ergonomics.
|
|
</implementation>
|
|
</feature>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: RED-verify — confirm Plan 02 tests fail at module resolution</name>
|
|
<files>(none modified)</files>
|
|
<read_first>
|
|
- tests/offscreen/ring-buffer.test.ts (Plan 02 output)
|
|
- tests/offscreen/codec-check.test.ts (Plan 02 output)
|
|
</read_first>
|
|
<action>
|
|
Run the two test files this plan owns and CAPTURE the failure mode:
|
|
|
|
```bash
|
|
npx vitest run tests/offscreen/ring-buffer.test.ts tests/offscreen/codec-check.test.ts 2>&1 | tee /tmp/01-03-red.log
|
|
```
|
|
|
|
The output MUST contain "Cannot find module '../../src/offscreen/recorder'" (or "Failed to resolve" — Vitest's wording varies by version). The exit code MUST be non-zero. Both of these confirm the RED gate is in place from Plan 02.
|
|
|
|
If either condition is NOT met:
|
|
- If the test passes: Plan 02 wrote a degenerate test — STOP and escalate (a passing test before the implementation exists means the test is wrong).
|
|
- If the test fails for a different reason (e.g., syntax error in the test file): STOP and escalate to Plan 02 owner.
|
|
|
|
Otherwise, RED is confirmed; proceed to Task 2.
|
|
|
|
This is a verify-only step; nothing is committed.
|
|
</action>
|
|
<verify>
|
|
<automated>grep -qE "Cannot find module|Failed to resolve" /tmp/01-03-red.log && ! npx vitest run tests/offscreen/ring-buffer.test.ts tests/offscreen/codec-check.test.ts 2>&1 | tail -1 | grep -q "passed"</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- `/tmp/01-03-red.log` exists and is non-empty
|
|
- Contains "Cannot find module" or "Failed to resolve"
|
|
- Exit code of the vitest invocation was non-zero
|
|
</acceptance_criteria>
|
|
<done>RED gate confirmed. Plan 02 test files import a module that does not yet exist. Proceed to GREEN.</done>
|
|
</task>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 2: GREEN — write src/offscreen/recorder.ts and src/offscreen/index.html</name>
|
|
<files>src/offscreen/recorder.ts, src/offscreen/index.html</files>
|
|
<read_first>
|
|
- src/background/index.ts (lines 26-75 — ring-buffer logic being relocated)
|
|
- src/shared/types.ts (the VideoChunk interface to import)
|
|
- src/shared/logger.ts (Logger class shape; OffscreenLogger lands in Task 3)
|
|
- .planning/phases/01-stabilize-video-pipeline/01-PATTERNS.md (lines 35-225 — composed pattern for the new module)
|
|
- .planning/phases/01-stabilize-video-pipeline/01-RESEARCH.md §"Example A" + §"Example E" + §"Example F" (lines 882-893, 949-971, 973-989)
|
|
- tests/offscreen/ring-buffer.test.ts (the export surface to match)
|
|
- tests/offscreen/codec-check.test.ts (the export surface to match)
|
|
</read_first>
|
|
<behavior>
|
|
After Task 2 ships, the following MUST hold:
|
|
|
|
```bash
|
|
npx vitest run tests/offscreen/ring-buffer.test.ts tests/offscreen/codec-check.test.ts --reporter=dot
|
|
# exit 0 — all 6 tests pass (4 in ring-buffer, 2 in codec-check)
|
|
```
|
|
</behavior>
|
|
<action>
|
|
Write the new `src/offscreen/recorder.ts`. Use the VERBATIM module below — line-by-line, comments and all (Russian section headers preserved per CONTEXT.md "Established patterns"):
|
|
|
|
```typescript
|
|
// src/offscreen/recorder.ts — Phase 01 source of truth (replaces dead
|
|
// offscreen/index.ts and the inline string in vite.config.ts:13-216).
|
|
// Owns: getDisplayMedia capture, MediaRecorder lifecycle, in-memory ring
|
|
// buffer with WebM-header retention + 30 s age trim, codec strict-mode,
|
|
// track.onended cleanup. Port keepalive + OFFSCREEN_READY handshake are
|
|
// wired by Plan 04 in the matching wave-1 lane.
|
|
|
|
import { OffscreenLogger } from '../shared/logger';
|
|
import type { Message, VideoChunk } from '../shared/types';
|
|
|
|
// ─── Константы (per CON-video-codec, CON-video-window) ──────────────────
|
|
export const VIDEO_BUFFER_DURATION_MS = 30_000; // 30 секунд
|
|
const VIDEO_MIME = 'video/webm;codecs=vp9'; // D-20 strict — no fallback
|
|
const VIDEO_BITRATE = 400_000; // CON-video-codec
|
|
const TIMESLICE_MS = 2_000; // SPEC §4.1
|
|
const PORT_NAME = 'video-keepalive'; // Plan 04 owns the ping loop
|
|
|
|
const logger = new OffscreenLogger('Recorder');
|
|
|
|
// ─── Состояние модуля (module-level, NOT inside startRecording — fixes audit P0 #1 shadow) ───
|
|
let videoRecorder: MediaRecorder | null = null; // renamed from 'mediaRecorder' to prevent shadowing
|
|
let mediaStream: MediaStream | null = null;
|
|
let videoBuffer: VideoChunk[] = [];
|
|
let firstChunkSaved = false;
|
|
let keepalivePort: chrome.runtime.Port | null = null; // Plan 04 fills the lifecycle
|
|
|
|
// ─── Кольцевой буфер (pure functions — testable in Node) ────────────────
|
|
|
|
export function addChunk(blob: Blob, timestamp: number): void {
|
|
const chunk: VideoChunk = {
|
|
data: blob,
|
|
timestamp,
|
|
isFirst: !firstChunkSaved,
|
|
};
|
|
if (!firstChunkSaved) {
|
|
firstChunkSaved = true;
|
|
logger.log('First chunk (WebM header) pinned, size:', blob.size);
|
|
}
|
|
videoBuffer.push(chunk);
|
|
trimAged(timestamp);
|
|
}
|
|
|
|
export function trimAged(now: number): void {
|
|
const cutoff = now - VIDEO_BUFFER_DURATION_MS;
|
|
videoBuffer = videoBuffer.filter((chunk) => {
|
|
if (chunk.isFirst) {
|
|
return true;
|
|
}
|
|
return chunk.timestamp >= cutoff;
|
|
});
|
|
}
|
|
|
|
export function getBuffer(): VideoChunk[] {
|
|
return videoBuffer;
|
|
}
|
|
|
|
export function resetBuffer(): void {
|
|
videoBuffer = [];
|
|
firstChunkSaved = false;
|
|
}
|
|
|
|
// ─── Проверка кодека (D-20 strict-mode — no fallback chain) ─────────────
|
|
|
|
export function assertCodecSupported(): void {
|
|
const supported =
|
|
typeof MediaRecorder !== 'undefined' &&
|
|
typeof MediaRecorder.isTypeSupported === 'function' &&
|
|
MediaRecorder.isTypeSupported(VIDEO_MIME);
|
|
if (!supported) {
|
|
const ua = typeof navigator !== 'undefined' ? navigator.userAgent : '<unknown>';
|
|
const errMessage = `vp9 unsupported. UA=${ua}`;
|
|
chrome.runtime.sendMessage({ type: 'RECORDING_ERROR', error: errMessage });
|
|
throw new Error(errMessage);
|
|
}
|
|
}
|
|
|
|
// ─── Захват экрана (getDisplayMedia inside offscreen — D-01) ────────────
|
|
|
|
async function startRecording(): Promise<void> {
|
|
if (videoRecorder !== null && videoRecorder.state !== 'inactive') {
|
|
logger.warn('Recording already active; ignoring duplicate START_RECORDING');
|
|
return;
|
|
}
|
|
try {
|
|
assertCodecSupported(); // throws if vp9 missing — no fallback
|
|
const stream = await navigator.mediaDevices.getDisplayMedia({
|
|
video: true,
|
|
audio: false, // SPEC §9 — Phase 2 / CAP-01 territory
|
|
});
|
|
mediaStream = stream;
|
|
videoRecorder = new MediaRecorder(stream, {
|
|
mimeType: VIDEO_MIME,
|
|
videoBitsPerSecond: VIDEO_BITRATE,
|
|
});
|
|
videoRecorder.ondataavailable = onDataAvailable;
|
|
videoRecorder.onerror = (event) => logger.error('MediaRecorder error:', event);
|
|
// Track end detection — RESEARCH.md Example F. Attach to ALL tracks
|
|
// (Pitfall 6) so an audio-track edge case won't silently desync.
|
|
stream.getTracks().forEach((track) => {
|
|
track.addEventListener('ended', onUserStoppedSharing, { once: true });
|
|
});
|
|
videoRecorder.start(TIMESLICE_MS);
|
|
logger.log('Recording started, mime:', VIDEO_MIME, 'timeslice:', TIMESLICE_MS, 'ms');
|
|
} catch (error) {
|
|
const msg = error instanceof Error ? error.message : String(error);
|
|
logger.error('startRecording failed:', msg);
|
|
chrome.runtime.sendMessage({ type: 'RECORDING_ERROR', error: msg });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function onDataAvailable(event: BlobEvent): void {
|
|
if (!event.data || event.data.size === 0) {
|
|
return;
|
|
}
|
|
addChunk(event.data, Date.now());
|
|
}
|
|
|
|
function onUserStoppedSharing(): void {
|
|
logger.log('Operator stopped sharing — clearing buffer');
|
|
resetBuffer();
|
|
if (videoRecorder !== null && videoRecorder.state !== 'inactive') {
|
|
videoRecorder.stop();
|
|
}
|
|
if (mediaStream !== null) {
|
|
mediaStream.getTracks().forEach((t) => t.stop());
|
|
mediaStream = null;
|
|
}
|
|
videoRecorder = null;
|
|
chrome.runtime.sendMessage({ type: 'RECORDING_ERROR', error: 'user-stopped-sharing' });
|
|
}
|
|
|
|
function stopRecording(): void {
|
|
if (videoRecorder !== null && videoRecorder.state !== 'inactive') {
|
|
videoRecorder.stop();
|
|
logger.log('Recording stopped manually');
|
|
}
|
|
}
|
|
|
|
// ─── Bootstrap (Plan 04 wires the full port + handshake) ────────────────
|
|
// Plan 03 commits this minimal bootstrap so that Plan 02's ring-buffer +
|
|
// codec tests can mock `chrome.runtime` without crashing on module import.
|
|
// Plan 04 elaborates the OFFSCREEN_READY handshake and the port reconnect
|
|
// loop without rewriting this module's exports.
|
|
|
|
function isFromOwnExtension(sender: chrome.runtime.MessageSender | undefined): boolean {
|
|
return sender?.id === chrome.runtime.id;
|
|
}
|
|
|
|
chrome.runtime.onMessage.addListener((message: Message, sender, sendResponse) => {
|
|
if (!isFromOwnExtension(sender)) {
|
|
return false;
|
|
}
|
|
switch (message.type) {
|
|
case 'START_RECORDING':
|
|
startRecording().then(() => sendResponse({ ok: true })).catch((err) => sendResponse({ ok: false, error: String(err) }));
|
|
return true;
|
|
case 'STOP_RECORDING':
|
|
stopRecording();
|
|
sendResponse({ ok: true });
|
|
return false;
|
|
default:
|
|
return false;
|
|
}
|
|
});
|
|
|
|
// Plan 04 will replace this stub with the full reconnect + ping loop.
|
|
keepalivePort = chrome.runtime.connect({ name: PORT_NAME });
|
|
chrome.runtime.sendMessage({ type: 'OFFSCREEN_READY' });
|
|
|
|
// Touch the keepalive var so noUnusedLocals doesn't complain — Plan 04
|
|
// uses it. Once Plan 04 lands, this line is removed in its refactor pass.
|
|
void keepalivePort;
|
|
|
|
// ─── D-13 fallback: restart-segments skeleton (pre-staged per CONTEXT.md) ──
|
|
// Activated only if the Phase 07 ffprobe gate fails on the simpler
|
|
// continuous-recorder + age-trim approach. See RESEARCH.md Pattern 3.
|
|
//
|
|
// const SEGMENT_MS = 10_000;
|
|
// const MAX_SEGMENTS = 3;
|
|
// let segments: Blob[] = [];
|
|
// let currentChunks: Blob[] = [];
|
|
// function rotateSegment(): void { videoRecorder?.stop(); /* onstop assembles a segment */ }
|
|
// function onSegmentStopped(): void {
|
|
// segments.push(new Blob(currentChunks, { type: 'video/webm' }));
|
|
// if (segments.length > MAX_SEGMENTS) segments.shift();
|
|
// currentChunks = [];
|
|
// if (mediaStream) {
|
|
// videoRecorder = new MediaRecorder(mediaStream, { mimeType: VIDEO_MIME, videoBitsPerSecond: VIDEO_BITRATE });
|
|
// videoRecorder.ondataavailable = (e) => { if (e.data.size > 0) currentChunks.push(e.data); };
|
|
// videoRecorder.onstop = onSegmentStopped;
|
|
// videoRecorder.start();
|
|
// setTimeout(rotateSegment, SEGMENT_MS);
|
|
// }
|
|
// }
|
|
```
|
|
|
|
Then write `src/offscreen/index.html` with VERBATIM content:
|
|
|
|
```html
|
|
<!doctype html>
|
|
<html>
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>Mokosh Recorder</title>
|
|
</head>
|
|
<body>
|
|
<script type="module" src="./recorder.ts"></script>
|
|
</body>
|
|
</html>
|
|
```
|
|
|
|
Notes:
|
|
- The exported pure functions (`addChunk`, `trimAged`, `getBuffer`, `resetBuffer`, `assertCodecSupported`) are deliberately decoupled from the impure `getDisplayMedia` path so they can be exercised in Node — exactly what Plan 02's ring-buffer + codec tests do.
|
|
- The handshake `OFFSCREEN_READY` send + the `chrome.runtime.connect` call are minimal stubs that satisfy the import-time test side-effects. Plan 04 elaborates them (adds ping loop, adds onDisconnect reconnect, adds REQUEST_BUFFER handler) — the changes Plan 04 makes are ADDITIVE within the same module, so they do NOT break Plan 03's tests.
|
|
- The `as any` count in this module is zero, the `@ts-ignore` count is zero (per CLAUDE.md naming rule + CONTEXT.md "Established patterns").
|
|
|
|
After writing both files, run:
|
|
```bash
|
|
npx vitest run tests/offscreen/ring-buffer.test.ts tests/offscreen/codec-check.test.ts --reporter=dot
|
|
npx tsc --noEmit
|
|
```
|
|
|
|
Both MUST exit 0.
|
|
</action>
|
|
<verify>
|
|
<automated>npx vitest run tests/offscreen/ring-buffer.test.ts tests/offscreen/codec-check.test.ts && npx tsc --noEmit</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- `test -f src/offscreen/recorder.ts && test -f src/offscreen/index.html` exits 0
|
|
- `npx vitest run tests/offscreen/ring-buffer.test.ts tests/offscreen/codec-check.test.ts` exits 0 with 6 passing tests
|
|
- `npx tsc --noEmit` exits 0
|
|
- `grep -c "export function addChunk" src/offscreen/recorder.ts` returns 1
|
|
- `grep -c "export function trimAged" src/offscreen/recorder.ts` returns 1
|
|
- `grep -c "export function getBuffer" src/offscreen/recorder.ts` returns 1
|
|
- `grep -c "export function resetBuffer" src/offscreen/recorder.ts` returns 1
|
|
- `grep -c "export function assertCodecSupported" src/offscreen/recorder.ts` returns 1
|
|
- `grep -c "VIDEO_BUFFER_DURATION_MS = 30_000" src/offscreen/recorder.ts` returns 1
|
|
- `grep -v '^#' src/offscreen/recorder.ts | grep -cE "codecs=(vp8|h264)"` returns 0 (T-1-01 mitigation — no fallback codec)
|
|
- `grep -c "as any" src/offscreen/recorder.ts` returns 0 (CLAUDE.md rule)
|
|
- `grep -c "@ts-ignore" src/offscreen/recorder.ts` returns 0
|
|
- `grep -c "start(2000)" src/offscreen/recorder.ts` returns 0 BUT `grep -c "TIMESLICE_MS" src/offscreen/recorder.ts` returns at least 2 (start uses the constant)
|
|
- `grep -c "./recorder.ts" src/offscreen/index.html` returns 1
|
|
</acceptance_criteria>
|
|
<done>Two tests files green; ring buffer and codec strict-mode behavior implemented; offscreen HTML entry exists; tsc clean.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 3: Logger + types ergonomics — add OffscreenLogger, clean up shared/types.ts</name>
|
|
<files>src/shared/logger.ts, src/shared/types.ts</files>
|
|
<read_first>
|
|
- src/shared/logger.ts (lines 1-51 — Logger and ContentLogger shapes)
|
|
- src/shared/types.ts (lines 1-68 — full current state)
|
|
- .planning/phases/01-stabilize-video-pipeline/01-PATTERNS.md §"Shared Patterns / Logging" (lines 716-759)
|
|
- .planning/phases/01-stabilize-video-pipeline/01-PATTERNS.md §`src/shared/types.ts` (lines 480-530)
|
|
</read_first>
|
|
<action>
|
|
Two edits.
|
|
|
|
(1) `src/shared/logger.ts` — append a new `OffscreenLogger` class at the END of the file, mirroring the shape of `ContentLogger`:
|
|
|
|
```typescript
|
|
|
|
// Логгер для Offscreen Document
|
|
export class OffscreenLogger {
|
|
private context: string;
|
|
|
|
constructor(context: string) {
|
|
this.context = context;
|
|
}
|
|
|
|
private logWithLevel(level: 'log' | 'warn' | 'error', ...args: any[]) {
|
|
const timestamp = new Date().toISOString();
|
|
console[level](`[OS:${this.context}] ${timestamp}`, ...args);
|
|
}
|
|
|
|
log(...args: any[]) {
|
|
this.logWithLevel('log', ...args);
|
|
}
|
|
|
|
warn(...args: any[]) {
|
|
this.logWithLevel('warn', ...args);
|
|
}
|
|
|
|
error(...args: any[]) {
|
|
this.logWithLevel('error', ...args);
|
|
}
|
|
}
|
|
```
|
|
|
|
Do not modify the existing `Logger` or `ContentLogger` classes.
|
|
|
|
(2) `src/shared/types.ts` — three edits:
|
|
|
|
(a) Remove `'VIDEO_CHUNK'` and `'VIDEO_CHUNK_SAVED'` from the `MessageType` union. The `MessageType` becomes:
|
|
|
|
```typescript
|
|
export type MessageType =
|
|
| 'REQUEST_PERMISSIONS'
|
|
| 'PERMISSIONS_GRANTED'
|
|
| 'PERMISSIONS_DENIED'
|
|
| 'GET_VIDEO_BUFFER'
|
|
| 'VIDEO_BUFFER_RESPONSE'
|
|
| 'GET_RRWEB_EVENTS'
|
|
| 'RRWEB_EVENTS_RESPONSE'
|
|
| 'SAVE_ARCHIVE'
|
|
| 'ARCHIVE_SAVED'
|
|
| 'START_RECORDING'
|
|
| 'STOP_RECORDING'
|
|
| 'RECORDING_ERROR'
|
|
| 'OFFSCREEN_READY';
|
|
```
|
|
|
|
(b) After the existing `Message<T>` interface (after line 24), add the port message types VERBATIM:
|
|
|
|
```typescript
|
|
|
|
// Типы сообщений в long-lived port (offscreen ↔ SW; D-17 / Plan 04)
|
|
export type PortMessageType =
|
|
| 'PING'
|
|
| 'REQUEST_BUFFER'
|
|
| 'BUFFER';
|
|
|
|
export interface PortMessage {
|
|
type: PortMessageType;
|
|
chunks?: VideoChunk[];
|
|
}
|
|
```
|
|
|
|
(c) Leave the `VideoChunk`, `UserEvent`, `SessionMetadata`, `PopupState`, `VideoBufferResponse`, `RrwebEventsResponse` interfaces unchanged.
|
|
|
|
After both edits, run:
|
|
```bash
|
|
npx tsc --noEmit
|
|
npx vitest run
|
|
```
|
|
|
|
Both MUST exit 0 (the 2 ring-buffer + codec tests should still pass; the 2 handshake + port tests remain RED — they get GREEN in Plan 04).
|
|
</action>
|
|
<verify>
|
|
<automated>npx tsc --noEmit && grep -c "OffscreenLogger" src/shared/logger.ts && grep -cE "VIDEO_CHUNK[^_S]|VIDEO_CHUNK_SAVED" src/shared/types.ts</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- `npx tsc --noEmit` exits 0
|
|
- `grep -c "export class OffscreenLogger" src/shared/logger.ts` returns 1
|
|
- `grep -c "'VIDEO_CHUNK'" src/shared/types.ts` returns 0 (removed)
|
|
- `grep -c "'VIDEO_CHUNK_SAVED'" src/shared/types.ts` returns 0 (removed)
|
|
- `grep -c "PortMessage" src/shared/types.ts` returns at least 2 (one for the type alias, one for the interface)
|
|
- `grep -c "OFFSCREEN_READY" src/shared/types.ts` returns 1 (still present)
|
|
- `npx vitest run tests/offscreen/ring-buffer.test.ts tests/offscreen/codec-check.test.ts` exits 0
|
|
</acceptance_criteria>
|
|
<done>OffscreenLogger available; types cleaned up; ring-buffer + codec tests still green; the deleted message types prevent Plan 05 from accidentally re-introducing the broken sendMessage-Blob path.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 4: REFACTOR — optional cleanup pass (only if obvious improvements exist)</name>
|
|
<files>src/offscreen/recorder.ts (only if changed)</files>
|
|
<read_first>
|
|
- src/offscreen/recorder.ts (Task 2 output)
|
|
</read_first>
|
|
<action>
|
|
Per the TDD REFACTOR phase rules in `$HOME/.claude/get-shit-done/references/tdd.md`:
|
|
|
|
1. Re-read `src/offscreen/recorder.ts` (from Task 2).
|
|
2. Look ONLY for obvious improvements:
|
|
- Constant duplication
|
|
- Unused imports
|
|
- Mis-placed comments
|
|
3. If no obvious improvements: SKIP THIS TASK (do not commit). Note in the summary: "Refactor: no changes needed."
|
|
4. If improvements found: make them; run `npx vitest run && npx tsc --noEmit`; commit ONLY if both exit 0.
|
|
|
|
Do NOT use this task to add features. Do NOT rewrite the bootstrap section (that's Plan 04's territory). Do NOT extract the ring buffer into a separate file in this phase (audit-driven decision: one source of truth in `src/offscreen/recorder.ts`).
|
|
</action>
|
|
<verify>
|
|
<automated>npx vitest run tests/offscreen/ring-buffer.test.ts tests/offscreen/codec-check.test.ts && npx tsc --noEmit</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- All Plan-03-owned tests still pass after the refactor pass (or after the no-op decision)
|
|
- `npx tsc --noEmit` still exits 0
|
|
- The SUMMARY.md documents whether refactor happened or was skipped
|
|
</acceptance_criteria>
|
|
<done>REFACTOR phase complete (or explicitly skipped with rationale).</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
After all four tasks land:
|
|
|
|
1. `npx vitest run tests/offscreen/ring-buffer.test.ts tests/offscreen/codec-check.test.ts` — exits 0, 6 tests passing.
|
|
2. `npx vitest run tests/offscreen/handshake.test.ts tests/offscreen/port.test.ts` — exits NON-ZERO (these stay RED until Plan 04). The failure mode is now NOT a module-resolution error (since the module exists), but rather an assertion mismatch — the stub bootstrap calls `chrome.runtime.sendMessage({type: 'OFFSCREEN_READY'})` exactly once and `chrome.runtime.connect({name: 'video-keepalive'})` exactly once, so the handshake test MAY actually pass after Plan 03 lands. **This is acceptable**: the reconnect test is what definitively pins Plan 04.
|
|
3. `npx tsc --noEmit` — exits 0.
|
|
4. `grep -c "as any\|@ts-ignore" src/offscreen/recorder.ts src/offscreen/index.html` — returns 0.
|
|
5. `grep -v '^#' src/offscreen/recorder.ts | grep -cE "codecs=(vp8|h264)"` — returns 0 (T-1-01 mitigation grep gate).
|
|
6. `wc -l src/offscreen/recorder.ts` — at least 150 lines.
|
|
|
|
Commit cadence: per the TDD reference, this is the GREEN phase of a TDD plan:
|
|
- Task 1: no commit (verify-only).
|
|
- Task 2: ONE commit (`feat(01-03): implement offscreen recorder ring buffer and codec strict-mode`).
|
|
- Task 3: ONE commit (`feat(01-03): add OffscreenLogger and clean up shared types`).
|
|
- Task 4: zero or one commit (`refactor(01-03): {what was cleaned}` only if changes were made).
|
|
|
|
Total: 2-3 commits.
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- `src/offscreen/recorder.ts` exists, exports all 6 symbols Plan 02 tests expect
|
|
- 6 ring-buffer + codec tests passing (Plan 02 → Plan 03 GREEN handoff complete for the ring-buffer + codec slice)
|
|
- `src/offscreen/index.html` exists at source path, references `./recorder.ts`
|
|
- `OffscreenLogger` class exists in `src/shared/logger.ts`
|
|
- `PortMessage` type exists in `src/shared/types.ts`; `VIDEO_CHUNK` / `VIDEO_CHUNK_SAVED` removed
|
|
- D-13 fallback skeleton present as comment block in recorder.ts (no re-plan needed if Plan 07 ffprobe fails)
|
|
- `npx tsc --noEmit` clean; no `as any`, no `@ts-ignore`
|
|
- The grep gates in `<verification>` all return their expected values
|
|
</success_criteria>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/01-stabilize-video-pipeline/01-03-SUMMARY.md` with:
|
|
- RED: log excerpt from Task 1 showing the module-resolution failure
|
|
- GREEN: list of 6 tests now passing, with the test names
|
|
- REFACTOR: what changed (if anything) in Task 4
|
|
- Final list of files modified (4 files) and line counts
|
|
- The exact export surface of recorder.ts (so Plan 04 / 05 can grep against it without re-reading)
|
|
- Note: "Plan 04 needs to: (a) replace the import-time stub `chrome.runtime.connect({name:'video-keepalive'})` with the full ping-loop + reconnect-on-disconnect from RESEARCH.md Pattern 5; (b) wire the `REQUEST_BUFFER` handler so the SW can pull chunks on export; (c) confirm the existing `OFFSCREEN_READY` send is still emitted exactly once. Plan 05 needs to update SW side accordingly."
|
|
- Commit list (2-3 commits)
|
|
</output>
|