diff --git a/src/offscreen/index.html b/src/offscreen/index.html new file mode 100644 index 0000000..cfd95a0 --- /dev/null +++ b/src/offscreen/index.html @@ -0,0 +1,10 @@ + + + + + Mokosh Recorder + + + + + diff --git a/src/offscreen/recorder.ts b/src/offscreen/recorder.ts new file mode 100644 index 0000000..8aad94d --- /dev/null +++ b/src/offscreen/recorder.ts @@ -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 : ''; + 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 { + 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); +// } +// } diff --git a/src/shared/logger.ts b/src/shared/logger.ts index e7da2c2..2d73483 100644 --- a/src/shared/logger.ts +++ b/src/shared/logger.ts @@ -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); + } } \ No newline at end of file