e8c151f089
MediaRecorder/captureStream kept crashing the Chrome renderer (STATUS_BREAKPOINT). Video is now encoded offline with VideoEncoder (VP9 with VP8 fallback) and muxed by a dependency-free EBML/Matroska writer. Adds precise export controls: video fps (24/30/60), exact length (1-8 loops or 5-60s), and a GIF loop-forever/play-once toggle. Co-authored-by: Cursor <cursoragent@cursor.com>
128 lines
4.4 KiB
JavaScript
128 lines
4.4 KiB
JavaScript
/* Minimal dependency-free WebM (Matroska) muxer for WebCodecs output.
|
|
Takes encoded VP8/VP9 chunks and produces a playable .webm file. */
|
|
|
|
var WebMMux = (function () {
|
|
|
|
function concat(arrays) {
|
|
var total = 0;
|
|
for (var i = 0; i < arrays.length; i++) total += arrays[i].length;
|
|
var out = new Uint8Array(total);
|
|
var o = 0;
|
|
for (var j = 0; j < arrays.length; j++) { out.set(arrays[j], o); o += arrays[j].length; }
|
|
return out;
|
|
}
|
|
|
|
/* EBML element IDs are written verbatim (big-endian) */
|
|
function idBytes(id) {
|
|
var bytes = [];
|
|
while (id > 0) { bytes.unshift(id & 0xff); id = Math.floor(id / 256); }
|
|
return new Uint8Array(bytes);
|
|
}
|
|
|
|
/* EBML data-size vint */
|
|
function sizeBytes(n) {
|
|
if (n < 0x7F) return new Uint8Array([0x80 | n]);
|
|
if (n < 0x3FFF) return new Uint8Array([0x40 | (n >> 8), n & 0xff]);
|
|
if (n < 0x1FFFFF) return new Uint8Array([0x20 | (n >> 16), (n >> 8) & 0xff, n & 0xff]);
|
|
if (n < 0x0FFFFFFF) return new Uint8Array([0x10 | (n >> 24), (n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]);
|
|
/* up to ~1TB, plenty for any export */
|
|
var hi = Math.floor(n / 0x100000000);
|
|
return new Uint8Array([0x08 | hi, (n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]);
|
|
}
|
|
|
|
function el(id, payload) {
|
|
return concat([idBytes(id), sizeBytes(payload.length), payload]);
|
|
}
|
|
|
|
function uintPayload(n) {
|
|
var bytes = [];
|
|
do { bytes.unshift(n & 0xff); n = Math.floor(n / 256); } while (n > 0);
|
|
return new Uint8Array(bytes);
|
|
}
|
|
|
|
function strPayload(s) {
|
|
var out = new Uint8Array(s.length);
|
|
for (var i = 0; i < s.length; i++) out[i] = s.charCodeAt(i) & 0x7f;
|
|
return out;
|
|
}
|
|
|
|
function float8Payload(d) {
|
|
var buf = new ArrayBuffer(8);
|
|
new DataView(buf).setFloat64(0, d, false);
|
|
return new Uint8Array(buf);
|
|
}
|
|
|
|
/* frames: [{ data: Uint8Array, timestampMs: number, key: boolean }] */
|
|
function mux(opts) {
|
|
var codecId = opts.codecId; // "V_VP8" | "V_VP9"
|
|
var w = opts.width, h = opts.height;
|
|
var durationMs = opts.durationMs;
|
|
var frames = opts.frames;
|
|
|
|
var header = el(0x1A45DFA3, concat([
|
|
el(0x4286, uintPayload(1)), // EBMLVersion
|
|
el(0x42F7, uintPayload(1)), // EBMLReadVersion
|
|
el(0x42F2, uintPayload(4)), // EBMLMaxIDLength
|
|
el(0x42F3, uintPayload(8)), // EBMLMaxSizeLength
|
|
el(0x4282, strPayload("webm")), // DocType
|
|
el(0x4287, uintPayload(2)), // DocTypeVersion
|
|
el(0x4285, uintPayload(2)) // DocTypeReadVersion
|
|
]));
|
|
|
|
var info = el(0x1549A966, concat([
|
|
el(0x2AD7B1, uintPayload(1000000)), // TimecodeScale: 1ms
|
|
el(0x4489, float8Payload(durationMs)), // Duration
|
|
el(0x4D80, strPayload("lumen")), // MuxingApp
|
|
el(0x5741, strPayload("lumen")) // WritingApp
|
|
]));
|
|
|
|
var tracks = el(0x1654AE6B, el(0xAE, concat([
|
|
el(0xD7, uintPayload(1)), // TrackNumber
|
|
el(0x73C5, uintPayload(1)), // TrackUID
|
|
el(0x83, uintPayload(1)), // TrackType: video
|
|
el(0x9C, uintPayload(0)), // FlagLacing
|
|
el(0x86, strPayload(codecId)), // CodecID
|
|
el(0xE0, concat([ // Video
|
|
el(0xB0, uintPayload(w)), // PixelWidth
|
|
el(0xBA, uintPayload(h)) // PixelHeight
|
|
]))
|
|
])));
|
|
|
|
/* clusters: start a new one every second (relative timecode is int16 ms) */
|
|
var clusters = [];
|
|
var blocks = [];
|
|
var clusterTc = 0;
|
|
|
|
function flushCluster() {
|
|
if (!blocks.length) return;
|
|
clusters.push(el(0x1F43B675, concat(
|
|
[el(0xE7, uintPayload(clusterTc))].concat(blocks)
|
|
)));
|
|
blocks = [];
|
|
}
|
|
|
|
for (var i = 0; i < frames.length; i++) {
|
|
var f = frames[i];
|
|
var tc = Math.round(f.timestampMs);
|
|
if (!blocks.length) clusterTc = tc;
|
|
else if (tc - clusterTc > 1000 || (f.key && tc !== clusterTc)) {
|
|
flushCluster();
|
|
clusterTc = tc;
|
|
}
|
|
var rel = tc - clusterTc;
|
|
var head = new Uint8Array(4);
|
|
head[0] = 0x81; // track 1 (vint)
|
|
head[1] = (rel >> 8) & 0xff;
|
|
head[2] = rel & 0xff;
|
|
head[3] = f.key ? 0x80 : 0x00; // flags
|
|
blocks.push(el(0xA3, concat([head, f.data]))); // SimpleBlock
|
|
}
|
|
flushCluster();
|
|
|
|
var segment = el(0x18538067, concat([info, tracks].concat(clusters)));
|
|
return concat([header, segment]);
|
|
}
|
|
|
|
return { mux: mux };
|
|
})();
|