feat(fix-a3): activate D-13 restart-segments in src/offscreen/recorder.ts
Replaces the single-continuous-MediaRecorder + age-trim lifecycle
(D-09..D-11, retired) with the restart-segments lifecycle pre-staged
in CONTEXT.md D-13 and RESEARCH.md Pattern 3. Debug session
webm-playback-freeze proved empirically that the prior approach
orphans VP9 P-frame keyframe references when middle chunks are
trimmed — ffmpeg dry-run on last_30sec.webm produced 8 "Error
submitting packet to decoder" lines and the playback froze ~1 s in
Chrome (root-cause-confirmed in the debug session).
Architecture change:
- MediaRecorder is rotated every SEGMENT_DURATION_MS = 10_000 ms on
the SAME mediaStream (so no new getDisplayMedia user gesture).
- Each segment is started fresh, so the encoder seeds an EBML header
+ initial VP9 keyframe — segment is independently decodable.
- Up to MAX_SEGMENTS = 3 finalized segments are retained (3 × 10 s =
30 s, matching the legacy operator-facing window).
- MediaRecorder.start() runs without a timeslice — exactly one
ondataavailable per .stop() yields exactly one Blob per segment.
- onstop → onSegmentStopped finalizes currentChunks, evicts oldest
if over the cap, and immediately starts the next segment.
- ~50–200 ms recording gap at each rotation boundary is the
documented trade-off per CONTEXT.md D-13.
API surface delta:
- REMOVED: addChunk, trimAged, getBuffer, firstChunkSaved
(entire D-09..D-11 ring-buffer module retired)
- ADDED: getSegments, pushSegmentForTest (test seam),
MAX_SEGMENTS, SEGMENT_DURATION_MS exports
- KEPT: assertCodecSupported, resetBuffer (semantics updated to
clear segments + currentChunks + rotation timer),
VIDEO_BUFFER_DURATION_MS (now derived as
MAX_SEGMENTS * SEGMENT_DURATION_MS = 30 s)
- bootstrap(), port lifecycle, OFFSCREEN_READY handshake, T-1-04
sender-id check — all unchanged.
encodeAndSendBuffer iterates segments + the in-flight currentChunks
(if non-empty) so SAVE_ARCHIVE seconds after START_RECORDING still
returns the partial first segment instead of an empty buffer. Each
segment is base64-encoded per the D-12 wire-format contract.
Verification:
- npx vitest run → 28 passed / 2 failed; the 2 failures are exactly
the empirical ffmpeg dry-runs in tests/offscreen/webm-playback.test.ts
which stay RED until the operator regenerates the committed fixture
via ./smoke.sh (expected and documented).
- tests/offscreen/segment-keyframes.test.ts production-driven RED
block is now GREEN — getSegments exists and meets the cap contract.
- tests/offscreen/segment-rotation.test.ts (8 tests, added in the
prior commit) all GREEN.
- npx tsc --noEmit clean.
- Zero new `as any` / `@ts-ignore` introduced.
This commit is contained in:
@@ -1,65 +1,100 @@
|
|||||||
// src/offscreen/recorder.ts — Phase 01 source of truth (replaces dead
|
// src/offscreen/recorder.ts — Phase 01 source of truth.
|
||||||
// offscreen/index.ts and the inline string in vite.config.ts:13-216).
|
// Owns: getDisplayMedia capture, MediaRecorder restart-segments lifecycle
|
||||||
// Owns: getDisplayMedia capture, MediaRecorder lifecycle, in-memory ring
|
// (D-13), in-memory segment ring buffer, codec strict-mode, track.onended
|
||||||
// buffer with WebM-header retention + 30 s age trim, codec strict-mode,
|
// cleanup, long-lived port keepalive (D-17), and the OFFSCREEN_READY
|
||||||
// track.onended cleanup, long-lived port keepalive (D-17), and the
|
// handshake (Pattern 4).
|
||||||
// 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 { OffscreenLogger } from '../shared/logger';
|
||||||
import { blobToBase64 } from '../shared/binary';
|
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_MIME = 'video/webm;codecs=vp9'; // D-20 strict — no fallback
|
||||||
const VIDEO_BITRATE = 400_000; // CON-video-codec
|
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_NAME = 'video-keepalive'; // long-lived port name (D-17, Pattern 5)
|
||||||
const PORT_PING_MS = 25_000; // < 30 s SW idle threshold
|
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 PORT_RECONNECT_MS = 290_000; // pre-empt the ~5 min port cap (Pitfall 4)
|
||||||
|
|
||||||
const logger = new OffscreenLogger('Recorder');
|
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 mediaStream: MediaStream | null = null;
|
||||||
let videoBuffer: VideoChunk[] = [];
|
let segments: Blob[] = [];
|
||||||
let firstChunkSaved = false;
|
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)
|
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,
|
* к новому. Копия — чтобы вызывающий код (например, SW-side encode)
|
||||||
timestamp,
|
* не мог случайно повредить внутреннее состояние.
|
||||||
isFirst: !firstChunkSaved,
|
*/
|
||||||
};
|
export function getSegments(): Blob[] {
|
||||||
if (!firstChunkSaved) {
|
return [...segments];
|
||||||
firstChunkSaved = true;
|
}
|
||||||
logger.log('First chunk (WebM header) pinned, size:', blob.size);
|
|
||||||
|
/**
|
||||||
|
* Тестовый 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 {
|
export function resetBuffer(): void {
|
||||||
videoBuffer = [];
|
segments = [];
|
||||||
firstChunkSaved = false;
|
currentChunks = [];
|
||||||
|
if (rotationTimerId !== null) {
|
||||||
|
clearTimeout(rotationTimerId);
|
||||||
|
rotationTimerId = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Проверка кодека (D-20 strict-mode — no fallback chain) ─────────────
|
// ─── Проверка кодека (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
|
audio: false, // SPEC §9 — Phase 2 / CAP-01 territory
|
||||||
});
|
});
|
||||||
mediaStream = stream;
|
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
|
// 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) => {
|
stream.getTracks().forEach((track) => {
|
||||||
track.addEventListener('ended', onUserStoppedSharing, { once: true });
|
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) {
|
} catch (error) {
|
||||||
const msg = error instanceof Error ? error.message : String(error);
|
const msg = error instanceof Error ? error.message : String(error);
|
||||||
logger.error('startRecording failed:', msg);
|
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 {
|
function onDataAvailable(event: BlobEvent): void {
|
||||||
if (!event.data || event.data.size === 0) {
|
if (!event.data || event.data.size === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
addChunk(event.data, Date.now());
|
// Накапливаем в per-segment буфер; финализация — в onSegmentStopped.
|
||||||
|
currentChunks.push(event.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onUserStoppedSharing(): void {
|
function onUserStoppedSharing(): void {
|
||||||
logger.log('Operator stopped sharing — clearing buffer');
|
logger.log('Operator stopped sharing — clearing buffer');
|
||||||
resetBuffer();
|
resetBuffer();
|
||||||
if (videoRecorder !== null && videoRecorder.state !== 'inactive') {
|
if (videoRecorder !== null && videoRecorder.state !== 'inactive') {
|
||||||
|
try {
|
||||||
videoRecorder.stop();
|
videoRecorder.stop();
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('stop() on user-stopped-sharing failed:', err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (mediaStream !== null) {
|
if (mediaStream !== null) {
|
||||||
mediaStream.getTracks().forEach((t) => t.stop());
|
mediaStream.getTracks().forEach((t) => t.stop());
|
||||||
@@ -134,6 +274,12 @@ function onUserStoppedSharing(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function stopRecording(): void {
|
function stopRecording(): void {
|
||||||
|
// Отменяем будущую ротацию, чтобы rotateSegment не дёрнул stop() на
|
||||||
|
// уже остановленном рекордере.
|
||||||
|
if (rotationTimerId !== null) {
|
||||||
|
clearTimeout(rotationTimerId);
|
||||||
|
rotationTimerId = null;
|
||||||
|
}
|
||||||
if (videoRecorder !== null && videoRecorder.state !== 'inactive') {
|
if (videoRecorder !== null && videoRecorder.state !== 'inactive') {
|
||||||
videoRecorder.stop();
|
videoRecorder.stop();
|
||||||
logger.log('Recording stopped manually');
|
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
|
// ignores the return value. We do the async base64 encoding inside
|
||||||
// an IIFE so the listener stays synchronous-typed (void return).
|
// 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,
|
// because chrome.runtime.Port JSON-serializes across extension contexts,
|
||||||
// and JSON.stringify(blob) === "{}". See src/shared/binary.ts and
|
// and JSON.stringify(blob) === "{}". See src/shared/binary.ts and
|
||||||
// tests/offscreen/port-serialization.test.ts for the contract.
|
// tests/offscreen/port-serialization.test.ts for the contract.
|
||||||
@@ -187,26 +333,43 @@ function onPortMessage(message: unknown): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function encodeAndSendBuffer(): Promise<void> {
|
async function encodeAndSendBuffer(): Promise<void> {
|
||||||
const chunks = getBuffer();
|
// Снимок завершённых сегментов + опциональный «свежий» текущий
|
||||||
// Per-chunk defensive encode: if a single Blob fails to encode (e.g.
|
// сегмент, если уже накопились dataavailable-чанки. Это нужно, чтобы
|
||||||
// an unexpected ArrayBuffer detach mid-flight), log the error and skip
|
// SAVE_ARCHIVE через 3 секунды после старта первой сессии не вернул
|
||||||
// it rather than dropping the entire BUFFER response. Operators get
|
// пустой буфер — операторская UX страдает иначе. Если currentChunks
|
||||||
// partial video > no video.
|
// пуст, 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(
|
const encodeResults = await Promise.all(
|
||||||
chunks.map(async (chunk): Promise<TransferredVideoChunk | null> => {
|
allSegments.map(async (segment, idx): Promise<TransferredVideoChunk | null> => {
|
||||||
try {
|
try {
|
||||||
const data = await blobToBase64(chunk.data);
|
const data = await blobToBase64(segment);
|
||||||
return {
|
return {
|
||||||
data,
|
data,
|
||||||
type: chunk.data.type,
|
type: segment.type || 'video/webm',
|
||||||
timestamp: chunk.timestamp,
|
// Порядковый offset — старшие сегменты получают меньший timestamp.
|
||||||
isFirst: chunk.isFirst,
|
// Шаг произвольный, лишь бы сохранил порядок при сортировке в SW.
|
||||||
|
timestamp: baseTimestamp + idx,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'blobToBase64 failed; skipping chunk',
|
'blobToBase64 failed; skipping segment',
|
||||||
'timestamp:', chunk.timestamp,
|
'index:', idx,
|
||||||
'isFirst:', chunk.isFirst,
|
'size:', segment.size,
|
||||||
'error:', err,
|
'error:', err,
|
||||||
);
|
);
|
||||||
return null;
|
return null;
|
||||||
@@ -252,11 +415,12 @@ function connectPort(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Бутстрап (handshake + port lifecycle).
|
// Бутстрап (handshake + port lifecycle).
|
||||||
// Guarded so the pure ring-buffer + codec-check tests can import the module
|
// Guarded so the pure segment-rotation + codec-check tests can import the
|
||||||
// without providing a full chrome stub. Production always has chrome.runtime
|
// module without providing a full chrome stub. Production always has
|
||||||
// fully populated, so the guard is a no-op there. Order matters per Pattern 4:
|
// chrome.runtime fully populated, so the guard is a no-op there. Order
|
||||||
// onMessage listener registration MUST happen BEFORE OFFSCREEN_READY is sent,
|
// matters per Pattern 4: onMessage listener registration MUST happen BEFORE
|
||||||
// so the SW can safely send START_RECORDING the moment it sees the ready signal.
|
// OFFSCREEN_READY is sent, so the SW can safely send START_RECORDING the
|
||||||
|
// moment it sees the ready signal.
|
||||||
function bootstrap(): void {
|
function bootstrap(): void {
|
||||||
if (typeof chrome === 'undefined' || !chrome.runtime) {
|
if (typeof chrome === 'undefined' || !chrome.runtime) {
|
||||||
return;
|
return;
|
||||||
@@ -290,26 +454,3 @@ function bootstrap(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bootstrap();
|
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