Milestone v1 (v2.0.0): Mokosh — Session Capture #1
@@ -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: ~50–200 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);
|
||||
// }
|
||||
// }
|
||||
|
||||
Reference in New Issue
Block a user