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:
@@ -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 1–4 loops.
|
- **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.
|
- **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
|
||||||
|
|||||||
@@ -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
@@ -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) ---------- */
|
||||||
|
|||||||
Reference in New Issue
Block a user