diff --git a/src/offscreen/recorder.ts b/src/offscreen/recorder.ts index 3f524a1..8db1b8f 100644 --- a/src/offscreen/recorder.ts +++ b/src/offscreen/recorder.ts @@ -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 | 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 { 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 { } } +/** + * Запуск нового сегмента: создаём свежий 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 { - 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 => { + allSegments.map(async (segment, idx): Promise => { 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); -// } -// }