Fix video export crashing Chrome by using deterministic frame capture

Replace realtime captureStream(60) with manual captureStream(0) +
requestFrame at 30fps, suspend the live render loop during export,
prefer VP8, and cap bitrate to avoid STATUS_BREAKPOINT tab crashes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonxlnx
2026-06-10 00:46:24 +02:00
parent 1fc88c1c4e
commit b0a040fcd0
3 changed files with 89 additions and 30 deletions
+1 -1
View File
@@ -33,7 +33,7 @@ Requires a browser with WebGL2 (Chrome, Edge, Firefox).
## Export ## Export
- **PNG** up to 3840×2160, rendered offscreen at full quality. - **PNG** up to 3840×2160, rendered offscreen at full quality.
- **Video** (WebM VP9/VP8, MP4 where supported) recorded at 60 fps for 14 loops. - **Video** (WebM VP8/VP9) rendered frame-by-frame at 30 fps for 14 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. - **GIF** rendered deterministically frame by frame, encoded in-page with a dependency-free GIF89a encoder (median-cut palette + ordered dithering), infinite loop flag set.
## Files ## Files
+10
View File
@@ -4,6 +4,7 @@
var Engine = (function () { var Engine = (function () {
var canvas, gl, program, uniforms = {}; var canvas, gl, program, uniforms = {};
var playing = true; var playing = true;
var suspended = false;
var loopT = 0; // seconds into current loop var loopT = 0; // seconds into current loop
var lastTick = 0; var lastTick = 0;
var fps = 60, fpsAcc = 0, fpsN = 0, fpsCb = null; var fps = 60, fpsAcc = 0, fpsN = 0, fpsCb = null;
@@ -119,6 +120,8 @@ var Engine = (function () {
} }
function tick(now) { function tick(now) {
if (suspended) return;
var dt = Math.min((now - lastTick) / 1000, 0.1); var dt = Math.min((now - lastTick) / 1000, 0.1);
lastTick = now; lastTick = now;
@@ -157,6 +160,13 @@ var Engine = (function () {
readPixels: readPixels, readPixels: readPixels,
currentPhase: currentPhase, currentPhase: currentPhase,
resetTime: function () { loopT = 0; }, resetTime: function () { loopT = 0; },
setLoopTime: function (t) { loopT = t; },
suspend: function () { suspended = true; },
resume: function () {
suspended = false;
lastTick = performance.now();
requestAnimationFrame(tick);
},
setPlaying: function (v) { playing = v; lastTick = performance.now(); }, setPlaying: function (v) { playing = v; lastTick = performance.now(); },
isPlaying: function () { return playing; }, isPlaying: function () { return playing; },
onFps: function (cb) { fpsCb = cb; }, onFps: function (cb) { fpsCb = cb; },
+78 -29
View File
@@ -54,13 +54,13 @@ var Exporter = (function () {
}, "image/png"); }, "image/png");
} }
/* ---------- Video (realtime capture) ---------- */ /* ---------- Video (deterministic frame capture — avoids captureStream(60) crashes) ---------- */
function pickVideoMime() { function pickVideoMime() {
/* VP8 first: most stable for canvas capture on Chrome/Edge */
var candidates = [ var candidates = [
"video/webm;codecs=vp9",
"video/webm;codecs=vp8", "video/webm;codecs=vp8",
"video/webm", "video/webm;codecs=vp9",
"video/mp4" "video/webm"
]; ];
for (var i = 0; i < candidates.length; i++) { for (var i = 0; i < candidates.length; i++) {
if (window.MediaRecorder && MediaRecorder.isTypeSupported(candidates[i])) return candidates[i]; if (window.MediaRecorder && MediaRecorder.isTypeSupported(candidates[i])) return candidates[i];
@@ -68,49 +68,98 @@ var Exporter = (function () {
return null; 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) { function exportVideo(P, aspect) {
if (busy) return; if (busy) return;
if (!window.MediaRecorder) { UI.toast("Video recording not supported in this browser"); return; }
var mime = pickVideoMime(); var mime = pickVideoMime();
if (!mime) { UI.toast("Video recording not supported in this browser"); return; } if (!mime) { UI.toast("Video recording not supported in this browser"); return; }
busy = true; busy = true;
var prev = Engine.size(); var prev = Engine.size();
var wasPlaying = Engine.isPlaying();
var h = parseInt(P.vidRes, 10); var h = parseInt(P.vidRes, 10);
var w = evenRound(h * aspect); var w = evenRound(h * aspect);
var fps = 30;
var loops = parseInt(P.vidLoops, 10) || 1;
var totalSec = P.loop * loops;
var nFrames = Math.max(2, Math.round(totalSec * fps));
var frameMs = 1000 / fps;
var ext = "webm";
Engine.suspend();
Engine.setPlaying(false);
Engine.setSize(w, h); Engine.setSize(w, h);
showOverlay("Rendering video");
var wasPlaying = Engine.isPlaying(); var canvas = Engine.canvas();
Engine.setPlaying(true); var stream = canvas.captureStream(0);
Engine.resetTime(); 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 durMs = P.loop * P.vidLoops * 1000;
var ext = mime.indexOf("mp4") >= 0 ? "mp4" : "webm";
showOverlay("Recording video");
var stream = Engine.canvas().captureStream(60);
var rec = new MediaRecorder(stream, { mimeType: mime, videoBitsPerSecond: 18000000 });
var parts = []; var parts = [];
rec.ondataavailable = function (e) { if (e.data.size) parts.push(e.data); }; 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 () { rec.onstop = function () {
Engine.setSize(prev[0], prev[1]); finishVideo(prev, wasPlaying, parts.length ? new Blob(parts, { type: mime }) : null, w, h, ext, totalSec);
Engine.setPlaying(wasPlaying); };
rec.start(250);
var finished = false;
captureVideoFrames(0);
function finishVideo(prevSize, resumePlaying, blob, width, height, extension, seconds) {
if (finished) return;
finished = true;
Engine.setSize(prevSize[0], prevSize[1]);
Engine.setPlaying(resumePlaying);
Engine.resume();
hideOverlay(); hideOverlay();
busy = false; busy = false;
if (!cancelled) { if (blob && !cancelled) {
download(new Blob(parts, { type: mime }), stamp(P, ext)); download(blob, stamp(P, extension));
UI.toast("Saved " + (durMs / 1000).toFixed(1) + "s " + ext.toUpperCase() + " (" + w + "\u00d7" + h + ")"); UI.toast("Saved " + seconds.toFixed(1) + "s " + extension.toUpperCase() + " (" + width + "\u00d7" + height + ")");
} }
}; }
rec.start(200);
var t0 = performance.now(); function captureVideoFrames(f) {
(function poll() { if (cancelled || f >= nFrames) {
var el = performance.now() - t0; try { rec.stop(); } catch (ignore) {}
if (cancelled) { rec.stop(); return; } return;
setProgress(Math.min(el / durMs, 1), (el / 1000).toFixed(1) + "s / " + (durMs / 1000).toFixed(1) + "s \u00b7 " + w + "\u00d7" + h); }
if (el >= durMs) { rec.stop(); return; }
requestAnimationFrame(poll); 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);
}
} }
/* ---------- GIF (offline, deterministic, perfect loop) ---------- */ /* ---------- GIF (offline, deterministic, perfect loop) ---------- */