Milestone v1 (v2.0.0): Mokosh — Session Capture #1
10
src/offscreen/index.html
Normal file
10
src/offscreen/index.html
Normal 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
214
src/offscreen/recorder.ts
Normal 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);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
@@ -49,3 +49,31 @@ export class ContentLogger {
|
|||||||
this.logWithLevel('error', ...args);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user