Replace MediaRecorder video export with WebCodecs + custom WebM muxer
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>
This commit is contained in:
@@ -33,8 +33,8 @@ Requires a browser with WebGL2 (Chrome, Edge, Firefox).
|
||||
## Export
|
||||
|
||||
- **PNG** up to 3840×2160, rendered offscreen at full quality.
|
||||
- **Video** (WebM VP8/VP9) rendered frame-by-frame at 30 fps for 1–4 loops. Uses manual canvas capture instead of realtime 60 fps streaming, which avoids browser crashes on heavy WebGL shaders.
|
||||
- **GIF** rendered deterministically frame by frame, encoded in-page with a dependency-free GIF89a encoder (median-cut palette + ordered dithering), infinite loop flag set.
|
||||
- **Video** (WebM VP9/VP8) encoded offline with **WebCodecs** (`VideoEncoder`) and muxed by a built-in dependency-free WebM writer — no `MediaRecorder`, no realtime capture, no tab crashes. Configurable fps (24/30/60), resolution (720p–1440p) and exact length (1–8 loops or 5–60 seconds).
|
||||
- **GIF** rendered deterministically frame by frame, encoded in-page with a dependency-free GIF89a encoder (median-cut palette + ordered dithering). Loop forever or play once.
|
||||
|
||||
## Files
|
||||
|
||||
@@ -42,5 +42,6 @@ Requires a browser with WebGL2 (Chrome, Edge, Firefox).
|
||||
- `js/shaders.js` — the uber fragment shader with all 9 modes
|
||||
- `js/engine.js` — WebGL2 engine and render loop
|
||||
- `js/gifenc.js` — GIF encoder (quantizer + LZW)
|
||||
- `js/webmmux.js` — WebM/Matroska muxer for WebCodecs output
|
||||
- `js/exporter.js` — PNG / video / GIF pipelines
|
||||
- `js/palettes.js`, `js/ui.js`, `js/main.js` — palettes, control builders, state and randomizer
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
<script src="js/shaders.js"></script>
|
||||
<script src="js/engine.js"></script>
|
||||
<script src="js/gifenc.js"></script>
|
||||
<script src="js/webmmux.js"></script>
|
||||
<script src="js/exporter.js"></script>
|
||||
<script src="js/ui.js"></script>
|
||||
<script src="js/main.js"></script>
|
||||
|
||||
+121
-79
@@ -54,111 +54,153 @@ var Exporter = (function () {
|
||||
}, "image/png");
|
||||
}
|
||||
|
||||
/* ---------- Video (deterministic frame capture — avoids captureStream(60) crashes) ---------- */
|
||||
function pickVideoMime() {
|
||||
/* VP8 first: most stable for canvas capture on Chrome/Edge */
|
||||
/* ---------- Video (WebCodecs: deterministic offline encode, no MediaRecorder) ---------- */
|
||||
|
||||
function videoDurationSec(P) {
|
||||
var v = String(P.vidLen || "l2");
|
||||
if (v.charAt(0) === "s") return Math.max(1, parseInt(v.slice(1), 10) || 5);
|
||||
return P.loop * Math.max(1, parseInt(v.slice(1), 10) || 1);
|
||||
}
|
||||
|
||||
function videoBitrate(w, h, fps) {
|
||||
var px = w * h;
|
||||
var base = px >= 2560 * 1440 ? 14000000 : px >= 1920 * 1080 ? 9000000 : px >= 1280 * 720 ? 6000000 : 3500000;
|
||||
return fps >= 60 ? Math.round(base * 1.4) : base;
|
||||
}
|
||||
|
||||
async function pickEncoderConfig(w, h, fps) {
|
||||
var candidates = [
|
||||
"video/webm;codecs=vp8",
|
||||
"video/webm;codecs=vp9",
|
||||
"video/webm"
|
||||
{ codec: "vp09.00.10.08", codecId: "V_VP9" },
|
||||
{ codec: "vp8", codecId: "V_VP8" }
|
||||
];
|
||||
for (var i = 0; i < candidates.length; i++) {
|
||||
if (window.MediaRecorder && MediaRecorder.isTypeSupported(candidates[i])) return candidates[i];
|
||||
var cfg = {
|
||||
codec: candidates[i].codec,
|
||||
width: w, height: h,
|
||||
bitrate: videoBitrate(w, h, fps),
|
||||
framerate: fps
|
||||
};
|
||||
try {
|
||||
var sup = await VideoEncoder.isConfigSupported(cfg);
|
||||
if (sup && sup.supported) return { config: cfg, codecId: candidates[i].codecId };
|
||||
} catch (e) { /* try next codec */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function recorderOptions(mime, w, h) {
|
||||
var px = w * h;
|
||||
var bps = px >= 1920 * 1080 ? 6000000 : px >= 1280 * 720 ? 4000000 : 2500000;
|
||||
return { mimeType: mime, videoBitsPerSecond: bps };
|
||||
}
|
||||
|
||||
function exportVideo(P, aspect) {
|
||||
async function exportVideo(P, aspect) {
|
||||
if (busy) return;
|
||||
if (!window.MediaRecorder) { UI.toast("Video recording not supported in this browser"); return; }
|
||||
var mime = pickVideoMime();
|
||||
if (!mime) { UI.toast("Video recording not supported in this browser"); return; }
|
||||
if (typeof VideoEncoder === "undefined" || typeof VideoFrame === "undefined") {
|
||||
UI.toast("This browser has no WebCodecs support \u2014 use a current Chrome, Edge or Firefox");
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
|
||||
var prev = Engine.size();
|
||||
var wasPlaying = Engine.isPlaying();
|
||||
var h = parseInt(P.vidRes, 10);
|
||||
var w = evenRound(h * aspect);
|
||||
var fps = 30;
|
||||
var loops = parseInt(P.vidLoops, 10) || 1;
|
||||
var totalSec = P.loop * loops;
|
||||
var fps = parseInt(P.vidFps, 10) || 30;
|
||||
var totalSec = videoDurationSec(P);
|
||||
var nFrames = Math.max(2, Math.round(totalSec * fps));
|
||||
var frameMs = 1000 / fps;
|
||||
var ext = "webm";
|
||||
|
||||
showOverlay("Rendering video");
|
||||
Engine.suspend();
|
||||
Engine.setPlaying(false);
|
||||
Engine.setSize(w, h);
|
||||
showOverlay("Rendering video");
|
||||
|
||||
var canvas = Engine.canvas();
|
||||
var stream = canvas.captureStream(0);
|
||||
var track = stream.getVideoTracks()[0];
|
||||
var rec;
|
||||
try {
|
||||
rec = new MediaRecorder(stream, recorderOptions(mime, w, h));
|
||||
} catch (err) {
|
||||
try {
|
||||
rec = new MediaRecorder(stream, { mimeType: mime });
|
||||
} catch (err2) {
|
||||
finishVideo(prev, wasPlaying, null, w, h, ext, 0);
|
||||
UI.toast("Video recording failed: " + (err2.message || "unsupported codec"));
|
||||
return;
|
||||
}
|
||||
var picked = await pickEncoderConfig(w, h, fps);
|
||||
if (!picked) {
|
||||
restore();
|
||||
UI.toast("No supported video codec (VP9/VP8) found");
|
||||
return;
|
||||
}
|
||||
|
||||
var parts = [];
|
||||
rec.ondataavailable = function (e) { if (e.data && e.data.size) parts.push(e.data); };
|
||||
rec.onerror = function () {
|
||||
cancelled = true;
|
||||
try { rec.stop(); } catch (ignore) {}
|
||||
finishVideo(prev, wasPlaying, null, w, h, ext, totalSec);
|
||||
UI.toast("Video recording failed");
|
||||
};
|
||||
rec.onstop = function () {
|
||||
finishVideo(prev, wasPlaying, parts.length ? new Blob(parts, { type: mime }) : null, w, h, ext, totalSec);
|
||||
};
|
||||
var encFrames = [];
|
||||
var encError = null;
|
||||
var encoder = new VideoEncoder({
|
||||
output: function (chunk) {
|
||||
var data = new Uint8Array(chunk.byteLength);
|
||||
chunk.copyTo(data);
|
||||
encFrames.push({
|
||||
data: data,
|
||||
timestampMs: chunk.timestamp / 1000,
|
||||
key: chunk.type === "key"
|
||||
});
|
||||
},
|
||||
error: function (e) { encError = e; }
|
||||
});
|
||||
encoder.configure(picked.config);
|
||||
|
||||
rec.start(250);
|
||||
var finished = false;
|
||||
captureVideoFrames(0);
|
||||
var canvas = Engine.canvas();
|
||||
var usPerFrame = 1e6 / fps;
|
||||
|
||||
function finishVideo(prevSize, resumePlaying, blob, width, height, extension, seconds) {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
Engine.setSize(prevSize[0], prevSize[1]);
|
||||
Engine.setPlaying(resumePlaying);
|
||||
try {
|
||||
for (var f = 0; f < nFrames; f++) {
|
||||
if (cancelled || encError) break;
|
||||
|
||||
var t = f / fps;
|
||||
Engine.setLoopTime(t % P.loop);
|
||||
Engine.renderAt((t % P.loop) / P.loop);
|
||||
|
||||
var vf = new VideoFrame(canvas, {
|
||||
timestamp: Math.round(f * usPerFrame),
|
||||
duration: Math.round(usPerFrame)
|
||||
});
|
||||
/* keyframe every 2 seconds keeps files small and seekable */
|
||||
encoder.encode(vf, { keyFrame: f % (fps * 2) === 0 });
|
||||
vf.close();
|
||||
|
||||
setProgress(0.9 * (f + 1) / nFrames,
|
||||
"frame " + (f + 1) + "/" + nFrames + " \u00b7 " + w + "\u00d7" + h + " @ " + fps + "fps");
|
||||
|
||||
/* backpressure: never let the encoder queue grow unbounded */
|
||||
while (encoder.encodeQueueSize > 2) await wait(2);
|
||||
if (f % 8 === 7) await wait(0);
|
||||
}
|
||||
|
||||
if (!cancelled && !encError) {
|
||||
setProgress(0.93, "finalizing encode");
|
||||
await encoder.flush();
|
||||
}
|
||||
} catch (e) {
|
||||
encError = e;
|
||||
}
|
||||
|
||||
try { encoder.close(); } catch (ignore) {}
|
||||
|
||||
/* restore live view before the (fast) muxing step */
|
||||
Engine.setSize(prev[0], prev[1]);
|
||||
Engine.setPlaying(wasPlaying);
|
||||
Engine.resume();
|
||||
|
||||
if (cancelled) { hideOverlay(); busy = false; return; }
|
||||
if (encError || !encFrames.length) {
|
||||
hideOverlay(); busy = false;
|
||||
UI.toast("Video encode failed" + (encError && encError.message ? ": " + encError.message : ""));
|
||||
return;
|
||||
}
|
||||
|
||||
setProgress(0.97, "writing webm container");
|
||||
await wait(0);
|
||||
var webm = WebMMux.mux({
|
||||
codecId: picked.codecId,
|
||||
width: w, height: h,
|
||||
durationMs: totalSec * 1000,
|
||||
frames: encFrames
|
||||
});
|
||||
|
||||
hideOverlay();
|
||||
busy = false;
|
||||
download(new Blob([webm], { type: "video/webm" }), stamp(P, "webm"));
|
||||
UI.toast("Saved " + totalSec.toFixed(1) + "s WEBM \u00b7 " + nFrames + " frames \u00b7 " + w + "\u00d7" + h + " @ " + fps + "fps");
|
||||
|
||||
function restore() {
|
||||
Engine.setSize(prev[0], prev[1]);
|
||||
Engine.setPlaying(wasPlaying);
|
||||
Engine.resume();
|
||||
hideOverlay();
|
||||
busy = false;
|
||||
if (blob && !cancelled) {
|
||||
download(blob, stamp(P, extension));
|
||||
UI.toast("Saved " + seconds.toFixed(1) + "s " + extension.toUpperCase() + " (" + width + "\u00d7" + height + ")");
|
||||
}
|
||||
}
|
||||
|
||||
function captureVideoFrames(f) {
|
||||
if (cancelled || f >= nFrames) {
|
||||
try { rec.stop(); } catch (ignore) {}
|
||||
return;
|
||||
}
|
||||
|
||||
var t = f / fps;
|
||||
var phase = (t % P.loop) / P.loop;
|
||||
Engine.setLoopTime(t % P.loop);
|
||||
Engine.renderAt(phase);
|
||||
if (track && track.requestFrame) track.requestFrame();
|
||||
|
||||
setProgress((f + 1) / nFrames,
|
||||
"frame " + (f + 1) + "/" + nFrames + " \u00b7 " + w + "\u00d7" + h + " @ " + fps + "fps");
|
||||
|
||||
setTimeout(function () { captureVideoFrames(f + 1); }, frameMs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,7 +237,7 @@ var Exporter = (function () {
|
||||
|
||||
var data = await GIFEnc.encode({
|
||||
frames: frames, width: w, height: h, fps: fps,
|
||||
dither: P.gifDither,
|
||||
dither: P.gifDither, loop: P.gifLoop,
|
||||
onProgress: function (frac, detail) { setProgress(0.4 + 0.6 * frac, "encoding \u00b7 " + detail); },
|
||||
isCancelled: function () { return cancelled; }
|
||||
});
|
||||
|
||||
+6
-4
@@ -186,10 +186,12 @@ var GIFEnc = (function () {
|
||||
out.byte(col[0]); out.byte(col[1]); out.byte(col[2]);
|
||||
}
|
||||
|
||||
/* NETSCAPE loop forever */
|
||||
out.byte(0x21); out.byte(0xFF); out.byte(11);
|
||||
out.string("NETSCAPE2.0");
|
||||
out.byte(3); out.byte(1); out.short(0); out.byte(0);
|
||||
/* NETSCAPE loop extension: 0 = forever. Omitted entirely -> play once. */
|
||||
if (opts.loop !== false) {
|
||||
out.byte(0x21); out.byte(0xFF); out.byte(11);
|
||||
out.string("NETSCAPE2.0");
|
||||
out.byte(3); out.byte(1); out.short(0); out.byte(0);
|
||||
}
|
||||
|
||||
var delay = Math.max(2, Math.round(100 / fps));
|
||||
var indices = new Uint8Array(w * h);
|
||||
|
||||
+8
-3
@@ -36,8 +36,8 @@ var P = {
|
||||
grain: 0.05, cell: 90, lines: 56, ca: 0.25, vig: 0.35, soft: 0.95,
|
||||
travel: 0.7, loop: 5,
|
||||
lockStyle: false,
|
||||
imgRes: "2160", vidRes: "1080", vidLoops: 2,
|
||||
gifW: "640", gifFps: 25, gifDither: true,
|
||||
imgRes: "2160", vidRes: "1080", vidFps: "30", vidLen: "l2",
|
||||
gifW: "640", gifFps: 25, gifDither: true, gifLoop: true,
|
||||
aspect: "16:9"
|
||||
};
|
||||
|
||||
@@ -222,10 +222,15 @@ function buildRail() {
|
||||
var sExp = UI.section(rail, "Export", null);
|
||||
reg(UI.selectRow(sExp, { label: "Image size", options: [["1080", "1920 \u00d7 1080"], ["1440", "2560 \u00d7 1440"], ["2160", "3840 \u00d7 2160"]], get: get("imgRes"), set: set("imgRes") }));
|
||||
reg(UI.selectRow(sExp, { label: "Video size", options: [["720", "720p"], ["1080", "1080p"], ["1440", "1440p"]], get: get("vidRes"), set: set("vidRes") }));
|
||||
reg(UI.selectRow(sExp, { label: "Video loops", options: [["1", "1 loop"], ["2", "2 loops"], ["3", "3 loops"], ["4", "4 loops"]], get: function () { return String(P.vidLoops); }, set: function (v) { P.vidLoops = parseInt(v, 10); } }));
|
||||
reg(UI.selectRow(sExp, { label: "Video fps", options: [["24", "24 fps"], ["30", "30 fps"], ["60", "60 fps"]], get: get("vidFps"), set: set("vidFps") }));
|
||||
reg(UI.selectRow(sExp, { label: "Video length", options: [
|
||||
["l1", "1 loop"], ["l2", "2 loops"], ["l3", "3 loops"], ["l4", "4 loops"], ["l6", "6 loops"], ["l8", "8 loops"],
|
||||
["s5", "5 seconds"], ["s10", "10 seconds"], ["s15", "15 seconds"], ["s30", "30 seconds"], ["s60", "60 seconds"]
|
||||
], get: get("vidLen"), set: set("vidLen") }));
|
||||
reg(UI.selectRow(sExp, { label: "GIF width", options: [["360", "360 px"], ["480", "480 px"], ["640", "640 px"], ["800", "800 px"]], get: get("gifW"), set: set("gifW") }));
|
||||
reg(UI.selectRow(sExp, { label: "GIF fps", options: [["15", "15 fps"], ["20", "20 fps"], ["25", "25 fps"], ["30", "30 fps"]], get: function () { return String(P.gifFps); }, set: function (v) { P.gifFps = parseInt(v, 10); } }));
|
||||
reg(UI.toggleRow(sExp, { label: "GIF dithering", get: get("gifDither"), set: set("gifDither") }));
|
||||
reg(UI.toggleRow(sExp, { label: "GIF loop forever", get: get("gifLoop"), set: set("gifLoop") }));
|
||||
|
||||
var grid = UI.el("div", "export-grid", sExp);
|
||||
UI.exportButton(grid, "Save image", "PNG",
|
||||
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
/* 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 };
|
||||
})();
|
||||
Reference in New Issue
Block a user