feat(01-08): implement remuxSegments — single-EBML WebM remux via ts-ebml + webm-muxer
Drives all 5 RED tests in tests/background/webm-remux.test.ts to GREEN.
- Parses each VideoSegment via ts-ebml Decoder; tracks current Cluster
Timestamp; extracts each SimpleBlock's VP9 frame(s) + keyframe flag
+ segment-local timestamp via tools.ebmlBlock.
- Re-emits all frames through a single webm-muxer Muxer<ArrayBufferTarget>
configured with type:'webm', codec:'V_VP9', and adjusted monotonic
timestamps (segmentBaseMs + cluster.Timestamp + block.timecode,
microseconds for the muxer).
- Picks track info (PixelWidth, PixelHeight, optional CodecPrivate)
from first segment that exposes them; falls back to 1024x768 with
a logged warning per Task 5's failure-mode (e).
- Defensive: empty input -> empty Blob (Test 5); sort by timestamp
ascending (mirrors retired mergeVideoSegments order discipline).
- 434 LOC including extensive JSDoc per project style; 8 small named
helpers, no nested mega-functions.
- Empirically: 3-segment fixture -> 912 frames in 29.954 s,
1_643_057 bytes (single-EBML); ffprobe duration=29.94s, count_frames=912.
- Logging via new Logger('Remux'); no console.* anywhere; no as any;
no @ts-ignore.
Full suite: 13 files / 60 GREEN + 2 RED (webm-playback duration assertions
still failing against the stale fixture — Task 4 swaps the call site,
Task 5 regenerates the fixture). tsc exit 0.
This commit is contained in:
434
src/background/webm-remux.ts
Normal file
434
src/background/webm-remux.ts
Normal file
@@ -0,0 +1,434 @@
|
|||||||
|
/**
|
||||||
|
* @file src/background/webm-remux.ts
|
||||||
|
*
|
||||||
|
* WebM remux pipeline for Plan 01-08 (CONTEXT.md amendment D-14-remux —
|
||||||
|
* disambiguated from the historical "D-14: Not applicable" tab-switch
|
||||||
|
* decision recorded in the original decisions block; see CONTEXT.md
|
||||||
|
* §"Amendment ... D-14-remux: WebM remux via ts-ebml + webm-muxer"
|
||||||
|
* for the canonical statement, B-02 fix). Replaces
|
||||||
|
* `mergeVideoSegments()` in `src/background/index.ts` (which
|
||||||
|
* file-concatenated the offscreen recorder's 3 self-contained ~10 s
|
||||||
|
* WebM segments — producing a multi-EBML-header file that mpv,
|
||||||
|
* Chrome's HTMLMediaElement, and ffprobe's `format=duration` all
|
||||||
|
* truncate to the first segment's local Info.Duration ~9.94 s).
|
||||||
|
*
|
||||||
|
* The new pipeline produces a single-EBML-headered WebM that any
|
||||||
|
* standards-compliant Matroska parser reads as the full ~30 s
|
||||||
|
* timeline. See `.planning/debug/d13-multi-ebml-concat-unplayable.md`
|
||||||
|
* for the byte-level evidence and library-survey rationale that
|
||||||
|
* locked the `ts-ebml` (parse) + `webm-muxer` (write) choice.
|
||||||
|
*
|
||||||
|
* ## Algorithm
|
||||||
|
*
|
||||||
|
* 1. Sort input segments by `timestamp` ascending (defensive — the
|
||||||
|
* offscreen recorder already emits in order, but a copy+sort is
|
||||||
|
* cheap relative to the parse/mux pass).
|
||||||
|
* 2. Parse the FIRST segment via `ts-ebml.Decoder` to derive track
|
||||||
|
* info: PixelWidth, PixelHeight, optional CodecPrivate. Needed
|
||||||
|
* for the muxer's `video` config.
|
||||||
|
* 3. Create one `Muxer<ArrayBufferTarget>` configured for VP9
|
||||||
|
* (`codec: 'V_VP9'`) with `type: 'webm'` and
|
||||||
|
* `firstTimestampBehavior: 'offset'` (forgives a non-zero first
|
||||||
|
* frame, in case the muxer rejects the first segment's "start
|
||||||
|
* at 0" timestamp after we add the prior-segment offset).
|
||||||
|
* 4. For each segment, accumulate a `segmentBaseMs` counter and
|
||||||
|
* feed every SimpleBlock through `addVideoChunkRaw(data, type,
|
||||||
|
* globalUs)` where `globalUs = (segmentBaseMs + clusterTs +
|
||||||
|
* blockOffset) * 1_000`. Frame data comes from
|
||||||
|
* `tools.ebmlBlock(simpleBlock.data).frames` (each SimpleBlock
|
||||||
|
* typically carries 1 VP9 frame; multi-frame lacing is rare in
|
||||||
|
* MediaRecorder output but supported here).
|
||||||
|
* 5. After every segment, advance `segmentBaseMs` by that
|
||||||
|
* segment's measured content duration (last-frame timestamp
|
||||||
|
* + nominal frame interval).
|
||||||
|
* 6. `muxer.finalize()` → wrap `target.buffer` in a `Blob`.
|
||||||
|
*
|
||||||
|
* ## Style notes
|
||||||
|
*
|
||||||
|
* - Extensive JSDoc per the project's global style guide.
|
||||||
|
* - No `as any`, no `@ts-ignore`. The two libraries' published
|
||||||
|
* type surfaces (see `node_modules/{ts-ebml,webm-muxer}/`)
|
||||||
|
* are used directly.
|
||||||
|
* - `if (cond) return ...` guard-clause exceptions to the
|
||||||
|
* "prefer if-else over early return" project rule are documented
|
||||||
|
* inline where they appear (empty input, missing track info).
|
||||||
|
* The user's CLAUDE.md acknowledges guard clauses as a clarity
|
||||||
|
* exception worth preserving over a deeper indent.
|
||||||
|
* - All diagnostics via `Logger('Remux')` — no bare `console.log`.
|
||||||
|
* - Threat-model note (T-1-08-01, T-1-08-03 in PLAN.md §threat_model):
|
||||||
|
* ts-ebml + webm-muxer process attacker-influenced bytes (the input
|
||||||
|
* segments come from the offscreen MediaRecorder, which captures
|
||||||
|
* whatever screen content the operator picked). Parse failures
|
||||||
|
* are surfaced as `EmptyVideoBufferError` upstream (via the
|
||||||
|
* `output.size === 0` branch in `createArchive`), giving the
|
||||||
|
* operator a clear failure surface rather than a corrupt archive.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Decoder, tools } from 'ts-ebml';
|
||||||
|
import { Muxer, ArrayBufferTarget } from 'webm-muxer';
|
||||||
|
|
||||||
|
import { Logger } from '../shared/logger';
|
||||||
|
import type { VideoSegment } from '../shared/types';
|
||||||
|
|
||||||
|
const logger = new Logger('Remux');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Codec identifier the muxer expects for VP9 in the Matroska
|
||||||
|
* codec-id taxonomy. See https://www.matroska.org/technical/codec_specs.html
|
||||||
|
*/
|
||||||
|
const VP9_MATROSKA_CODEC = 'V_VP9';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nominal frame interval added after the last frame of each segment
|
||||||
|
* to advance `segmentBaseMs` so the next segment's first frame slots
|
||||||
|
* in just after the prior segment's last frame. Mirrors the
|
||||||
|
* MediaRecorder cadence (`getDisplayMedia` at ~30 fps → 33 ms/frame).
|
||||||
|
* The exact value matters only for the inter-segment gap; +/-3 ms
|
||||||
|
* is invisible at human playback timescales.
|
||||||
|
*/
|
||||||
|
const NOMINAL_FRAME_INTERVAL_MS = 33;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback frame rate hint for the muxer's `video.frameRate`
|
||||||
|
* field. Used as metadata only — the muxer does not enforce it.
|
||||||
|
*/
|
||||||
|
const DEFAULT_FRAME_RATE = 30;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default pixel dimensions when the first segment lacks a usable
|
||||||
|
* Video element. These are conservative — they keep the muxer
|
||||||
|
* happy even if the first segment is malformed. In practice the
|
||||||
|
* MediaRecorder always emits PixelWidth/PixelHeight at segment
|
||||||
|
* head, so this branch is a defense-in-depth fallback.
|
||||||
|
*/
|
||||||
|
const FALLBACK_PIXEL_WIDTH = 1024;
|
||||||
|
const FALLBACK_PIXEL_HEIGHT = 768;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track info extracted from a segment's Tracks → TrackEntry → Video
|
||||||
|
* subtree. `codecPrivate` is optional because VP9 MediaRecorder
|
||||||
|
* streams generally do not ship one (Chrome derives the VP9 config
|
||||||
|
* from the first keyframe's superframe header).
|
||||||
|
*/
|
||||||
|
interface TrackInfo {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
codecPrivate?: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the contents of a Blob into an ArrayBuffer. Uses the web
|
||||||
|
* standard `Blob.arrayBuffer()` which is available in Chrome
|
||||||
|
* service-worker context (Chrome 76+) — no fallback needed.
|
||||||
|
*
|
||||||
|
* @param blob - Source Blob.
|
||||||
|
* @returns Promise resolving to the blob's bytes as an ArrayBuffer.
|
||||||
|
*/
|
||||||
|
async function blobToArrayBuffer(blob: Blob): Promise<ArrayBuffer> {
|
||||||
|
return blob.arrayBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a Node `Buffer` (which ts-ebml uses internally because
|
||||||
|
* its `ebml` dep is built for Node Buffer) to a `Uint8Array` view
|
||||||
|
* with no copy. Both share the same underlying memory.
|
||||||
|
*
|
||||||
|
* Why: `webm-muxer.addVideoChunkRaw` takes `Uint8Array`. `Buffer`
|
||||||
|
* IS a `Uint8Array` (TypeScript's lib.dom.d.ts encodes this), but
|
||||||
|
* to keep call-site types crisp we narrow explicitly here.
|
||||||
|
*
|
||||||
|
* @param buf - Node Buffer or already-Uint8Array.
|
||||||
|
* @returns The same bytes as a `Uint8Array`.
|
||||||
|
*/
|
||||||
|
function asUint8Array(buf: Uint8Array): Uint8Array {
|
||||||
|
// Buffer extends Uint8Array — pass through unchanged. The explicit
|
||||||
|
// identity function is a typed-narrowing convenience; the runtime
|
||||||
|
// cost is zero.
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk the decoded EBML element list for a segment and pull out
|
||||||
|
* the first Tracks → TrackEntry → Video subtree's PixelWidth,
|
||||||
|
* PixelHeight, and (optional) CodecPrivate. Stops at the first
|
||||||
|
* complete Video subtree; subsequent video tracks are ignored
|
||||||
|
* because Phase 1's MediaRecorder produces exactly one track.
|
||||||
|
*
|
||||||
|
* Returns `null` if no Video subtree was found — caller should
|
||||||
|
* fall back to {@link FALLBACK_PIXEL_WIDTH} / {@link
|
||||||
|
* FALLBACK_PIXEL_HEIGHT} so the muxer can still produce output.
|
||||||
|
*
|
||||||
|
* @param elements - Output of `Decoder.decode(buffer)`.
|
||||||
|
* @returns Track info or null if not derivable.
|
||||||
|
*/
|
||||||
|
function pickTrackInfoFromSegment(
|
||||||
|
elements: ReturnType<Decoder['decode']>,
|
||||||
|
): TrackInfo | null {
|
||||||
|
let inVideo = false;
|
||||||
|
let width: number | null = null;
|
||||||
|
let height: number | null = null;
|
||||||
|
let codecPrivate: Uint8Array | undefined;
|
||||||
|
for (const el of elements) {
|
||||||
|
if (el.name === 'Video' && el.type === 'm') {
|
||||||
|
// Master element: track enter/exit.
|
||||||
|
if (el.isEnd) {
|
||||||
|
// Leaving the Video subtree — if we got both dimensions we're done.
|
||||||
|
if (width !== null && height !== null) {
|
||||||
|
return { width, height, codecPrivate };
|
||||||
|
}
|
||||||
|
inVideo = false;
|
||||||
|
} else {
|
||||||
|
inVideo = true;
|
||||||
|
}
|
||||||
|
} else if (inVideo && el.name === 'PixelWidth' && el.type === 'u') {
|
||||||
|
width = el.value;
|
||||||
|
} else if (inVideo && el.name === 'PixelHeight' && el.type === 'u') {
|
||||||
|
height = el.value;
|
||||||
|
} else if (el.name === 'CodecPrivate' && el.type === 'b') {
|
||||||
|
// CodecPrivate lives at TrackEntry level (sibling of Video),
|
||||||
|
// not inside Video. Pick the first one seen.
|
||||||
|
if (codecPrivate === undefined && el.data) {
|
||||||
|
codecPrivate = asUint8Array(el.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (width !== null && height !== null) {
|
||||||
|
return { width, height, codecPrivate };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single VP9 frame extracted from a segment's SimpleBlock,
|
||||||
|
* paired with its keyframe flag and a per-segment-local
|
||||||
|
* timestamp in milliseconds.
|
||||||
|
*/
|
||||||
|
interface ExtractedFrame {
|
||||||
|
data: Uint8Array;
|
||||||
|
isKey: boolean;
|
||||||
|
/** Per-segment-local timestamp in milliseconds. */
|
||||||
|
localTimestampMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of {@link extractFramesFromSegment}. `segmentDurationMs`
|
||||||
|
* is the duration the SEGMENT consumed on its own local timeline
|
||||||
|
* (last frame's timestamp + {@link NOMINAL_FRAME_INTERVAL_MS}).
|
||||||
|
* The caller adds this to `segmentBaseMs` so the next segment's
|
||||||
|
* first frame doesn't collide with this segment's last.
|
||||||
|
*/
|
||||||
|
interface SegmentExtraction {
|
||||||
|
frames: ExtractedFrame[];
|
||||||
|
segmentDurationMs: number;
|
||||||
|
trackInfo: TrackInfo | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a segment's ArrayBuffer via ts-ebml and walk its element
|
||||||
|
* tree extracting one {@link ExtractedFrame} per VP9 frame inside
|
||||||
|
* each SimpleBlock. Tracks current Cluster Timestamp so each
|
||||||
|
* frame's `localTimestampMs` is the absolute segment-local time
|
||||||
|
* (cluster timestamp + per-block offset).
|
||||||
|
*
|
||||||
|
* The keyframe flag is taken from `tools.ebmlBlock(buf).keyframe`
|
||||||
|
* which decodes the SimpleBlock's flags byte per the Matroska
|
||||||
|
* spec (bit 7 of the byte after the variable-length track number
|
||||||
|
* and the 16-bit timestamp delta).
|
||||||
|
*
|
||||||
|
* Multi-frame SimpleBlocks (lacing) are flattened — each frame
|
||||||
|
* gets its own `addVideoChunkRaw` call sharing the same
|
||||||
|
* `localTimestampMs`. MediaRecorder under Chrome rarely uses
|
||||||
|
* lacing for VP9 (typical SimpleBlock = 1 frame), but the
|
||||||
|
* implementation handles it correctly.
|
||||||
|
*
|
||||||
|
* @param buffer - The segment's bytes.
|
||||||
|
* @returns Frames + measured segment duration + extracted track info.
|
||||||
|
*/
|
||||||
|
function extractFramesFromSegment(
|
||||||
|
buffer: ArrayBuffer,
|
||||||
|
): SegmentExtraction {
|
||||||
|
const decoder = new Decoder();
|
||||||
|
const elements = decoder.decode(buffer);
|
||||||
|
const trackInfo = pickTrackInfoFromSegment(elements);
|
||||||
|
const frames: ExtractedFrame[] = [];
|
||||||
|
let currentClusterTs = 0;
|
||||||
|
let lastFrameTimestampMs = 0;
|
||||||
|
let inCluster = false;
|
||||||
|
for (const el of elements) {
|
||||||
|
if (el.name === 'Cluster' && el.type === 'm') {
|
||||||
|
if (el.isEnd) {
|
||||||
|
inCluster = false;
|
||||||
|
} else {
|
||||||
|
inCluster = true;
|
||||||
|
}
|
||||||
|
} else if (inCluster && el.name === 'Timestamp' && el.type === 'u') {
|
||||||
|
// Matroska v4 renamed Cluster.Timecode → Cluster.Timestamp.
|
||||||
|
// ts-ebml's schema reflects the rename, so this is the
|
||||||
|
// correct name (not 'Timecode').
|
||||||
|
currentClusterTs = el.value;
|
||||||
|
} else if (el.name === 'SimpleBlock' && el.type === 'b' && el.data) {
|
||||||
|
const sb = tools.ebmlBlock(el.data);
|
||||||
|
const blockGlobalMs = currentClusterTs + sb.timecode;
|
||||||
|
for (const frame of sb.frames) {
|
||||||
|
frames.push({
|
||||||
|
data: asUint8Array(frame),
|
||||||
|
isKey: sb.keyframe,
|
||||||
|
localTimestampMs: blockGlobalMs,
|
||||||
|
});
|
||||||
|
lastFrameTimestampMs = blockGlobalMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Segment duration covers the last frame plus a nominal interval
|
||||||
|
// — the next segment's first frame slots in just after.
|
||||||
|
const segmentDurationMs =
|
||||||
|
frames.length === 0 ? 0 : lastFrameTimestampMs + NOMINAL_FRAME_INTERVAL_MS;
|
||||||
|
return { frames, segmentDurationMs, trackInfo };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remux a sequence of self-contained WebM `VideoSegment` blobs
|
||||||
|
* into a single WebM Blob with one EBML header and one Segment
|
||||||
|
* element. Each input segment carries its own EBML+Segment+
|
||||||
|
* Cluster tree (output of `MediaRecorder.start()` →
|
||||||
|
* `dataavailable` → `MediaRecorder.stop()` cycle); the output
|
||||||
|
* concatenates every VP9 frame across all input segments into a
|
||||||
|
* single Matroska timeline with monotonically increasing
|
||||||
|
* timestamps.
|
||||||
|
*
|
||||||
|
* Empty input is handled defensively (returns an empty Blob —
|
||||||
|
* the upstream `EmptyVideoBufferError` throw in
|
||||||
|
* `src/background/index.ts:createArchive` catches that and
|
||||||
|
* surfaces RECORDING_ERROR to the popup; this function is also
|
||||||
|
* safe in isolation).
|
||||||
|
*
|
||||||
|
* Caller contract:
|
||||||
|
* - Input segments must be self-contained WebM bytes per
|
||||||
|
* D-13's restart-segments lifecycle.
|
||||||
|
* - Input order is normalized internally (sorted by
|
||||||
|
* `timestamp` ascending — defensive copy).
|
||||||
|
* - Output type is `video/webm`.
|
||||||
|
*
|
||||||
|
* @param segments - Sequence of WebM segments produced by the
|
||||||
|
* offscreen MediaRecorder rotation lifecycle.
|
||||||
|
* @returns Single-EBML-headered WebM Blob covering every VP9
|
||||||
|
* frame across all input segments with adjusted monotonic
|
||||||
|
* timestamps.
|
||||||
|
*/
|
||||||
|
export async function remuxSegments(segments: VideoSegment[]): Promise<Blob> {
|
||||||
|
// Guard clause exception: empty input is the most common
|
||||||
|
// failure surface in the saveArchive path, and the early-return
|
||||||
|
// body is one statement long — clearer than nesting the entire
|
||||||
|
// remux inside an else.
|
||||||
|
if (segments.length === 0) {
|
||||||
|
logger.log('Empty input — returning empty Blob');
|
||||||
|
return new Blob([], { type: 'video/webm' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = [...segments].sort((a, b) => a.timestamp - b.timestamp);
|
||||||
|
logger.log(
|
||||||
|
`Remuxing ${sorted.length} segments; sizes:`,
|
||||||
|
sorted.map((s) => s.data.size),
|
||||||
|
);
|
||||||
|
|
||||||
|
// First-pass extraction of all segments. We need the FIRST
|
||||||
|
// segment's trackInfo for the muxer config before we can start
|
||||||
|
// pushing chunks, so it's cleanest to extract everything up
|
||||||
|
// front and then drive the muxer in a second pass with the
|
||||||
|
// monotonic timestamps. Memory cost is modest (~3 × ~10 s of
|
||||||
|
// VP9 frame bytes ≈ same as the input total ~1.5 MB).
|
||||||
|
const extractions: SegmentExtraction[] = [];
|
||||||
|
for (const seg of sorted) {
|
||||||
|
const ab = await blobToArrayBuffer(seg.data);
|
||||||
|
const extraction = extractFramesFromSegment(ab);
|
||||||
|
extractions.push(extraction);
|
||||||
|
logger.log(
|
||||||
|
`Segment ts=${seg.timestamp}: ${extraction.frames.length} frames, ` +
|
||||||
|
`duration=${extraction.segmentDurationMs}ms, ` +
|
||||||
|
`trackInfo=${
|
||||||
|
extraction.trackInfo
|
||||||
|
? `${extraction.trackInfo.width}x${extraction.trackInfo.height}`
|
||||||
|
: 'null'
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick track info from the FIRST segment that exposes one.
|
||||||
|
// Fallback to conservative defaults — the muxer needs *some*
|
||||||
|
// width/height in its video config or the output WebM will
|
||||||
|
// refuse to play.
|
||||||
|
let pickedTrackInfo: TrackInfo | null = null;
|
||||||
|
for (const extraction of extractions) {
|
||||||
|
if (extraction.trackInfo !== null) {
|
||||||
|
pickedTrackInfo = extraction.trackInfo;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pickedTrackInfo === null) {
|
||||||
|
logger.warn(
|
||||||
|
`pickTrackInfoFromSegment returned null for all ${extractions.length} segments — ` +
|
||||||
|
`falling back to ${FALLBACK_PIXEL_WIDTH}x${FALLBACK_PIXEL_HEIGHT}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const trackInfo: TrackInfo = pickedTrackInfo ?? {
|
||||||
|
width: FALLBACK_PIXEL_WIDTH,
|
||||||
|
height: FALLBACK_PIXEL_HEIGHT,
|
||||||
|
};
|
||||||
|
|
||||||
|
const target = new ArrayBufferTarget();
|
||||||
|
const muxer = new Muxer({
|
||||||
|
target,
|
||||||
|
video: {
|
||||||
|
codec: VP9_MATROSKA_CODEC,
|
||||||
|
width: trackInfo.width,
|
||||||
|
height: trackInfo.height,
|
||||||
|
frameRate: DEFAULT_FRAME_RATE,
|
||||||
|
},
|
||||||
|
// No `audio` block — Phase 1 SPEC §9 / CAP-01 excludes audio.
|
||||||
|
type: 'webm',
|
||||||
|
firstTimestampBehavior: 'offset',
|
||||||
|
});
|
||||||
|
|
||||||
|
let segmentBaseMs = 0;
|
||||||
|
let totalFramesEmitted = 0;
|
||||||
|
for (const extraction of extractions) {
|
||||||
|
for (const frame of extraction.frames) {
|
||||||
|
const globalMs = segmentBaseMs + frame.localTimestampMs;
|
||||||
|
const globalUs = globalMs * 1_000;
|
||||||
|
// EncodedVideoChunkMetadata.decoderConfig.codec is required by
|
||||||
|
// the WebCodecs typing (lib.dom.d.ts VideoDecoderConfig).
|
||||||
|
// 'vp09.00.10.08' is the canonical WebCodecs codec string for
|
||||||
|
// VP9 Profile 0, level 1.0, 8-bit — the published Chrome
|
||||||
|
// default for MediaRecorder VP9 output. Only attached when a
|
||||||
|
// CodecPrivate was actually extracted (T-trackInfo path). For
|
||||||
|
// MediaRecorder-produced segments this branch is rare —
|
||||||
|
// Chrome's published VP9 stream omits CodecPrivate and the
|
||||||
|
// muxer derives parameters from the first keyframe.
|
||||||
|
const meta = trackInfo.codecPrivate
|
||||||
|
? {
|
||||||
|
decoderConfig: {
|
||||||
|
codec: 'vp09.00.10.08',
|
||||||
|
description: trackInfo.codecPrivate,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
muxer.addVideoChunkRaw(
|
||||||
|
frame.data,
|
||||||
|
frame.isKey ? 'key' : 'delta',
|
||||||
|
globalUs,
|
||||||
|
meta,
|
||||||
|
);
|
||||||
|
totalFramesEmitted++;
|
||||||
|
}
|
||||||
|
segmentBaseMs += extraction.segmentDurationMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
muxer.finalize();
|
||||||
|
const outputBuffer = target.buffer;
|
||||||
|
const outputBlob = new Blob([outputBuffer], { type: 'video/webm' });
|
||||||
|
logger.log(
|
||||||
|
`Remux complete: ${totalFramesEmitted} frames, ` +
|
||||||
|
`total timeline=${segmentBaseMs}ms, output=${outputBlob.size} bytes`,
|
||||||
|
);
|
||||||
|
return outputBlob;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user