Milestone v1 (v2.0.0): Mokosh — Session Capture #1
@@ -2,8 +2,8 @@ import { Logger } from '../shared/logger';
|
||||
import { base64ToBlob } from '../shared/binary';
|
||||
import type {
|
||||
Message,
|
||||
TransferredVideoChunk,
|
||||
VideoChunk,
|
||||
TransferredVideoSegment,
|
||||
VideoSegment,
|
||||
SessionMetadata,
|
||||
VideoBufferResponse
|
||||
} from '../shared/types';
|
||||
@@ -105,14 +105,14 @@ const BUFFER_FETCH_TIMEOUT_MS = 2_000;
|
||||
async function getVideoBufferFromOffscreen(): Promise<VideoBufferResponse> {
|
||||
if (videoPort === null) {
|
||||
logger.warn('No offscreen port available; returning empty buffer');
|
||||
return { chunks: [] };
|
||||
return { segments: [] };
|
||||
}
|
||||
const port = videoPort;
|
||||
return new Promise<VideoBufferResponse>((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
port.onMessage.removeListener(handler);
|
||||
logger.warn(`Buffer fetch timed out after ${BUFFER_FETCH_TIMEOUT_MS} ms`);
|
||||
resolve({ chunks: [] });
|
||||
resolve({ segments: [] });
|
||||
}, BUFFER_FETCH_TIMEOUT_MS);
|
||||
const handler = (msg: unknown) => {
|
||||
if (
|
||||
@@ -122,20 +122,19 @@ async function getVideoBufferFromOffscreen(): Promise<VideoBufferResponse> {
|
||||
) {
|
||||
clearTimeout(timer);
|
||||
port.onMessage.removeListener(handler);
|
||||
// D-12 wire format: payload arrives as TransferredVideoChunk[]
|
||||
// — base64 string + MIME — because chrome.runtime.Port
|
||||
// JSON-serializes across extension contexts. Decode each entry
|
||||
// back into a VideoChunk. D-13 lifecycle: each entry is now a
|
||||
// self-contained WebM segment (~10 s, EBML header + seed
|
||||
// keyframe), not a partial chunk; concatenating them
|
||||
// sequentially produces a multi-EBML-header file Chrome plays
|
||||
// natively. See src/shared/binary.ts + RESEARCH.md Pattern 3.
|
||||
// D-12 wire format + D-13 segment lifecycle: payload arrives
|
||||
// as TransferredVideoSegment[] (base64 string + MIME). Decode
|
||||
// each entry back into a VideoSegment — each is a
|
||||
// self-contained ~10 s WebM (EBML header + seed keyframe).
|
||||
// Concatenating them sequentially produces a multi-EBML-header
|
||||
// file Chrome plays natively. See src/shared/binary.ts +
|
||||
// RESEARCH.md Pattern 3.
|
||||
const wireSegments =
|
||||
(msg as { chunks?: TransferredVideoChunk[] }).chunks ?? [];
|
||||
const chunks: VideoChunk[] = [];
|
||||
(msg as { segments?: TransferredVideoSegment[] }).segments ?? [];
|
||||
const segments: VideoSegment[] = [];
|
||||
for (const wire of wireSegments) {
|
||||
try {
|
||||
chunks.push({
|
||||
segments.push({
|
||||
data: base64ToBlob(wire.data, wire.type || VIDEO_MIME_FALLBACK),
|
||||
timestamp: wire.timestamp,
|
||||
});
|
||||
@@ -147,7 +146,7 @@ async function getVideoBufferFromOffscreen(): Promise<VideoBufferResponse> {
|
||||
);
|
||||
}
|
||||
}
|
||||
resolve({ chunks });
|
||||
resolve({ segments });
|
||||
}
|
||||
};
|
||||
port.onMessage.addListener(handler);
|
||||
@@ -238,7 +237,7 @@ async function captureScreenshot(): Promise<Blob> {
|
||||
|
||||
// Склейка сегментов в один WebM-файл.
|
||||
//
|
||||
// Под D-13 каждый VideoChunk — это самодостаточный ~10-секундный WebM
|
||||
// Под D-13 каждый VideoSegment — это самодостаточный ~10-секундный WebM
|
||||
// (собственный EBML-заголовок + seed keyframe). Сортируем по timestamp
|
||||
// и склеиваем подряд: получаем multi-EBML-header файл, который Chrome
|
||||
// проигрывает последовательно (см. SPEC §10 #7 — требуется проигрывание
|
||||
@@ -246,7 +245,7 @@ async function captureScreenshot(): Promise<Blob> {
|
||||
//
|
||||
// Никакой логики «pin первого чанка» или header-retention больше нет
|
||||
// — это снято вместе с D-09..D-11 и активацией D-13 в recorder.ts.
|
||||
function mergeVideoSegments(segments: VideoChunk[]): Blob {
|
||||
function mergeVideoSegments(segments: VideoSegment[]): Blob {
|
||||
logger.log(`Merging ${segments.length} segments`);
|
||||
|
||||
// Сортируем по времени, чтобы сохранить правильный порядок (старшие
|
||||
@@ -286,9 +285,9 @@ async function createArchive(
|
||||
|
||||
const zip = new JSZip();
|
||||
|
||||
// Добавляем видео (D-13: чанки в ответе — это уже сегменты)
|
||||
if (videoBufferResponse.chunks.length > 0) {
|
||||
const videoBlob = mergeVideoSegments(videoBufferResponse.chunks);
|
||||
// Добавляем видео (D-13: каждая запись — самодостаточный WebM-сегмент)
|
||||
if (videoBufferResponse.segments.length > 0) {
|
||||
const videoBlob = mergeVideoSegments(videoBufferResponse.segments);
|
||||
zip.file('video/last_30sec.webm', videoBlob);
|
||||
logger.log(`✓ Added video: ${videoBlob.size} bytes`);
|
||||
} else {
|
||||
@@ -386,7 +385,7 @@ async function saveArchive() {
|
||||
|
||||
// Получаем видео буфер из offscreen через long-lived port (D-17)
|
||||
const videoBufferResp = await getVideoBufferFromOffscreen();
|
||||
logger.log(`Video buffer: ${videoBufferResp.chunks.length} chunks`);
|
||||
logger.log(`Video buffer: ${videoBufferResp.segments.length} segments`);
|
||||
|
||||
// Получаем rrweb события от content script
|
||||
logger.log(`Requesting rrweb events from content script on tab ${tab.id} (${tab.url})...`);
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import { OffscreenLogger } from '../shared/logger';
|
||||
import { blobToBase64 } from '../shared/binary';
|
||||
import type { Message, TransferredVideoChunk } from '../shared/types';
|
||||
import type { Message, TransferredVideoSegment } from '../shared/types';
|
||||
|
||||
// ─── Константы (per CON-video-codec, CON-video-window, D-13) ────────────
|
||||
// Длительность одного сегмента — 10 с (D-13, RESEARCH.md Pattern 3).
|
||||
@@ -355,7 +355,7 @@ async function encodeAndSendBuffer(): Promise<void> {
|
||||
// (например, неожиданный detach ArrayBuffer-а на лету), логируем и
|
||||
// пропускаем — частичное видео > отсутствующее видео.
|
||||
const encodeResults = await Promise.all(
|
||||
allSegments.map(async (segment, idx): Promise<TransferredVideoChunk | null> => {
|
||||
allSegments.map(async (segment, idx): Promise<TransferredVideoSegment | null> => {
|
||||
try {
|
||||
const data = await blobToBase64(segment);
|
||||
return {
|
||||
@@ -376,15 +376,15 @@ async function encodeAndSendBuffer(): Promise<void> {
|
||||
}
|
||||
}),
|
||||
);
|
||||
const transferred: TransferredVideoChunk[] = encodeResults.filter(
|
||||
(c): c is TransferredVideoChunk => c !== null,
|
||||
const transferred: TransferredVideoSegment[] = encodeResults.filter(
|
||||
(c): c is TransferredVideoSegment => c !== null,
|
||||
);
|
||||
// Re-check port AFTER the await: it may have disconnected during encoding.
|
||||
if (keepalivePort === null) {
|
||||
logger.warn('port disconnected during base64 encoding; dropping BUFFER response');
|
||||
return;
|
||||
}
|
||||
keepalivePort.postMessage({ type: 'BUFFER', chunks: transferred });
|
||||
keepalivePort.postMessage({ type: 'BUFFER', segments: transferred });
|
||||
}
|
||||
|
||||
function connectPort(): void {
|
||||
|
||||
@@ -21,7 +21,7 @@ export interface Message<T = any> {
|
||||
tabId?: number;
|
||||
}
|
||||
|
||||
// Типы сообщений в long-lived port (offscreen ↔ SW; D-17 / Plan 04)
|
||||
// Типы сообщений в long-lived port (offscreen ↔ SW; D-17 / Plan 04 / D-13)
|
||||
export type PortMessageType =
|
||||
| 'PING'
|
||||
| 'REQUEST_BUFFER'
|
||||
@@ -29,31 +29,35 @@ export type PortMessageType =
|
||||
|
||||
export interface PortMessage {
|
||||
type: PortMessageType;
|
||||
// Wire-format (post-D-12 fix): chunks travel as TransferredVideoChunk[]
|
||||
// because chrome.runtime.Port JSON-serializes payloads, and
|
||||
// JSON.stringify(blob) === "{}" loses binary content. The receive
|
||||
// side reconstructs VideoChunk[] via src/shared/binary.ts.
|
||||
chunks?: TransferredVideoChunk[];
|
||||
// Wire-format (D-12 base64 transfer + D-13 segment lifecycle):
|
||||
// segments travel as TransferredVideoSegment[] because
|
||||
// chrome.runtime.Port JSON-serializes payloads across extension
|
||||
// contexts and JSON.stringify(blob) === "{}" loses binary content.
|
||||
// Each entry is one self-contained ~10 s WebM segment (EBML header
|
||||
// + seed keyframe). The receive side reconstructs VideoSegment[]
|
||||
// via src/shared/binary.ts.
|
||||
segments?: TransferredVideoSegment[];
|
||||
}
|
||||
|
||||
// In-memory chunk shape used by the offscreen ring buffer and by
|
||||
// mergeVideoChunks after the SW decodes the wire format.
|
||||
export interface VideoChunk {
|
||||
// In-memory segment shape used by mergeVideoSegments after the SW
|
||||
// decodes the wire format. Под D-13 каждый сегмент — самодостаточный
|
||||
// WebM-блок ≈ 10 секунд (свой EBML-заголовок и стартовый keyframe).
|
||||
export interface VideoSegment {
|
||||
data: Blob;
|
||||
timestamp: number;
|
||||
isFirst?: boolean;
|
||||
}
|
||||
|
||||
// Wire-format for video chunks traveling across the offscreen↔SW
|
||||
// chrome.runtime.Port boundary. Replaces the previous VideoChunk[]
|
||||
// payload, which failed because Blob is not JSON-serializable.
|
||||
// See debug session d12-blob-port-transfer-fails and the GREEN block
|
||||
// of tests/offscreen/port-serialization.test.ts for the pinned shape.
|
||||
export interface TransferredVideoChunk {
|
||||
// Wire-format for video segments traveling across the offscreen↔SW
|
||||
// chrome.runtime.Port boundary. Replaces the previous Blob payload,
|
||||
// which failed because Blob is not JSON-serializable.
|
||||
// See debug session d12-blob-port-transfer-fails (resolved) and the
|
||||
// GREEN block of tests/offscreen/port-serialization.test.ts for the
|
||||
// pinned shape. The header-pin flag from the D-09..D-11 era is gone
|
||||
// — under D-13 every segment IS implicitly its own header.
|
||||
export interface TransferredVideoSegment {
|
||||
data: string; // base64-encoded blob bytes (no data: prefix)
|
||||
type: string; // MIME type to apply when reconstructing the Blob
|
||||
timestamp: number;
|
||||
isFirst?: boolean;
|
||||
}
|
||||
|
||||
// Лог событий пользователя
|
||||
@@ -86,9 +90,9 @@ export interface PopupState {
|
||||
|
||||
// Ответы от Service Worker
|
||||
export interface VideoBufferResponse {
|
||||
chunks: VideoChunk[];
|
||||
segments: VideoSegment[];
|
||||
}
|
||||
|
||||
export interface RrwebEventsResponse {
|
||||
events: any[];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user