Milestone v1 (v2.0.0): Mokosh — Session Capture #1

Merged
strategy155 merged 297 commits from gsd/phase-04-harden-clean-up-optional into main 2026-05-31 15:34:17 +00:00
Showing only changes of commit 35db6c2357 - Show all commits

View File

@@ -7,6 +7,7 @@ import type {
SessionMetadata, SessionMetadata,
VideoBufferResponse VideoBufferResponse
} from '../shared/types'; } from '../shared/types';
import { remuxSegments } from './webm-remux';
import JSZip from 'jszip'; import JSZip from 'jszip';
// Default MIME applied when a wire chunk somehow lacks a type // Default MIME applied when a wire chunk somehow lacks a type
@@ -130,9 +131,10 @@ function decodeBufferSegments(
wireSegments: TransferredVideoSegment[], wireSegments: TransferredVideoSegment[],
): VideoSegment[] { ): VideoSegment[] {
// WR-07 fix: filter empty wire segments BEFORE base64 decode. An empty // WR-07 fix: filter empty wire segments BEFORE base64 decode. An empty
// wire.data would decode to a zero-byte Blob; mergeVideoSegments would // wire.data would decode to a zero-byte Blob; the remux pipeline
// then concat it into the output WebM, producing a stray empty EBML // (src/background/webm-remux.ts, Plan 01-08 D-14-remux) would try
// segment that breaks Chrome playback. Two passes (filter -> decode -> // to parse it via ts-ebml and either fail loudly or emit zero frames,
// either way wasting a parse cycle. Two passes (filter -> decode ->
// filter-non-empty) keep the iteration semantics declarative. // filter-non-empty) keep the iteration semantics declarative.
const nonEmptyWires = wireSegments.filter((wire) => { const nonEmptyWires = wireSegments.filter((wire) => {
const isEmpty = !wire.data || wire.data.length === 0; const isEmpty = !wire.data || wire.data.length === 0;
@@ -382,43 +384,14 @@ async function captureScreenshot(): Promise<Blob> {
return cachedScreenshot; return cachedScreenshot;
} }
// Склейка сегментов в один WebM-файл. // mergeVideoSegments (D-13 file-concat) retired in Plan 01-08 (D-14-remux):
// // see src/background/webm-remux.ts for the single-EBML remux path.
// Под D-13 каждый VideoSegment — это самодостаточный ~10-секундный WebM // The concat-of-self-contained-WebM-segments approach produced a
// (собственный EBML-заголовок + seed keyframe). Сортируем по timestamp // multi-EBML-header file that mpv / Chrome / ffprobe truncated to
// и склеиваем подряд: получаем multi-EBML-header файл, который Chrome // the first segment's local Info.Duration ~9.94 s; the remux path
// проигрывает последовательно (см. SPEC §10 #7 — требуется проигрывание // emits a single-EBML WebM whose Info.Duration covers the full ~30 s
// в Chrome, кросс-плейер совместимость вне scope Phase 1). // timeline. D-13's recorder-side restart-segments lifecycle is
// // preserved — only the merge step is replaced.
// Никакой логики «pin первого чанка» или header-retention больше нет
// — это снято вместе с D-09..D-11 и активацией D-13 в recorder.ts.
function mergeVideoSegments(segments: VideoSegment[]): Blob {
logger.log(`Merging ${segments.length} segments`);
// Сортируем по времени, чтобы сохранить правильный порядок (старшие
// сегменты — раньше). Под D-13 порядок уже задаётся offscreen-стороной,
// но сортируем оборонительно — стоимость copy+sort ничтожна.
const sortedSegments = [...segments].sort((a, b) => a.timestamp - b.timestamp);
logger.log(
`Segments sorted, first timestamp: ${sortedSegments[0]?.timestamp}, ` +
`last: ${sortedSegments[sortedSegments.length - 1]?.timestamp}`,
);
// Каждый сегмент уже валидный 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 segments merged: ${blobs.length}`,
);
return finalBlob;
}
// Создание архива // Создание архива
async function createArchive( async function createArchive(
@@ -446,14 +419,17 @@ async function createArchive(
'no video segments available — buffer fetch returned empty (port replacement timed out, or recorder never started)', 'no video segments available — buffer fetch returned empty (port replacement timed out, or recorder never started)',
); );
} }
const videoBlob = mergeVideoSegments(videoBufferResponse.segments); // Plan 01-08 D-14-remux: replaces the retired mergeVideoSegments()
// file-concat with the new single-EBML WebM remux. Async now —
// ts-ebml parse + webm-muxer write happen on the SW thread.
const videoBlob = await remuxSegments(videoBufferResponse.segments);
if (videoBlob.size === 0) { if (videoBlob.size === 0) {
throw new EmptyVideoBufferError( throw new EmptyVideoBufferError(
`merged video blob is zero bytes (segment count=${videoBufferResponse.segments.length})`, `remuxed video blob is zero bytes (segment count=${videoBufferResponse.segments.length})`,
); );
} }
zip.file('video/last_30sec.webm', videoBlob); zip.file('video/last_30sec.webm', videoBlob);
logger.log(`✓ Added video: ${videoBlob.size} bytes`); logger.log(`✓ Added video (remuxed): ${videoBlob.size} bytes`);
// Добавляем rrweb события // Добавляем rrweb события
const rrwebJson = JSON.stringify(rrwebEvents, null, 2); const rrwebJson = JSON.stringify(rrwebEvents, null, 2);