feat(01-03): implement offscreen recorder ring buffer and codec strict-mode

- Add src/offscreen/recorder.ts (214 lines) — Phase 01 source of truth
  owning getDisplayMedia capture, MediaRecorder lifecycle, in-memory ring
  buffer with WebM header retention + 30 s age trim, codec strict-mode
  (D-20), and track.onended cleanup.
- Add src/offscreen/index.html — crxjs-managed bundle entry referencing
  ./recorder.ts.
- Add OffscreenLogger class to src/shared/logger.ts (uses ...args:
  unknown[] for strict-mode hygiene; legacy Logger / ContentLogger keep
  ...args: any[] per project provenance). Bundled into this commit
  because recorder.ts cannot typecheck without the import (Rule 3 —
  blocking dependency).
- Pre-stage D-13 restart-segments fallback as commented skeleton at
  bottom of recorder.ts so Plan 07's fallback path needs no re-plan.
- Defensive bootstrap (typeof chrome guard) so the pure ring-buffer +
  codec tests can import the module without stubbing the full chrome
  surface (Rule 3 — Plan 02 ring-buffer test does not stub chrome).

Flips Plan 02's RED tests to GREEN:
- tests/offscreen/ring-buffer.test.ts — 4 tests passing
- tests/offscreen/codec-check.test.ts — 2 tests passing

Handshake test also passes (single OFFSCREEN_READY emission); port
reconnect test stays RED until Plan 04 wires the reconnect loop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-15 17:34:00 +02:00
parent edc605d475
commit fff1aea592
3 changed files with 252 additions and 0 deletions

10
src/offscreen/index.html Normal file
View File

@@ -0,0 +1,10 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8">
<title>Mokosh Recorder</title>
</head>
<body>
<script type="module" src="./recorder.ts"></script>
</body>
</html>

214
src/offscreen/recorder.ts Normal file
View File

@@ -0,0 +1,214 @@
// 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;
}
// Бутстрап (Plan 03 stub; Plan 04 wires the full handshake + reconnect).
// Guarded so the pure ring-buffer + codec-check tests can import the module
// without providing a chrome stub. Production runs always have chrome.runtime
// available, so the guard is a no-op there.
function bootstrap(): void {
if (typeof chrome === 'undefined' || !chrome.runtime) {
return;
}
if (typeof chrome.runtime.onMessage?.addListener === 'function') {
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.
if (typeof chrome.runtime.connect === 'function') {
keepalivePort = chrome.runtime.connect({ name: PORT_NAME });
}
if (typeof chrome.runtime.sendMessage === 'function') {
chrome.runtime.sendMessage({ type: 'OFFSCREEN_READY' });
}
}
bootstrap();
// 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.
//
// FALLBACK D-13: restart-segments
// 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);
// }
// }

View File

@@ -48,4 +48,32 @@ export class ContentLogger {
error(...args: any[]) {
this.logWithLevel('error', ...args);
}
}
// Логгер для Offscreen Document
// Note: uses `...args: unknown[]` (strict-mode hygiene) vs Logger / ContentLogger
// which retain the legacy `...args: any[]` — see plan 01-03 style_divergence_note.
export class OffscreenLogger {
private context: string;
constructor(context: string) {
this.context = context;
}
private logWithLevel(level: 'log' | 'warn' | 'error', ...args: unknown[]) {
const timestamp = new Date().toISOString();
console[level](`[OS:${this.context}] ${timestamp}`, ...args);
}
log(...args: unknown[]) {
this.logWithLevel('log', ...args);
}
warn(...args: unknown[]) {
this.logWithLevel('warn', ...args);
}
error(...args: unknown[]) {
this.logWithLevel('error', ...args);
}
}