Milestone v1 (v2.0.0): Mokosh — Session Capture #1
@@ -122,26 +122,27 @@ async function getVideoBufferFromOffscreen(): Promise<VideoBufferResponse> {
|
||||
) {
|
||||
clearTimeout(timer);
|
||||
port.onMessage.removeListener(handler);
|
||||
// D-12 fix: chunks arrive as TransferredVideoChunk[] (base64
|
||||
// string + MIME). Decode each back into a VideoChunk so
|
||||
// mergeVideoChunks keeps operating on real Blobs. See
|
||||
// src/shared/binary.ts and the GREEN block of
|
||||
// tests/offscreen/port-serialization.test.ts.
|
||||
const wireChunks =
|
||||
// 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.
|
||||
const wireSegments =
|
||||
(msg as { chunks?: TransferredVideoChunk[] }).chunks ?? [];
|
||||
const chunks: VideoChunk[] = [];
|
||||
for (const wire of wireChunks) {
|
||||
for (const wire of wireSegments) {
|
||||
try {
|
||||
chunks.push({
|
||||
data: base64ToBlob(wire.data, wire.type || VIDEO_MIME_FALLBACK),
|
||||
timestamp: wire.timestamp,
|
||||
isFirst: wire.isFirst,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
'base64ToBlob failed; skipping chunk',
|
||||
'base64ToBlob failed; skipping segment',
|
||||
'timestamp:', wire.timestamp,
|
||||
'isFirst:', wire.isFirst,
|
||||
'error:', err,
|
||||
);
|
||||
}
|
||||
@@ -235,23 +236,40 @@ async function captureScreenshot(): Promise<Blob> {
|
||||
return cachedScreenshot;
|
||||
}
|
||||
|
||||
// Склейка видео чанков
|
||||
function mergeVideoChunks(chunks: VideoChunk[]): Blob {
|
||||
logger.log(`Merging ${chunks.length} chunks`);
|
||||
// Склейка сегментов в один WebM-файл.
|
||||
//
|
||||
// Под D-13 каждый VideoChunk — это самодостаточный ~10-секундный WebM
|
||||
// (собственный EBML-заголовок + seed keyframe). Сортируем по timestamp
|
||||
// и склеиваем подряд: получаем multi-EBML-header файл, который Chrome
|
||||
// проигрывает последовательно (см. SPEC §10 #7 — требуется проигрывание
|
||||
// в Chrome, кросс-плейер совместимость вне scope Phase 1).
|
||||
//
|
||||
// Никакой логики «pin первого чанка» или header-retention больше нет
|
||||
// — это снято вместе с D-09..D-11 и активацией D-13 в recorder.ts.
|
||||
function mergeVideoSegments(segments: VideoChunk[]): Blob {
|
||||
logger.log(`Merging ${segments.length} segments`);
|
||||
|
||||
// Сортируем по времени, чтобы сохранить правильный порядок
|
||||
const sortedChunks = [...chunks].sort((a, b) => a.timestamp - b.timestamp);
|
||||
// Сортируем по времени, чтобы сохранить правильный порядок (старшие
|
||||
// сегменты — раньше). Под D-13 порядок уже задаётся offscreen-стороной,
|
||||
// но сортируем оборонительно — стоимость copy+sort ничтожна.
|
||||
const sortedSegments = [...segments].sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
logger.log(`Chunks sorted, first timestamp: ${sortedChunks[0]?.timestamp}, last: ${sortedChunks[sortedChunks.length - 1]?.timestamp}`);
|
||||
logger.log(
|
||||
`Segments sorted, first timestamp: ${sortedSegments[0]?.timestamp}, ` +
|
||||
`last: ${sortedSegments[sortedSegments.length - 1]?.timestamp}`,
|
||||
);
|
||||
|
||||
// Конвертируем в массив Blob
|
||||
const blobs: Blob[] = sortedChunks.map((chunk, index) => {
|
||||
logger.log(`Adding chunk ${index}, size: ${chunk.data.size} bytes, isFirst: ${chunk.isFirst}`);
|
||||
return chunk.data;
|
||||
// Каждый сегмент уже валидный WebM — конкатенация безопасна.
|
||||
const blobs: Blob[] = sortedSegments.map((segment, index) => {
|
||||
logger.log(`Adding segment ${index}, size: ${segment.data.size} bytes`);
|
||||
return segment.data;
|
||||
});
|
||||
|
||||
const finalBlob = new Blob(blobs, { type: 'video/webm' });
|
||||
logger.log(`Final video blob size: ${finalBlob.size} bytes, total chunks merged: ${blobs.length}`);
|
||||
logger.log(
|
||||
`Final video blob size: ${finalBlob.size} bytes, ` +
|
||||
`total segments merged: ${blobs.length}`,
|
||||
);
|
||||
|
||||
return finalBlob;
|
||||
}
|
||||
@@ -268,13 +286,13 @@ async function createArchive(
|
||||
|
||||
const zip = new JSZip();
|
||||
|
||||
// Добавляем видео
|
||||
// Добавляем видео (D-13: чанки в ответе — это уже сегменты)
|
||||
if (videoBufferResponse.chunks.length > 0) {
|
||||
const videoBlob = mergeVideoChunks(videoBufferResponse.chunks);
|
||||
const videoBlob = mergeVideoSegments(videoBufferResponse.chunks);
|
||||
zip.file('video/last_30sec.webm', videoBlob);
|
||||
logger.log(`✓ Added video: ${videoBlob.size} bytes`);
|
||||
} else {
|
||||
logger.warn('✗ No video chunks to add');
|
||||
logger.warn('✗ No video segments to add');
|
||||
}
|
||||
|
||||
// Добавляем rrweb события
|
||||
|
||||
Reference in New Issue
Block a user