feat(fix-a3): rename TransferredVideoChunk → TransferredVideoSegment

Pure rename pass — zero behavioural change at any call site. The
prior "chunk" naming is a vestige of D-09..D-11's chunk-level
buffer; under D-13 the unit of transfer is a self-contained ~10 s
WebM segment, so the type name now matches the data shape.

Renames propagated atomically:
- src/shared/types.ts
  * TransferredVideoChunk → TransferredVideoSegment
    (also: the `isFirst?: boolean` field is dropped — D-13 segments
    are all implicitly their own header, so the flag is meaningless
    and only existed for the retired ring-buffer's pin semantics)
  * VideoChunk → VideoSegment (drops `isFirst?` for the same reason)
  * PortMessage.chunks? → PortMessage.segments?
  * VideoBufferResponse.chunks → VideoBufferResponse.segments
- src/offscreen/recorder.ts
  * import type rename
  * encodeAndSendBuffer's per-element accumulator + filter type
  * the port.postMessage payload field
- src/background/index.ts
  * import type rename
  * getVideoBufferFromOffscreen reads `(msg as {segments?}).segments`
    (matching the new wire field name)
  * empty-buffer returns `{ segments: [] }`
  * mergeVideoSegments signature takes VideoSegment[]
  * createArchive consumes videoBufferResponse.segments
  * saveArchive log line says "segments"

Verification:
- npx tsc --noEmit clean.
- npx vitest run → 28 passed / 2 failed (the 2 fixture-empirical
  ffmpeg dry-runs; unchanged — they're gated on ./smoke.sh regen).
- npm run build succeeds, all 60 modules transformed.
- grep predicates clean in src/:
  * no addChunk / trimAged / firstChunkSaved / isFirst
  * no TransferredVideoChunk / VideoChunk (old names)
  * 21 occurrences of new names propagated correctly
- Pre-existing port-serialization.test.ts still GREEN: its `isFirst`
  references are inside inline-test-scoped objects (not imports
  from production types), and its tests assert JSON-roundtrip
  behaviour rather than the production type shape.
This commit is contained in:
2026-05-15 21:15:19 +02:00
parent 670daa3fe8
commit f81438d6c8
3 changed files with 49 additions and 46 deletions

View File

@@ -2,8 +2,8 @@ import { Logger } from '../shared/logger';
import { base64ToBlob } from '../shared/binary'; import { base64ToBlob } from '../shared/binary';
import type { import type {
Message, Message,
TransferredVideoChunk, TransferredVideoSegment,
VideoChunk, VideoSegment,
SessionMetadata, SessionMetadata,
VideoBufferResponse VideoBufferResponse
} from '../shared/types'; } from '../shared/types';
@@ -105,14 +105,14 @@ const BUFFER_FETCH_TIMEOUT_MS = 2_000;
async function getVideoBufferFromOffscreen(): Promise<VideoBufferResponse> { async function getVideoBufferFromOffscreen(): Promise<VideoBufferResponse> {
if (videoPort === null) { if (videoPort === null) {
logger.warn('No offscreen port available; returning empty buffer'); logger.warn('No offscreen port available; returning empty buffer');
return { chunks: [] }; return { segments: [] };
} }
const port = videoPort; const port = videoPort;
return new Promise<VideoBufferResponse>((resolve) => { return new Promise<VideoBufferResponse>((resolve) => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
port.onMessage.removeListener(handler); port.onMessage.removeListener(handler);
logger.warn(`Buffer fetch timed out after ${BUFFER_FETCH_TIMEOUT_MS} ms`); logger.warn(`Buffer fetch timed out after ${BUFFER_FETCH_TIMEOUT_MS} ms`);
resolve({ chunks: [] }); resolve({ segments: [] });
}, BUFFER_FETCH_TIMEOUT_MS); }, BUFFER_FETCH_TIMEOUT_MS);
const handler = (msg: unknown) => { const handler = (msg: unknown) => {
if ( if (
@@ -122,20 +122,19 @@ async function getVideoBufferFromOffscreen(): Promise<VideoBufferResponse> {
) { ) {
clearTimeout(timer); clearTimeout(timer);
port.onMessage.removeListener(handler); port.onMessage.removeListener(handler);
// D-12 wire format: payload arrives as TransferredVideoChunk[] // D-12 wire format + D-13 segment lifecycle: payload arrives
// — base64 string + MIME — because chrome.runtime.Port // as TransferredVideoSegment[] (base64 string + MIME). Decode
// JSON-serializes across extension contexts. Decode each entry // each entry back into a VideoSegment — each is a
// back into a VideoChunk. D-13 lifecycle: each entry is now a // self-contained ~10 s WebM (EBML header + seed keyframe).
// self-contained WebM segment (~10 s, EBML header + seed // Concatenating them sequentially produces a multi-EBML-header
// keyframe), not a partial chunk; concatenating them // file Chrome plays natively. See src/shared/binary.ts +
// sequentially produces a multi-EBML-header file Chrome plays // RESEARCH.md Pattern 3.
// natively. See src/shared/binary.ts + RESEARCH.md Pattern 3.
const wireSegments = const wireSegments =
(msg as { chunks?: TransferredVideoChunk[] }).chunks ?? []; (msg as { segments?: TransferredVideoSegment[] }).segments ?? [];
const chunks: VideoChunk[] = []; const segments: VideoSegment[] = [];
for (const wire of wireSegments) { for (const wire of wireSegments) {
try { try {
chunks.push({ segments.push({
data: base64ToBlob(wire.data, wire.type || VIDEO_MIME_FALLBACK), data: base64ToBlob(wire.data, wire.type || VIDEO_MIME_FALLBACK),
timestamp: wire.timestamp, timestamp: wire.timestamp,
}); });
@@ -147,7 +146,7 @@ async function getVideoBufferFromOffscreen(): Promise<VideoBufferResponse> {
); );
} }
} }
resolve({ chunks }); resolve({ segments });
} }
}; };
port.onMessage.addListener(handler); port.onMessage.addListener(handler);
@@ -238,7 +237,7 @@ async function captureScreenshot(): Promise<Blob> {
// Склейка сегментов в один WebM-файл. // Склейка сегментов в один WebM-файл.
// //
// Под D-13 каждый VideoChunk — это самодостаточный ~10-секундный WebM // Под D-13 каждый VideoSegment — это самодостаточный ~10-секундный WebM
// (собственный EBML-заголовок + seed keyframe). Сортируем по timestamp // (собственный EBML-заголовок + seed keyframe). Сортируем по timestamp
// и склеиваем подряд: получаем multi-EBML-header файл, который Chrome // и склеиваем подряд: получаем multi-EBML-header файл, который Chrome
// проигрывает последовательно (см. SPEC §10 #7 — требуется проигрывание // проигрывает последовательно (см. SPEC §10 #7 — требуется проигрывание
@@ -246,7 +245,7 @@ async function captureScreenshot(): Promise<Blob> {
// //
// Никакой логики «pin первого чанка» или header-retention больше нет // Никакой логики «pin первого чанка» или header-retention больше нет
// — это снято вместе с D-09..D-11 и активацией D-13 в recorder.ts. // — это снято вместе с D-09..D-11 и активацией D-13 в recorder.ts.
function mergeVideoSegments(segments: VideoChunk[]): Blob { function mergeVideoSegments(segments: VideoSegment[]): Blob {
logger.log(`Merging ${segments.length} segments`); logger.log(`Merging ${segments.length} segments`);
// Сортируем по времени, чтобы сохранить правильный порядок (старшие // Сортируем по времени, чтобы сохранить правильный порядок (старшие
@@ -286,9 +285,9 @@ async function createArchive(
const zip = new JSZip(); const zip = new JSZip();
// Добавляем видео (D-13: чанки в ответе — это уже сегменты) // Добавляем видео (D-13: каждая запись — самодостаточный WebM-сегмент)
if (videoBufferResponse.chunks.length > 0) { if (videoBufferResponse.segments.length > 0) {
const videoBlob = mergeVideoSegments(videoBufferResponse.chunks); const videoBlob = mergeVideoSegments(videoBufferResponse.segments);
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: ${videoBlob.size} bytes`);
} else { } else {
@@ -386,7 +385,7 @@ async function saveArchive() {
// Получаем видео буфер из offscreen через long-lived port (D-17) // Получаем видео буфер из offscreen через long-lived port (D-17)
const videoBufferResp = await getVideoBufferFromOffscreen(); const videoBufferResp = await getVideoBufferFromOffscreen();
logger.log(`Video buffer: ${videoBufferResp.chunks.length} chunks`); logger.log(`Video buffer: ${videoBufferResp.segments.length} segments`);
// Получаем rrweb события от content script // Получаем rrweb события от content script
logger.log(`Requesting rrweb events from content script on tab ${tab.id} (${tab.url})...`); logger.log(`Requesting rrweb events from content script on tab ${tab.id} (${tab.url})...`);

View File

@@ -16,7 +16,7 @@
import { OffscreenLogger } from '../shared/logger'; import { OffscreenLogger } from '../shared/logger';
import { blobToBase64 } from '../shared/binary'; 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) ──────────── // ─── Константы (per CON-video-codec, CON-video-window, D-13) ────────────
// Длительность одного сегмента — 10 с (D-13, RESEARCH.md Pattern 3). // Длительность одного сегмента — 10 с (D-13, RESEARCH.md Pattern 3).
@@ -355,7 +355,7 @@ async function encodeAndSendBuffer(): Promise<void> {
// (например, неожиданный detach ArrayBuffer-а на лету), логируем и // (например, неожиданный detach ArrayBuffer-а на лету), логируем и
// пропускаем — частичное видео > отсутствующее видео. // пропускаем — частичное видео > отсутствующее видео.
const encodeResults = await Promise.all( const encodeResults = await Promise.all(
allSegments.map(async (segment, idx): Promise<TransferredVideoChunk | null> => { allSegments.map(async (segment, idx): Promise<TransferredVideoSegment | null> => {
try { try {
const data = await blobToBase64(segment); const data = await blobToBase64(segment);
return { return {
@@ -376,15 +376,15 @@ async function encodeAndSendBuffer(): Promise<void> {
} }
}), }),
); );
const transferred: TransferredVideoChunk[] = encodeResults.filter( const transferred: TransferredVideoSegment[] = encodeResults.filter(
(c): c is TransferredVideoChunk => c !== null, (c): c is TransferredVideoSegment => c !== null,
); );
// Re-check port AFTER the await: it may have disconnected during encoding. // Re-check port AFTER the await: it may have disconnected during encoding.
if (keepalivePort === null) { if (keepalivePort === null) {
logger.warn('port disconnected during base64 encoding; dropping BUFFER response'); logger.warn('port disconnected during base64 encoding; dropping BUFFER response');
return; return;
} }
keepalivePort.postMessage({ type: 'BUFFER', chunks: transferred }); keepalivePort.postMessage({ type: 'BUFFER', segments: transferred });
} }
function connectPort(): void { function connectPort(): void {

View File

@@ -21,7 +21,7 @@ export interface Message<T = any> {
tabId?: number; 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 = export type PortMessageType =
| 'PING' | 'PING'
| 'REQUEST_BUFFER' | 'REQUEST_BUFFER'
@@ -29,31 +29,35 @@ export type PortMessageType =
export interface PortMessage { export interface PortMessage {
type: PortMessageType; type: PortMessageType;
// Wire-format (post-D-12 fix): chunks travel as TransferredVideoChunk[] // Wire-format (D-12 base64 transfer + D-13 segment lifecycle):
// because chrome.runtime.Port JSON-serializes payloads, and // segments travel as TransferredVideoSegment[] because
// JSON.stringify(blob) === "{}" loses binary content. The receive // chrome.runtime.Port JSON-serializes payloads across extension
// side reconstructs VideoChunk[] via src/shared/binary.ts. // contexts and JSON.stringify(blob) === "{}" loses binary content.
chunks?: TransferredVideoChunk[]; // 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 // In-memory segment shape used by mergeVideoSegments after the SW
// mergeVideoChunks after the SW decodes the wire format. // decodes the wire format. Под D-13 каждый сегмент — самодостаточный
export interface VideoChunk { // WebM-блок ≈ 10 секунд (свой EBML-заголовок и стартовый keyframe).
export interface VideoSegment {
data: Blob; data: Blob;
timestamp: number; timestamp: number;
isFirst?: boolean;
} }
// Wire-format for video chunks traveling across the offscreen↔SW // Wire-format for video segments traveling across the offscreen↔SW
// chrome.runtime.Port boundary. Replaces the previous VideoChunk[] // chrome.runtime.Port boundary. Replaces the previous Blob payload,
// payload, which failed because Blob is not JSON-serializable. // which failed because Blob is not JSON-serializable.
// See debug session d12-blob-port-transfer-fails and the GREEN block // See debug session d12-blob-port-transfer-fails (resolved) and the
// of tests/offscreen/port-serialization.test.ts for the pinned shape. // GREEN block of tests/offscreen/port-serialization.test.ts for the
export interface TransferredVideoChunk { // 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) data: string; // base64-encoded blob bytes (no data: prefix)
type: string; // MIME type to apply when reconstructing the Blob type: string; // MIME type to apply when reconstructing the Blob
timestamp: number; timestamp: number;
isFirst?: boolean;
} }
// Лог событий пользователя // Лог событий пользователя
@@ -86,7 +90,7 @@ export interface PopupState {
// Ответы от Service Worker // Ответы от Service Worker
export interface VideoBufferResponse { export interface VideoBufferResponse {
chunks: VideoChunk[]; segments: VideoSegment[];
} }
export interface RrwebEventsResponse { export interface RrwebEventsResponse {