Milestone v1 (v2.0.0): Mokosh — Session Capture #1

Merged
strategy155 merged 297 commits from gsd/phase-04-harden-clean-up-optional into main 2026-05-31 15:34:17 +00:00
Showing only changes of commit 6a1a034030 - Show all commits

View File

@@ -1,65 +1,100 @@
// 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, long-lived port keepalive (D-17), and the
// OFFSCREEN_READY handshake (Pattern 4).
// src/offscreen/recorder.ts — Phase 01 source of truth.
// Owns: getDisplayMedia capture, MediaRecorder restart-segments lifecycle
// (D-13), in-memory segment ring buffer, codec strict-mode, track.onended
// cleanup, long-lived port keepalive (D-17), and the OFFSCREEN_READY
// handshake (Pattern 4).
//
// Architecture note (D-13 activation, debug session webm-playback-freeze):
// the recorder rotates segments — stop+restart the MediaRecorder every
// SEGMENT_DURATION_MS on the SAME MediaStream. Each segment is therefore
// a self-contained WebM with its own EBML header and seed keyframe,
// eliminating the orphan-keyframe gap that froze playback at ~1 s under
// the prior single-continuous-recorder + age-trim approach (D-09..D-11,
// retired). Trade-off: ~50200 ms recording gap at each rotation boundary
// (accepted per CONTEXT.md D-13; the operator-facing "last 30 s" promise
// is preserved as 3 × 10 s = 30 s.)
import { OffscreenLogger } from '../shared/logger';
import { blobToBase64 } from '../shared/binary';
import type { Message, TransferredVideoChunk, VideoChunk } from '../shared/types';
import type { Message, TransferredVideoChunk } from '../shared/types';
// ─── Константы (per CON-video-codec, CON-video-window, D-13) ────────────
// Длительность одного сегмента — 10 с (D-13, RESEARCH.md Pattern 3).
export const SEGMENT_DURATION_MS = 10_000;
// Сколько сегментов держим в памяти — 3 × 10 с = 30 с (CON-video-window).
export const MAX_SEGMENTS = 3;
// Окно «последних 30 секунд» в миллисекундах — выводимая константа.
// Сохранена под старым именем для обратной совместимости с тестами и
// для self-документации (см. segment-rotation.test.ts инвариант).
export const VIDEO_BUFFER_DURATION_MS = MAX_SEGMENTS * SEGMENT_DURATION_MS;
// ─── Константы (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
// На втором уровне MediaRecorder.start() мы НЕ передаём timeslice —
// дождёмся финального ondataavailable из onstop(). Если потребуется
// промежуточная диагностика, можно вернуть таймслайс позже, не меняя
// контракт ротации.
const PORT_NAME = 'video-keepalive'; // long-lived port name (D-17, Pattern 5)
const PORT_PING_MS = 25_000; // < 30 s SW idle threshold
const PORT_RECONNECT_MS = 290_000; // pre-empt the ~5 min port cap (Pitfall 4)
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
// ─── Состояние модуля ───────────────────────────────────────────────────
// videoRecorder — текущий MediaRecorder; пересоздаётся при ротации.
// mediaStream — общий захватываемый поток (НЕ пересоздаётся при ротации,
// иначе getDisplayMedia затребует новый user gesture, что недопустимо).
// segments — завершённые сегменты в порядке возраста (старшие первыми).
// currentChunks — буфер dataavailable-чанков для текущего сегмента;
// сливается в один Blob по onstop и пушится в `segments`.
// rotationTimerId — setTimeout-хэндл следующей ротации; должен быть
// очищен при STOP_RECORDING / onUserStoppedSharing.
let videoRecorder: MediaRecorder | null = null;
let mediaStream: MediaStream | null = null;
let videoBuffer: VideoChunk[] = [];
let firstChunkSaved = false;
let segments: Blob[] = [];
let currentChunks: Blob[] = [];
let rotationTimerId: ReturnType<typeof setTimeout> | null = null;
let keepalivePort: chrome.runtime.Port | null = null; // long-lived SW keepalive (D-17, Pattern 5)
// ─── Кольцевой буфер (pure functions — testable in Node) ───────────────
// ─── Сегментный буфер (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);
/**
* Возвращает копию массива завершённых сегментов в порядке от старого
* к новому. Копия — чтобы вызывающий код (например, SW-side encode)
* не мог случайно повредить внутреннее состояние.
*/
export function getSegments(): Blob[] {
return [...segments];
}
/**
* Тестовый seam (D-13): позволяет vitest-у драйвить ротацию
* детерминистически, не инстанцируя реальный MediaRecorder (jsdom не
* поставляет capture pipeline). Производственный путь — onstop → blob
* → push — приходит через MediaRecorder.onstop в `onSegmentStopped`.
*
* Не использовать из продакшен-кода. Имя содержит `ForTest`, чтобы
* grep по prod-коду не нашёл случайных вызовов.
*/
export function pushSegmentForTest(segment: Blob): void {
segments.push(segment);
if (segments.length > MAX_SEGMENTS) {
segments.shift();
}
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;
}
/**
* Полная очистка буферов: завершённые сегменты, in-flight чанки и
* запланированная ротация. Вызывается из `onUserStoppedSharing` и
* между тестами.
*/
export function resetBuffer(): void {
videoBuffer = [];
firstChunkSaved = false;
segments = [];
currentChunks = [];
if (rotationTimerId !== null) {
clearTimeout(rotationTimerId);
rotationTimerId = null;
}
}
// ─── Проверка кодека (D-20 strict-mode — no fallback chain) ─────────────
@@ -91,19 +126,23 @@ async function startRecording(): Promise<void> {
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.
// (Pitfall 6) так, чтобы edge-case-аудио-трек не оставил нас в
// несинхронизованном состоянии. Регистрация — один раз на поток;
// при ротации MediaRecorder поток тот же, перерегистрировать не нужно.
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');
// Чистим возможные остатки прежней сессии (на случай повторного
// START_RECORDING после ручного STOP_RECORDING без перезагрузки
// offscreen-документа).
resetBuffer();
startNewSegment();
logger.log(
'Recording started (D-13 restart-segments), mime:', VIDEO_MIME,
'segment_ms:', SEGMENT_DURATION_MS,
'max_segments:', MAX_SEGMENTS,
);
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
logger.error('startRecording failed:', msg);
@@ -112,18 +151,119 @@ async function startRecording(): Promise<void> {
}
}
/**
* Запуск нового сегмента: создаём свежий MediaRecorder на том же
* MediaStream, навешиваем обработчики, стартуем без timeslice (один
* dataavailable на остановку → один blob на сегмент → один keyframe в
* заголовке за счёт fresh-encoder-инициализации).
*/
function startNewSegment(): void {
if (mediaStream === null) {
logger.warn('startNewSegment called without an active mediaStream — abort');
return;
}
currentChunks = [];
videoRecorder = new MediaRecorder(mediaStream, {
mimeType: VIDEO_MIME,
videoBitsPerSecond: VIDEO_BITRATE,
});
videoRecorder.ondataavailable = onDataAvailable;
videoRecorder.onstop = onSegmentStopped;
videoRecorder.onerror = (event) => logger.error('MediaRecorder error:', event);
// Без timeslice: одно событие dataavailable придёт по .stop() — это
// ровно один blob, содержащий целиком сегмент (EBML-заголовок +
// кластеры). Так каждый сегмент гарантированно декодируется
// независимо. Если когда-то потребуется живая стат-телеметрия,
// можно дать timeslice назад без изменения семантики ротации.
videoRecorder.start();
scheduleRotation();
}
/**
* Планируем следующую ротацию через SEGMENT_DURATION_MS. Хэндл
* сохраняем в `rotationTimerId`, чтобы `resetBuffer` / остановка
* могли её отменить — иначе на остановленном MediaRecorder будет
* вызван .stop(), что бросит исключение.
*/
function scheduleRotation(): void {
if (rotationTimerId !== null) {
clearTimeout(rotationTimerId);
}
rotationTimerId = setTimeout(rotateSegment, SEGMENT_DURATION_MS);
}
/**
* Вызывает stop() на текущем рекордере. Финальный ondataavailable
* и onstop сработают синхронно сразу после; именно в onSegmentStopped
* сегмент будет упакован и запушен в массив сегментов.
*/
function rotateSegment(): void {
if (videoRecorder === null) {
return;
}
if (videoRecorder.state !== 'recording') {
// Кто-то остановил рекордер вручную раньше таймера — выходим
// молча; стартовать новый сегмент тут не нужно (он будет запущен
// следующим START_RECORDING, если он придёт).
return;
}
try {
videoRecorder.stop(); // onstop → onSegmentStopped → новый сегмент
} catch (err) {
logger.error('rotateSegment: stop() failed:', err);
}
}
/**
* Финализация одного сегмента: склеиваем накопленные dataavailable-чанки
* в один Blob, кладём в `segments`, обрезаем до MAX_SEGMENTS, стартуем
* следующий сегмент на том же mediaStream.
*
* Edge case: если по какой-то причине currentChunks пустой (например,
* MediaRecorder.stop() сработал прежде, чем энкодер успел эмитнуть хоть
* один пакет — теоретически возможно сразу после start() без timeslice
* на пустом потоке), сегмент не пушим — не корраптим буфер пустым blob-ом.
*/
function onSegmentStopped(): void {
if (currentChunks.length > 0) {
const segmentBlob = new Blob(currentChunks, { type: 'video/webm' });
segments.push(segmentBlob);
if (segments.length > MAX_SEGMENTS) {
segments.shift();
}
logger.log(
'Segment finalized, size:', segmentBlob.size,
'kept:', segments.length, '/', MAX_SEGMENTS,
);
} else {
logger.warn('Segment finalized with zero chunks — skipping push');
}
currentChunks = [];
// Стартуем следующий сегмент только если поток ещё активен. Если
// пользователь нажал «прекратить шеринг» между stop() и сюда —
// onUserStoppedSharing уже сбросил mediaStream в null.
if (mediaStream !== null) {
startNewSegment();
}
}
function onDataAvailable(event: BlobEvent): void {
if (!event.data || event.data.size === 0) {
return;
}
addChunk(event.data, Date.now());
// Накапливаем в per-segment буфер; финализация — в onSegmentStopped.
currentChunks.push(event.data);
}
function onUserStoppedSharing(): void {
logger.log('Operator stopped sharing — clearing buffer');
resetBuffer();
if (videoRecorder !== null && videoRecorder.state !== 'inactive') {
videoRecorder.stop();
try {
videoRecorder.stop();
} catch (err) {
logger.warn('stop() on user-stopped-sharing failed:', err);
}
}
if (mediaStream !== null) {
mediaStream.getTracks().forEach((t) => t.stop());
@@ -134,6 +274,12 @@ function onUserStoppedSharing(): void {
}
function stopRecording(): void {
// Отменяем будущую ротацию, чтобы rotateSegment не дёрнул stop() на
// уже остановленном рекордере.
if (rotationTimerId !== null) {
clearTimeout(rotationTimerId);
rotationTimerId = null;
}
if (videoRecorder !== null && videoRecorder.state !== 'inactive') {
videoRecorder.stop();
logger.log('Recording stopped manually');
@@ -177,7 +323,7 @@ function onPortMessage(message: unknown): void {
// ignores the return value. We do the async base64 encoding inside
// an IIFE so the listener stays synchronous-typed (void return).
//
// D-12 fix: each chunk's Blob is encoded to base64 BEFORE postMessage
// D-12 fix: each segment's Blob is encoded to base64 BEFORE postMessage
// because chrome.runtime.Port JSON-serializes across extension contexts,
// and JSON.stringify(blob) === "{}". See src/shared/binary.ts and
// tests/offscreen/port-serialization.test.ts for the contract.
@@ -187,26 +333,43 @@ function onPortMessage(message: unknown): void {
}
async function encodeAndSendBuffer(): Promise<void> {
const chunks = getBuffer();
// Per-chunk defensive encode: if a single Blob fails to encode (e.g.
// an unexpected ArrayBuffer detach mid-flight), log the error and skip
// it rather than dropping the entire BUFFER response. Operators get
// partial video > no video.
// Снимок завершённых сегментов + опциональный «свежий» текущий
// сегмент, если уже накопились dataavailable-чанки. Это нужно, чтобы
// SAVE_ARCHIVE через 3 секунды после старта первой сессии не вернул
// пустой буфер — операторская UX страдает иначе. Если currentChunks
// пуст, in-flight сегмент не добавляем.
const finalized = getSegments();
const inFlight =
currentChunks.length > 0
? new Blob(currentChunks, { type: 'video/webm' })
: null;
const allSegments: Blob[] =
inFlight !== null ? [...finalized, inFlight] : finalized;
// Метка времени для каждого сегмента — момент текущего экспорта; даёт
// SW-side `mergeVideoSegments` стабильный порядок сортировки и не зависит
// от часов внутри MediaRecorder. Старший сегмент — самый старый.
const baseTimestamp = Date.now();
// Per-segment defensive encode: если одиночный Blob не зенкодится
// (например, неожиданный detach ArrayBuffer-а на лету), логируем и
// пропускаем — частичное видео > отсутствующее видео.
const encodeResults = await Promise.all(
chunks.map(async (chunk): Promise<TransferredVideoChunk | null> => {
allSegments.map(async (segment, idx): Promise<TransferredVideoChunk | null> => {
try {
const data = await blobToBase64(chunk.data);
const data = await blobToBase64(segment);
return {
data,
type: chunk.data.type,
timestamp: chunk.timestamp,
isFirst: chunk.isFirst,
type: segment.type || 'video/webm',
// Порядковый offset — старшие сегменты получают меньший timestamp.
// Шаг произвольный, лишь бы сохранил порядок при сортировке в SW.
timestamp: baseTimestamp + idx,
};
} catch (err) {
logger.error(
'blobToBase64 failed; skipping chunk',
'timestamp:', chunk.timestamp,
'isFirst:', chunk.isFirst,
'blobToBase64 failed; skipping segment',
'index:', idx,
'size:', segment.size,
'error:', err,
);
return null;
@@ -252,11 +415,12 @@ function connectPort(): void {
}
// Бутстрап (handshake + port lifecycle).
// Guarded so the pure ring-buffer + codec-check tests can import the module
// without providing a full chrome stub. Production always has chrome.runtime
// fully populated, so the guard is a no-op there. Order matters per Pattern 4:
// onMessage listener registration MUST happen BEFORE OFFSCREEN_READY is sent,
// so the SW can safely send START_RECORDING the moment it sees the ready signal.
// Guarded so the pure segment-rotation + codec-check tests can import the
// module without providing a full chrome stub. Production always has
// chrome.runtime fully populated, so the guard is a no-op there. Order
// matters per Pattern 4: onMessage listener registration MUST happen BEFORE
// OFFSCREEN_READY is sent, so the SW can safely send START_RECORDING the
// moment it sees the ready signal.
function bootstrap(): void {
if (typeof chrome === 'undefined' || !chrome.runtime) {
return;
@@ -290,26 +454,3 @@ function bootstrap(): void {
}
bootstrap();
// ─── 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);
// }
// }